Compare commits

..
Author SHA1 Message Date
rahulramesha 32b4019a69 add Layout options while creating a new view 2024-06-19 16:32:31 +05:30
Aaryan KhandelwalandGitHub 135024a940 [WEB-1397] chore: update space app types (#4861)
* chore: update types

* chore: dummy page actions components

* chore: rename component

* refactor: rearrange declarations
2024-06-18 17:51:52 +05:30
rahulrameshaandGitHub 8500c63205 fix issue pagination cursor logic (#4857) 2024-06-18 17:45:24 +05:30
Prateek ShouryaandGitHub c8736f13ec [WEB-1635] style: fix vertical section tabs highlights. (#4855)
* [WEB-1635] style: fix vertical section tabs highlights.

* fix: highlights in archive tabs.
2024-06-18 16:59:15 +05:30
Prateek ShouryaandGitHub e43b4b3d47 [WEB-1611] style: fixed state column width for consistency. (#4859) 2024-06-18 16:17:51 +05:30
Aaryan KhandelwalandGitHub 59f0e9fe2c [WEB-1397] fix: UI package logo component (#4858)
* fix: logo component

* chore: add missing anchor observable
2024-06-18 16:16:50 +05:30
guru_sainathandGitHub 56956d8786 fix: updated the ui inconsistancy in active cycle estimate dropdown (#4860) 2024-06-18 16:15:26 +05:30
guru_sainathandGitHub cc455b0e76 [WEB-1640] fix: validating the error in estimate delete (#4853)
* fix: validating the error in estimate delete

* fix: moved estimate delete to ee
2024-06-18 16:14:24 +05:30
Aaryan KhandelwalandGitHub 8705a96220 dev: create a global component for emoji/icon logo (#4851) 2024-06-18 14:48:23 +05:30
190c85468b [WEB - 1418] chore: events for auth & onboarding (#4576)
* chore: onboarding events added

* chore: updated events

* event payload updated

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-18 14:14:46 +05:30
guru_sainathandGitHub d3d723cadd [WEB-1632] fix: validating and showing proper alert estimate point has to be taken greater than 0 in create and update (#4850)
* fix: validating and showing proper alert estimate point has to be taken greater than 0 in create and update

* fix: updating the number point validation
2024-06-18 13:31:23 +05:30
Prateek ShouryaandGitHub 6828d33c3f [WEB-1634] dev: update application router to support n-progress. (#4846)
* [WEB-1634] dev: update application router to support n-progress.

* chore: update app router initilization logic.

* fix: lint errors
2024-06-18 11:35:20 +05:30
rahulrameshaandGitHub 10e67144a0 fix the bug that incorrectly updates issue store for array properties during bulk ops (#4849) 2024-06-18 11:34:57 +05:30
Prateek ShouryaandGitHub 0e63128d45 [WEB-1589] chore: fix list group header padding. (#4848) 2024-06-17 20:49:41 +05:30
c9cf7cc631 [WEB-1397] refactor: edition specific migration (#4847)
* refactor: edition specific migration

* revert: pagination from space endpoints

* fix: project publish

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-17 20:09:15 +05:30
Aaryan KhandelwalandGitHub 413d6d21b4 [WEB-1397] chore: add anchor key to page type (#4844)
* chore: add anchor key to page type

* chore: add anchor key to page type
2024-06-17 20:02:02 +05:30
Bavisetti NarayanandGitHub 909fe128f1 chore: change one estimate point to another (#4845) 2024-06-17 18:26:49 +05:30
sriram veeraghanta 6a997bb9da fix: workspace help section badge 2024-06-17 18:03:32 +05:30
Prateek ShouryaandGitHub 0dc0a2a8a8 [WEB-1612] chore: add length validation for state name. (#4837)
* [WEB-1612] chore: add length validation for state name.

* style: update error message padding.
2024-06-17 17:03:26 +05:30
rahulrameshaandGitHub e01e736ffe fix bulk issues selection checkboxes in list view (#4842) 2024-06-17 16:53:33 +05:30
Aaryan KhandelwalandGitHub ae3dcc3dbd [WEB-1397] chore: update project publish types (#4841)
* chore: update project publish types

* chore: added additional entity name

* chore: updated types
2024-06-17 16:51:38 +05:30
Prateek ShouryaandGitHub c4f5093492 [WEB-1630] chore: fix project doesn't exist empty state fluctuation on refresh. (#4835) 2024-06-17 16:50:56 +05:30
Prateek ShouryaandGitHub cf8053825b [WEB-1551] fix: theme mutation when custom theme is applied. (#4838) 2024-06-17 16:49:42 +05:30
Satish GandhamandGitHub fc3e63f67a Add some new eslint rules and fix the corresponding errors (#4839) 2024-06-17 16:45:35 +05:30
Anmol Singh BhatiaandGitHub c5cac27026 [WEB-1622] chore: profile setting layout improvement (#4840)
* chore: profile setting content header and wrapper component added

* chore: profile setting content header and wrapper component added

* chore: profile settings layout code refactor
2024-06-17 16:32:36 +05:30
rahulrameshaandGitHub 072e8213f0 fix error while switching grouped by (#4843) 2024-06-17 16:23:17 +05:30
sriram veeraghanta 5eb8e76b3b fix: turbo upgrade 2024-06-17 16:15:14 +05:30
Manish GuptaandGitHub bd0799f5e7 replaced redis docker images with valkey/valkey (#4836) 2024-06-17 14:39:43 +05:30
aba2af9a7c [WEB-1559] chore: updated pages response (#4821)
* dev: fix pages responses

* chore: updated pages response

* fix: search endpoint

* fix: pages delete endpoint

* fix: command k pages response

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-17 13:56:41 +05:30
1028ec8735 [WEB-1617] chore: created by implementation (#4831)
* chore: created by implementation

* Fix lint issue

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-06-17 11:47:19 +05:30
Anmol Singh BhatiaandGitHub c99579cddc [WEB-1600] chore: issue detail ui enhancement (#4832)
* chore: archived issue header consistency

* chore: restor banner removed from issue detail page

* chore: issue detail quick action component added

* chore: moved sidebar issue quick action to app header
2024-06-15 18:12:18 +05:30
rahulrameshaandGitHub bba10d7073 [WEB-1603] fix: load more issues when issues are deleted (#4830)
* fix load more issues when issues are deleted

* fix build
2024-06-15 11:07:04 +05:30
rahulrameshaandGitHub a8f4d21e8b [WEB-1596] fix: empty groups for issue list and kanban (#4829)
* fix empty groups in kanban and list

* fix build error
2024-06-14 20:46:04 +05:30
Prateek ShouryaandGitHub c5731ce454 fix: duplicate import lint error. (#4827) 2024-06-14 19:27:21 +05:30
guru_sainathandGitHub 8f091b7d7e [WEB-522] chore: estimate point active cycles pending count and fixed burndown graph total issues (#4825)
* chore: updated active cycles count and graph payload

* chore: updated mobx observer
2024-06-14 19:06:24 +05:30
NikhilandGitHub 84236f506b fix: gitlab authentication (#4826) 2024-06-14 18:29:12 +05:30
sriram veeraghanta 5bbb796e5e fix: migration order 2024-06-14 17:59:45 +05:30
Prateek ShouryaandGitHub 59256588db [WEB-1628] style: fix admin app telmetry checkbox on setup page. (#4824) 2024-06-14 17:42:50 +05:30
NikhilandGitHub 2a740b9cd9 [WEB - 1604]fix: pagination on many to many fields when grouping and sub grouping (#4818)
* fix: pagination on module grouping

* fix: pagination on grouping with m2m fields
2024-06-14 17:40:33 +05:30
Anmol Singh BhatiaandGitHub 831a336690 [WEB-1613] chore: material logo loader (#4823)
* chore: material logo loader added

* chore: material logo loader added
2024-06-14 17:37:05 +05:30
Aaryan KhandelwalandGitHub 244986554c chore: move modal core components to the UI package (#4794)
* chore: move modal core components to the UI package

* fix: build errors
2024-06-14 17:12:39 +05:30
Prateek ShouryaandGitHub 0aca5c7a86 [WEB-1601] fix: archive issues mutation. (#4815) 2024-06-14 17:09:49 +05:30
71c77d30a0 fix: extra indexed db update on mount (reload) causing repeated popups of unsaved changes (#4808)
* docs: update self-host guide link in README (#4704)

found via:

- https://github.com/makeplane/docs/issues/48
- https://github.com/makeplane/plane/pull/3109

* fix: extra indexed db update on mount causing repeated popups on unload

* revert: old stuff

---------

Co-authored-by: jon ⚝ <jon@allmende.io>
2024-06-14 17:01:36 +05:30
Prateek ShouryaandGitHub c5b1d95c76 fix: god mode redireciton without trailing slash. (#4811) 2024-06-14 17:00:35 +05:30
guru_sainathandGitHub 707c4f9e8d [WEB-522] chore: handled numeric validation on estimate point update in categories (#4819)
* dev: handled numeric validation on estimate point update

* chore: updated number comparision in estimate category type
2024-06-14 16:51:26 +05:30
Prateek ShouryaandGitHub fd9f0fb17c [WEB-1607[ fix: state dropwdown default state. (#4816) 2024-06-14 16:45:06 +05:30
d1bfed950a [WEB-1608] fix: deleted project errors (#4820)
* dev: fix project not found error

* [WEB-1608] chore: fix no projects found logic.

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-06-14 16:39:25 +05:30
guru_sainathandGitHub cfbc0cf2e1 chore: ui fix and validation on estimare on active cycles estimate in project cycles (#4822) 2024-06-14 16:38:48 +05:30
sriram veeraghanta 299e220d08 fix: adding constant package. 2024-06-14 15:29:27 +05:30
sriram veeraghanta 64bbe19f1b fix: merging gitlab changes 2024-06-14 15:23:30 +05:30
sriram veeraghanta 92ea5998c5 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-06-14 15:01:51 +05:30
jon ⚝andGitHub 99e1963d9b feat: add GitLab OAuth client (#4692) 2024-06-14 14:55:59 +05:30
guru_sainathandGitHub b3626d815f [WEB-522] chore: estimate restructure and handled error while creating estimates (#4817)
* dev:updated estimate UI

* dev: updated error messages in estimate point create
2024-06-14 13:23:58 +05:30
sriram veeraghanta 9145234a6c fix: core store import fixes 2024-06-14 03:50:25 +05:30
sriram veeraghanta 78d4d981d1 fix: core root store import fixes 2024-06-14 03:37:18 +05:30
sriram veeraghanta c9147e7a57 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-06-13 21:17:17 +05:30
sriram veeraghanta b8ee986636 fix: eslint config and errors 2024-06-13 21:17:06 +05:30
rahulrameshaandGitHub f5f5726e15 fix ee sync error on base issues store (#4812) 2024-06-13 21:15:20 +05:30
sriram veeraghanta a72d095e60 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-06-13 20:30:58 +05:30
sriram veeraghanta 5183f4439b fix: eslint warnings 2024-06-13 20:30:22 +05:30
Aaryan KhandelwalandGitHub e5d2902a41 chore: add woprkspace pages empty state (#4806) 2024-06-13 17:58:45 +05:30
Satish GandhamandGitHub 7f97f234d0 Add missing exports (#4807) 2024-06-13 17:41:22 +05:30
Aaryan KhandelwalandGitHub 645764e53d chore: update page search input (#4805) 2024-06-13 16:56:48 +05:30
rahulrameshaandGitHub d75e33ccf0 fix issue mutation and count (#4804) 2024-06-13 16:55:42 +05:30
Anmol Singh BhatiaandGitHub ab3a00dd6e chore: admin sidebar improvement (#4802) 2024-06-13 15:52:21 +05:30
guru_sainathandGitHub 52617baf0e [WEB-522] chore:Estimate structure (#4801)
* dv: seperating constants for ce and ee

* dev: update estimate constants

* dev: updated estimate structure for ce and ee
2024-06-13 15:51:41 +05:30
Anmol Singh BhatiaandGitHub ee4ad580fc chore: auth email input one password extenction improvement (#4791) 2024-06-13 14:50:36 +05:30
d9c8271f35 chore: build error fixes and code cleanup. (#4800)
* chore: add export for store.

* chore: remove pages route group.

* fix: upgrading turbo

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-13 14:49:59 +05:30
Aaryan KhandelwalandGitHub e79b0c40ec chore: rename page store (#4799) 2024-06-13 13:56:33 +05:30
Prateek ShouryaandGitHub 0b4b092eaa fix: add default export for sidebar. (#4798) 2024-06-13 13:43:51 +05:30
Aaryan KhandelwalandGitHub 703aac597c chore: create extended root store (#4796)
* chore: create extended root store

* chore: rename core root store
2024-06-13 13:27:13 +05:30
Aaryan KhandelwalandGitHub d81a476e0b [WEB-1559] chore: add page prop to the quick actions dropdown (#4782)
* chore: add pageLink prop to the quick actions dropdown

* chore: accept page store as a prop
2024-06-13 12:45:00 +05:30
Aaryan KhandelwalandGitHub ec955e064b [WEB-1559] chore: App switcher component added (#4793)
* chore: dummy app switcher component added

* chore: add export statement for the logo
2024-06-13 12:44:03 +05:30
Anmol Singh BhatiaandGitHub 34e1be7ba0 fix: headless ui version updated (#4789) 2024-06-12 19:36:37 +05:30
Prateek ShouryaandGitHub 47dfb8797f [WEB-1564] chore: add interceptor to handle 401 errors and redirect users to login page. (#4788) 2024-06-12 19:18:48 +05:30
Anmol Singh BhatiaandGitHub 4e5ca88e5e style: list layout load more border (#4787) 2024-06-12 19:18:05 +05:30
bdd43f3f24 feat(configure_instance): check mandatory variables before starting (#4760)
* docs: update self-host guide link in README (#4704)

found via:

- https://github.com/makeplane/docs/issues/48
- https://github.com/makeplane/plane/pull/3109

* feat(configure_instance): add check for mandatory variables before starting

* fix(configure_instance): use correct variable and improve the exception

* fix(configure_instance): remove trailling spaces

* fix(configure_instance): check the mandatory value from exported env only

* fix(configure_instance): remove useless import

* fix(configure_command): improve the way error is raising

---------

Co-authored-by: jon ⚝ <jon@allmende.io>
2024-06-12 19:13:55 +05:30
cb593538e4 [WEB - 1552]chore: attach pages to multiple projects (#4741)
* dev: pages migrations

* dev: page models

* dev: api migrations

* chore: apis for pages migrations

* chore: dropped project id from page label and logs

* dev: pages logger exception

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-12 19:09:21 +05:30
61d8586f7f [WEB-522] chore: enabled estimate point analytics for module and cycle (#4763)
* chore: updated modal and form validations

* chore: module estimate analytics

* chore: state analytics

* chore: cycle estimate analytics

* chore: module points serializer

* chore: added fields in serializer

* chore: module state estimate points

* dev: updated module analytics

* dev: updated hover description on the burndown

* dev: UI and module total percentage validation

* chore: estimate points structure change

* chore: module burndown

* chore: key values changed

* chore: cycle progress snapshot

* chore: cycle detail endpoint

* chore: progress snapshot payload change

* chore: resolved merge conflicts

* chore: updated issue and point dropdown in active cycle

* chore: optimized grouped issues count in cycle and module

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-06-12 19:02:27 +05:30
NikhilandGitHub 8071350640 fix: pagination grouping when grouping by created_by (#4783) 2024-06-12 18:55:44 +05:30
Prateek ShouryaandGitHub d64cc44225 [WEB-1597] fix: toast themes. (#4781) 2024-06-12 18:48:45 +05:30
Anmol Singh BhatiaandGitHub a0152cab55 style: spreadsheet layout loader border (#4786) 2024-06-12 18:48:18 +05:30
Bavisetti NarayanandGitHub aeec219f6d chore: duplicated project collaborators (#4784) 2024-06-12 18:45:45 +05:30
NikhilandGitHub 3165c3b1d3 fix: magic code when smtp is not configured (#4767) 2024-06-12 18:45:16 +05:30
rahulrameshaandGitHub 060fe8ebcc cancel issues API call when new call are being made (#4785) 2024-06-12 18:27:33 +05:30
Anmol Singh BhatiaandGitHub 7677f021a9 [WEB-1580] chore: drag handler and sidebar improvement (#4780)
* chore: sidebar width reduced

* chore: drag handler added to project sidebar and ui improvement

* chore: label drag handler added
2024-06-12 18:25:57 +05:30
NikhilandGitHub 64619bf5eb dev: fix issue export (#4777) 2024-06-12 16:00:05 +05:30
Prateek ShouryaandGitHub cf13ac3116 [WEB-1578] chore: add max length validation for workspace slug in create workspace form. (#4778) 2024-06-12 15:44:06 +05:30
Prateek ShouryaandGitHub 28fc8ccd0e [WEB-1565] fix: onboarding steps logic. (#4779) 2024-06-12 15:42:05 +05:30
Anmol Singh BhatiaandGitHub eb34dcaaaa fix: issue title input flicker (#4775) 2024-06-12 15:06:42 +05:30
Aaryan KhandelwalandGitHub aa92ace57f [WEB-1559] chore: make AI assistant endpoint workspace level (#4770)
* chore: make ai assistant endpoint workspace level

* chore: create workspace level ai assistant endpoint
2024-06-12 15:01:53 +05:30
Anmol Singh BhatiaandGitHub f4ceaaf01c [WEB-1574] chore: page head (#4773)
* chore: page head component refactor

* chore: page head component refactor
2024-06-12 13:12:08 +05:30
guru_sainathandGitHub 8ccd37d777 chore: handled next_path in auth wrapper (#4774) 2024-06-12 13:10:03 +05:30
rahulrameshaandGitHub afe723ee3d [WEB-1548] chore: Remove Drag Handles (#4769)
* gantt remove drag handles

* remove drag handles for labels and project sidebar develop
2024-06-12 12:08:19 +05:30
Anmol Singh BhatiaandGitHub 679db712cf [WEB-1574] dev: page head hook (#4768)
* dev: page head custom hook added

* chore: code refactor
2024-06-11 23:17:39 +05:30
Anmol Singh BhatiaandGitHub 3e34699194 chore: page loader improvement (#4753) 2024-06-11 18:03:02 +05:30
Anmol Singh BhatiaandGitHub ee655a045c chore: spreadsheet layout assignee column improvement (#4756) 2024-06-11 18:02:48 +05:30
Anmol Singh BhatiaandGitHub 12f101f52a fix: dashboard empty state flicker (#4755) 2024-06-11 18:01:56 +05:30
e3ebb9b61c chore: files restructure (#4766)
* chore: bulk operations folder restructuring.

* fix: build errors

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-11 17:53:48 +05:30
Anmol Singh BhatiaandGitHub 1c558eb6b3 [WEB-1555] fix: analytics empty state flicker (#4754)
* fix: analytics empty state flicker

* chore: todo added
2024-06-11 17:35:54 +05:30
Anmol Singh BhatiaandGitHub 778fca9db6 fix: profile picture modal close on remove functionality added and mutation fix (#4757) 2024-06-11 17:33:45 +05:30
Anmol Singh BhatiaandGitHub b05579d506 [WEB-1508] chore: module and cycle peek view improvement (#4758)
* chore: module and cycle list layout peek view improvement

* chore: code refactor
2024-06-11 17:33:31 +05:30
Anmol Singh BhatiaandGitHub cb292b010f fix: inbox issue (#4765) 2024-06-11 17:32:52 +05:30
Aaryan KhandelwalandGitHub b5bbdbfa8e dev: create separate folder for page service (#4764) 2024-06-11 16:18:42 +05:30
Anmol Singh BhatiaandGitHub bdaef179b1 chore: project publish url updated (#4762) 2024-06-11 15:00:46 +05:30
guru_sainathandGitHub 59376ee3da fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add (#4761) 2024-06-11 14:55:35 +05:30
Satish GandhamandGitHub 346bc2afe2 Refactor folder structure (#4759) 2024-06-11 14:39:52 +05:30
sriram veeraghanta a0e16692da fix: workflow updates 2024-06-11 12:46:59 +05:30
jon ⚝andGitHub c24be25024 docs: update self-host guide link in README (#4704)
found via:

- https://github.com/makeplane/docs/issues/48
- https://github.com/makeplane/plane/pull/3109
2024-06-11 02:38:43 +05:30
05de4d83f3 chore: app dir headers re-implementation (#4751)
* chore: header refactor.

* fix: core imports

* chore: refactor profile activity header and fix all other header imports.

* fix: import fixes

* chore: header refactor.

* fix: app dir header reimplementation

* fix: removing parllel headers

* fix: adding route groups to handle pages

* fix: disabling sentry for temp

* chore: update default exports in layouts & headers for consistency.

* fix: bugfixes

* fix: build errors

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-06-11 02:23:19 +05:30
guru_sainathandGitHub 423bc15119 chore: updated modal and form validations (#4748) 2024-06-10 23:47:30 +05:30
+2 666d35afb9 feat: Issue pagination (#4109)
* dev: separate order by of issue queryset to separate utilty function

* dev: pagination for spreadhseet and gantt

* dev: group pagination

* dev: paginate single entities

* dev: refactor pagination

* dev: paginating issue apis

* dev: grouped pagination for empty groups

* dev: ungrouped list

* dev: fix paginator for single groups

* dev: fix paginating true list

* dev: state__group pagination

* fix: imports

* dev: fix grouping on taget date and project_id

* dev: remove unused imports

* dev: add ruff in dependencies

* make store changes for pagination

* fix some build errors due to type changes

* dev: add total pages key

* chore: paginator changes

* implement pagination for spreadsheet, list, kanban and calendar

* fix: order by grouped pagination

* dev: sub group paginator

* dev: grouped paginator

* dev: sub grouping paginator

* restructure gantt layout charts

* dev: fix pagination count

* dev: date filtering for issues

* dev: group by counts

* implement new logic for pagination layouts

* fix: label id and assignee id interchange

* dev: fix priority ordering

* fix group by bugs

* dev: grouping for priority

* fix reeordering while update

* dev: fix order by for pagination

* fix: total results for sub group pagination

* dev: add comments and fix ordering

* fix orderby priority for spreadsheet

* fix subGroupCount

* Fix logic for load more in Kanban

* fix issue quick add

* dev: fix issue creation

* dev: add sorting

* fix order by for modules and cycles

* fix non render of Issues

* fix subGroupKey generation when subGroupId is null

* dev: fix cycle and module issue

* dev: fix sub grouping

* fix: imports

* fix minor build errors

* fix major build errors

* fix priority order by

* grouped pagination cursor logic changes

* fix calendar pagination

* active cycle issues pagination

* dev: fix lint errors

* fix Kanban subgroup dnd

* fix empty subgroup kanbans

* fix updation from an empty field with groupBy

* fix issue count of groups

* fix issue sorting on first page fetch

* dev: remove pagination from list endpoint add ordering for sub grouping and handle error for empty issues

* refactor module and cycle issues

* fix quick add refactor

* refactor gantt roots

* fix empty states

* fix filter params

* fix group by module

* minor UX changes

* fix sub grouping in Kanban

* remove unnecessary sorting logic in backend (Nikhil's changes)

* dev: add error handling when using without on results

* calendar layout loader improvement

* list per page count logic change

* spreadsheet loader improvement

* Added loader for issues load more pagination

* fix quick add in gantt

* dev: add profile issue pagination

* fix all issue and profile issues logic

* remove empty state from calendar layout

* use useEffect instead of swr to fetch issues to have quick switching between views cycles etc

* dev: add aggregation for multi fields

* fix priority sorting for workspace issues

* fix move from draft for draft issues

* fix pagination loader for spreadsheet

* fetch project, module and cycle stats on update, create and delete of issues

* increase horizontal margin

* change load more pagination to on scroll pagination for active cycle issues

* fix linting error

* dev: fix ordering when order by m2m

* dev: fix null paginations

* dev: commenting

* 0add comments to the issue stores methods

* fix order by for array properties

* fix: priority ordering

* perform optimistic updates while adding or removing cycles or modules

* fix build errors

* dev: add default values when iterating through sub group

* Move code from EE to CE repo

* chore: folder structure updates

* Move sortabla and radio input to packages/ui

* chore: updated empty and loading screens

* chore: delete an estimate point

* chore: estimate point response change

* chore: updated create estimate and handled the build error

* chore: migration fixes

* chore: updated create estimate

* [WEB-1322] dev: conflict free pages collaboration (#4463)

* chore: pages realtime

* chore: empty binary response

* chore: added a ypy package

* feat: pages collaboration

* chore: update fetching logic

* chore: degrade ypy version

* chore: replace useEffect fetch logic with useSWR

* chore: move all the update logic to the page store

* refactor: remove react-hook-form

* chore: save description_html as well

* chore: migrate old data logic

* fix: added description_binary as field name

* fix: code cleanup

* refactor: create separate hook to handle page description

* fix: build errors

* chore: combine updates instead of using the whole document

* chore: removed ypy package

* chore: added conflict resolving logic to the client side

* chore: add a save changes button

* chore: add read-only validation

* chore: remove saving state information

* chore: added permission class

* chore: removed the migration file

* chore: corrected the model field

* chore: rename pageStore to page

* chore: update collaboration provider

* chore: add try catch to handle error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* chore: create estimate workflow update

* chore: editing and deleting the existing estimate updates

* chore: updating the new estinates in update modal

* chore: ui changed

* chore: response changes of get and post

* chore: new field added in estimates

* chore: individual endpoint for estimate points

* chore: typo changes

* chore: create estimate point

* chore: integrated new endpoints

* chore: update key value pair

* chore: update sorting in the estimates

* Add custom option in the estimate templates

* chore: handled current project active estimate

* chore: handle estimate update worklfow

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled estimates switch

* chore: handled estimate edit

* chore: handled close button in estimate edit

* chore: updated ceate estimare workflow

* chore: updated switch estimate

* fix minor bugs in base issues store

* single column scroll pagination

* UI changes for load more button

* chore: UI and typos

* chore: resolved build error

* [WEB-1184] feat: issue bulk operations (#4530)

* chore: bulk operations

* chore: archive bulk issues

* chore: bulk ops keys changed

* chore: bulk delete and archive confirmation modals

* style: list layout spacing

* chore: create hoc for multi-select groups

* chore: update multiple select components

* chore: archive, target and start date error messsage

* chore: edge case handling

* chore: bulk ops in spreadsheet layout

* chore: update UI

* chore: scroll element into view

* fix: shift + arrow navigation

* chore: implement bulk ops in the gantt layout

* fix: ui bugs

* chore: move selection logic to store

* fix: group selection

* refactor: multiple select store

* style: dropdowns UI

* fix: bulk assignee and label update mutation

* chore: removed migrations

* refactor: entities grouping logic

* fix performance issue is selection of bulk ops

* fix: shift keyboard navigation

* fix: group click action

* chore: start and target date validation

* chore: remove optimistic updates, check archivability in frontend

* chore: code optimisation

* chore: add store comments

* refactor: component fragmentation

* style: issue active state

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>

* fix a performance issue when there are too many groups

* chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point

* [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583)

* chore: added logo_props

* chore: logo props in cycles, views and modules

* chore: emoji icon picker types updated

* chore: info icon added to plane ui package

* chore: icon color adjust helper function added

* style: icon picker ui improvement and default color options updated

* chore: update page logo action added in store

* chore: emoji code to unicode helper function added

* chore: common logo renderer component added

* chore: app header project logo updated

* chore: project logo updated across platform

* chore: page logo picker added

* chore: control link component improvement

* chore: list item improvement

* chore: emoji picker component updated

* chore: space app and package logo prop type updated

* chore: migration

* chore: logo added to project view

* chore: page logo picker added in create modal and breadcrumbs

* chore: view logo picker added in create modal and updated breadcrumbs

* fix: build error

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: merge conflict

* chore: lucide react added to planu ui package

* chore: new emoji picker component added with lucid icon and code refactor

* chore: logo component updated

* chore: emoji picker updated for pages and views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled inline errors in the estimate switch

* fix module and cycle drag and drop

* Fix issue count bug for accumulated actions

* chore: handled active and availability vadilation

* chore: handled create and update components in projecr estimates

* chore: added migration

* Add category specific values for custom template

* chore: estimate dropdown handled in issues

* chore: estimate alerts

* fix bulk updates

* chore: updated alerts

* add optional chaining

* Extract the list row actions

* change color of load more to match new Issues

* list group collapsible

* fix: updated and handled the estimate points

* fix: upgrader ee banner

* Fix issues with sortable

* Fix sortable spacing issue in create estimate modal

* fix: updated the issue create sorting

* chore: removed radio button from ui and updated in the estimates

* chore: resolved import error in packaged ui

* chore: handled props in create modal

* chore: removed ee files

* chore: changed default analytics

* fix: pagination ordering for grouped and subgrouped

* chore: removed the migration file

* chore: estimate point value in graph

* chore: estimate point key change

* chore: squashed migration (#4634)

* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* [WEB-1516] refactor: space app routing and layouts (#4705)

* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* Merge develop into revamp-estimates-ce

* chore: removed migration file and updated the estimate system order and removed ee banner

* chore: initial radio select in create estimate

* chore: space key changes

* Fix sortable component as the sort order was broken.

* fix: formatting and linting errors

* fix Alignment for load more

* add logic to approuter

* fix approuter changes and fix build

* chore: removed the linting issue

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
2024-06-10 20:15:03 +05:30
Prateek ShouryaandGitHub 7ac07b7b73 chore: app dir improvement (#4750)
* chore: remove constants package.

* fix: useRouter import in editor.

* chore: improve `peekCycle` and `peekModule` logic.

* style: update app progress bar color.
2024-06-10 19:05:32 +05:30
sriram veeraghanta a88f2e3cba fix: nprogress next integration 2024-06-10 15:20:53 +05:30
Anmol Singh BhatiaandGitHub d3556f457b chore: web app use client directive (#4747)
* chore: use client directive

* chore: use client directive
2024-06-10 15:13:10 +05:30
Prateek ShouryaandGitHub c880e8b48c fix: app dir build errors. (#4746) 2024-06-10 15:02:44 +05:30
f93803ace8 Fix app directory migration bugs (#4745)
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-06-10 14:18:30 +05:30
sriram veeraghanta a04fb07406 fix: client component rendering 2024-06-10 13:36:10 +05:30
+6 5d807db69e feat: app dir migration (#4743)
* feat: creating new app dir structure for web app

* fix: moving few pages to app dir

* fix: adding profile settings layout

* fix: errors on app dir.

* chore: remove pages routes.

* chore: add sign-in/ sign-up, invitations, onboarding pages.

* [WEB-1374] fix: clear changes made on modal close (#4555)

* [WEB-1480] fix: preserve page access when making a copy (#4568)

* [WEB-1465] fix: theme fluctuation on initial load. (#4638)

* [WEB-1445] fix: issue creation on sub groups when cycle/ module grouping is applied. (#4636)

* [WEB-1244] fix: add better image insertion and replacement logic in the editor (#4508)

* fix: add better image insertion and replacement logic

* refactor: image handling in editor

* chore: remove passing uploadKey around

* refactor: remove unused code

* fix: redundant files removed

* fix: add is editor ready to discard api to control behvaiours from our app

* fix: focus issues and image insertion position when not using slash command

* fix: import order fixed

* fix: notification mark all as read (#4643)

* chore: remove enter key extension (#4648)

* [WEB-1467] chore: run the API's required to bootstrap the application in parallel. (#4642)

* [WEB - 1482] fix: uploads when using block storages other than s3 and minio (#4647)

* fix: minio storage and redirection

* dev: disconnect web url and app base url configuration.

* fix: negate check while trying to discard (#4653)

* fix: email notification preferences (#4656)

* [WEB-1493] chore: product tour asset and app sidebar quick action hover (#4655)

* chore: product tour asset updated

* fix: app sidebar quick action hover

* fix: project state setting state name remove camel case logic (#4652)

* [WEB-1419] chore: enable module creation with dates older than today. (#4659)

* [WEB-1216] chore: increase module empty state for consistency. (#4658)

* fix: build errors

* [WEB-1235] chore: module and cycle sidebar graph improvement (#4650)

* chore: module and cycle sidebar graph improvement

* chore: code refactor

* [WEB-1424] chore: page and view logo implementation, and emoji/icon (#4662)

* [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583)

* chore: added logo_props

* chore: logo props in cycles, views and modules

* chore: emoji icon picker types updated

* chore: info icon added to plane ui package

* chore: icon color adjust helper function added

* style: icon picker ui improvement and default color options updated

* chore: update page logo action added in store

* chore: emoji code to unicode helper function added

* chore: common logo renderer component added

* chore: app header project logo updated

* chore: project logo updated across platform

* chore: page logo picker added

* chore: control link component improvement

* chore: list item improvement

* chore: emoji picker component updated

* chore: space app and package logo prop type updated

* chore: migration

* chore: logo added to project view

* chore: page logo picker added in create modal and breadcrumbs

* chore: view logo picker added in create modal and updated breadcrumbs

* fix: build error

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: merge conflict

* chore: lucide react added to planu ui package

* chore: new emoji picker component added with lucid icon and code refactor

* chore: logo component updated

* chore: emoji picker updated for pages and views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: build error

---------

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>

* refactor: drag handle component (#4663)

* refactor: checkbox ui component (#4665)

* [WEB-1325] chore: refactor inbox issue store to avoid data loss. (#4640)

* [WEB-1325] chore: refactor inbox issue store to avoid data loss.

* chore: inbox store improvement.

* chore: priority dropdown accepts undefined (#4666)

* chore: added buttonClassName prop to label dropdown (#4671)

* chore: created new constants for marketing website page links (#4670)

* chore: added a prop to render default state conditionally (#4669)

* [WEB-1501] dev: multiple select core components (#4667)

* dev: multiple select core components

* chore: added export statement

* chore: created a new constant for archivable state groups (#4668)

* chore: added primary variant to the alert modal (#4664)

* [WEB-1436] chore: pages improvement. (#4657)

* add empty state if no pages are available.
* set access to private in create page modal when the modal is open form private tab.

* [WEB-1440] chore: update cycle empty state to use project level access. (#4672)

* fix: checkbox ui component (#4675)

* fix: ai buttons overlapping issue (#4621)

* [WEB - 1500] chore: add extra fields on instance and create changelog table to store release change logs (#4673)

* chore: add extra fields on instance and create changelog table to store release change logs

* dev: rename new_version to latest_version

* [WEB - 1505] chore: alter instance id field (#4676)

* chore: instance id

* dev: update to max length

* feat: creating new app dir structure for web app

* fix: moving few pages to app dir

* feat: creating new app dir structure for web app

* fix: moving few pages to app dir

* fix: errors on app dir.

* chore: remove pages routes.

* chore: add sign-in/ sign-up, invitations, onboarding pages.

* fix: instance serializer

* fix: instance register script  (#4681)

* fix: instance register script

* dev: remove api key and add latest version and current version in types

* [WEB-1492] fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add (#4682)

* fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add

* fix: updated braces in conditions

* fix: inbox issue store update logic. (#4683)

* chore: update package version

* [WEB-1184] feat: issue bulk operations (#4674)

* feat: issue bulk operations

* style: bulk operations action bar

* chore: remove edition separation

* style: fix overlapping of response container in AI popover. (#4684)

* [WEB-1498] style: fix comments reaction alignment. (#4686)

* [WEB-1503] chore: add `autofocus` to name field in inline create/ update state component. (#4685)

* [WEB-1312] fix: trim file name before uploading (#4661)

* fix: trim file name before uploading

* fix: check the cursor position before inserting image

* dev: add trimming for file assets

* dev: add filename validation above if

* dev: make the validation to 50 to support user uploads

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* [WEB-1481] fix: multiple API calls in inbox issues on closed issues tab. (#4691)

* fix: multiple API calls on scroll and closed issues tab.

* fix: pagination loader on initial load.

* feat: Add components required for estimates (#4690)

* Add sortable, radio and typography components

* Remove stray css classes

* Prevent drag of items from other draggable

* Minor cleanup

* Update yarn.lock

* Remove radio input component as it was build on
headless ui v2.0.0 and now we are using v1.7.0

* Fix build errors

* Update dependencies in use memo.

* [WEB-1521] chore: add configuration to enable/disable sign-ups. (#4697)

* fix: regenerating lock file

* fix: docker image build errors

* fix: remove `setupInterceptors` to avoid circular dependency.

* chore: migrate all `accounts` related routes.

* chore: migrate all `profiles` related routes.

* chore: workspace invitation and onboarding migration / fixes.

* chore: installation provider migrations.

* regression: focus changing issue with the peek overview editor (#4700)

* [WEB-1459] chore: save users all / favorite project list collapse state into localstorage. (#4701)

* [WEB-1501] chore: update selected entity details on entities list change (#4702)

* chore: update selected entity detials on entities list change

* chore: addd selectionHelpers as a prop

* [WEB-1517] chore: remove drag handle from list drag block (#4698)

* remove drag handle from list drag block

* align list group header with list item

* rearrange chevron for list subissues and rearrange spaces

* adding default draggable property to control link

* remove unnecessary dependencies for useEffect

* fix: email validation  (#4707)

* fix: email validation on complete login or sign up functionality

* dev: add try catch block

* dev: split up code

* dev: empty return

* fix: cache invalidation on new members invite (#4699)

* fix: build test pull request running on non draft PRs (#4708)

* fix: cache invalidation on new members invite (#4699)

* fix: add version max length (#4713)

* chore: migrations for `routing` hooks.

* [WEB-1533] chore: fix alignment issues in List and Spreadsheet view (#4714)

* fix alignment issues in List and Spreadsheet view

* fix spreadsheet indentation

* chore: migration for workspace dashboard/ views/ analytics/ settings and active-cycles.

* chore: handle undefined identifier case

* fix: Overflowing loader in issue edit modal (#4720)

* [WEB-1529] chore: workspace sidebar updates. (#4710)

* fix: temporary fix exiting lines with slashes (#4725)

* [WEB-1537] fix: inline code block size fixed for headers, etc (#4709)

* fix: inline code block size fixed for headers, etc

* feat: persisting focus accurately post converting the code block into text

* fix: typo in error handling

* [WEB-1526] feat: add auto merge behaviour to task lists and fix infinite backspace case (#4703)

* feat: add auto merge behaviour to task lists

* fix: unhandled cases for taskItem and taskList

* fix: css task list such that toggling task list doesn't shift things

* fix: task list jumps around while trying create/delete things in between two task lists

* fix: remove filtering for generic transactions i.e. transactions with some meta data while tying to join things

* chore: migration for profile activity along with headers refactor.

* [WEB-1201] dev: dropdowns (#4721)

* chore: lodash package added

* chore: dropdown key down hook added

* dev: dropdown component

* chore: build error and code refactor

* chore: readme file updated

* chore: added disabled prop to multiple select components (#4724)

* chore: added disabled prop to mutliple select group hoc

* style: fix empty space

* fix: don't add  as a sub-issue if parent has been removed (#4731)

* fix: member list item custom menu placement (#4729)

* [WEB-1535] chore: project logo picker improvement (#4718)

* chore: emoji icon picker improvement

* chore: emoji icon picker improvement

* fix: resolved border flicker on issue title (#4727)

* chore: profile activity empty state added (#4732)

* [WEB-1481] fix: inbox issue list update after changing issue status. (#4715)

* style: fix ux copy style on project feature preview page. (#4734)

* chore: remove clear seleciton logic on escape key press (#4735)

* chore: migrations for projects and project issues.

* chore: issue and properties filter dropdown improvement (#4733)

* save all filters and properties for views (#4728)

* chore: migrations for issue details route.

* chore: migration for cycle routes.

* chore: migration for module routes.

* chore: migrations for project views routes.

* chore: migrations for project pages routes.

* chore: migration for project inbox routes.

* chore: migration for project settings routes.

* chore: migrations for draft issues routes.

* chore: migrations for project archives routes.

* chore: remove unused headers.

* temp: comment out auth constant and use-reload-confirmation code to avoid errors.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: rahulramesha <71900764+rahulramesha@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-06-10 12:37:38 +05:30
59fdd611e4 feat: estimates revamp and space app refactor (#4742)
* Move code from EE to CE repo

* chore: folder structure updates

* Move sortabla and radio input to packages/ui

* chore: updated empty and loading screens

* chore: delete an estimate point

* chore: estimate point response change

* chore: updated create estimate and handled the build error

* chore: migration fixes

* chore: updated create estimate

* chore: create estimate workflow update

* chore: editing and deleting the existing estimate updates

* chore: updating the new estinates in update modal

* chore: ui changed

* chore: response changes of get and post

* chore: new field added in estimates

* chore: individual endpoint for estimate points

* chore: typo changes

* chore: create estimate point

* chore: integrated new endpoints

* chore: update key value pair

* chore: update sorting in the estimates

* Add custom option in the estimate templates

* chore: handled current project active estimate

* chore: handle estimate update worklfow

* chore: handled estimates switch

* chore: handled estimate edit

* chore: handled close button in estimate edit

* chore: updated ceate estimare workflow

* chore: updated switch estimate

* chore: UI and typos

* chore: resolved build error

* chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point

* chore: handled inline errors in the estimate switch

* chore: handled active and availability vadilation

* chore: handled create and update components in projecr estimates

* chore: added migration

* Add category specific values for custom template

* chore: estimate dropdown handled in issues

* chore: estimate alerts

* chore: updated alerts

* Extract the list row actions

* fix: updated and handled the estimate points

* fix: upgrader ee banner

* Fix issues with sortable

* Fix sortable spacing issue in create estimate modal

* fix: updated the issue create sorting

* chore: removed radio button from ui and updated in the estimates

* chore: resolved import error in packaged ui

* chore: handled props in create modal

* chore: removed ee files

* chore: changed default analytics

* chore: removed the migration file

* chore: estimate point value in graph

* chore: estimate point key change

* chore: squashed migration (#4634)

* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* [WEB-1516] refactor: space app routing and layouts (#4705)

* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* Merge develop into revamp-estimates-ce

* chore: removed migration file and updated the estimate system order and removed ee banner

* chore: initial radio select in create estimate

* chore: space key changes

* Fix sortable component as the sort order was broken.

* [WEB-1516] refactor: publish project modal and types (#4716)

* refacotr: project publish

* chore: rename service names

* chore: is_deployed changed to anchor

* chore: update is_deployed key

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* [WEB-412] chore: estimates analytics  (#4730)

* chore: estimate points in modules and cycle

* chore: burn down chart analytics

* chore: module serializer change

* dev: handled y-axis estimates in analytics, implemented estimate points on modules

* chore: burn down analytics

* chore: state estimate point analytics

* chore: updated the burn down values

* Remove check mark from estimate point edit field in
create estimate flow

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
2024-06-10 12:16:23 +05:30
rahulrameshaandGitHub fb2b4ae303 save all filters and properties for views (#4728) 2024-06-07 19:43:27 +05:30
Anmol Singh BhatiaandGitHub de8da176d3 chore: issue and properties filter dropdown improvement (#4733) 2024-06-07 19:33:13 +05:30
Aaryan KhandelwalandGitHub 17ce1bceb6 chore: remove clear seleciton logic on escape key press (#4735) 2024-06-07 18:18:47 +05:30
Prateek ShouryaandGitHub 1561b710ca style: fix ux copy style on project feature preview page. (#4734) 2024-06-07 18:01:42 +05:30
Prateek ShouryaandGitHub 9a4971efa4 [WEB-1481] fix: inbox issue list update after changing issue status. (#4715) 2024-06-07 17:02:30 +05:30
Anmol Singh BhatiaandGitHub 2331404d46 chore: profile activity empty state added (#4732) 2024-06-07 16:26:52 +05:30
Anmol Singh BhatiaandGitHub a23c528396 fix: resolved border flicker on issue title (#4727) 2024-06-07 16:09:56 +05:30
Anmol Singh BhatiaandGitHub dee57326a5 [WEB-1535] chore: project logo picker improvement (#4718)
* chore: emoji icon picker improvement

* chore: emoji icon picker improvement
2024-06-07 16:09:27 +05:30
Anmol Singh BhatiaandGitHub 15918f2d9f fix: member list item custom menu placement (#4729) 2024-06-07 16:08:56 +05:30
Aaryan KhandelwalandGitHub 8b6a48f05c fix: don't add as a sub-issue if parent has been removed (#4731) 2024-06-07 16:08:21 +05:30
Aaryan KhandelwalandGitHub 1c849103f9 chore: added disabled prop to multiple select components (#4724)
* chore: added disabled prop to mutliple select group hoc

* style: fix empty space
2024-06-07 13:59:57 +05:30
Anmol Singh BhatiaandGitHub cdb932ab67 [WEB-1201] dev: dropdowns (#4721)
* chore: lodash package added

* chore: dropdown key down hook added

* dev: dropdown component

* chore: build error and code refactor

* chore: readme file updated
2024-06-07 13:59:31 +05:30
M. PalanikannanandGitHub b1c7e6ae20 [WEB-1526] feat: add auto merge behaviour to task lists and fix infinite backspace case (#4703)
* feat: add auto merge behaviour to task lists

* fix: unhandled cases for taskItem and taskList

* fix: css task list such that toggling task list doesn't shift things

* fix: task list jumps around while trying create/delete things in between two task lists

* fix: remove filtering for generic transactions i.e. transactions with some meta data while tying to join things
2024-06-07 12:36:19 +05:30
M. PalanikannanandGitHub f5656111ee [WEB-1537] fix: inline code block size fixed for headers, etc (#4709)
* fix: inline code block size fixed for headers, etc

* feat: persisting focus accurately post converting the code block into text

* fix: typo in error handling
2024-06-07 12:34:57 +05:30
M. PalanikannanandGitHub 51758b774e fix: temporary fix exiting lines with slashes (#4725) 2024-06-07 12:22:55 +05:30
Prateek ShouryaandGitHub d31aaee32c [WEB-1529] chore: workspace sidebar updates. (#4710) 2024-06-07 12:22:30 +05:30
guru_sainathandGitHub 9af9268be6 fix: Overflowing loader in issue edit modal (#4720) 2024-06-06 17:46:49 +05:30
pushya22andGitHub c18a6a9654 Merge pull request #4719 from makeplane/fix/project-identifier
[WEB-1540] chore: handle undefined identifier error
2024-06-06 17:12:16 +05:30
Aaryan Khandelwal 282597bf83 chore: handle undefined identifier case 2024-06-06 17:02:47 +05:30
rahulrameshaandGitHub b24e530816 [WEB-1533] chore: fix alignment issues in List and Spreadsheet view (#4714)
* fix alignment issues in List and Spreadsheet view

* fix spreadsheet indentation
2024-06-06 11:03:56 +05:30
NikhilandGitHub ca9f3f2f5a fix: add version max length (#4713) 2024-06-05 20:13:28 +05:30
1756 changed files with 24404 additions and 16947 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
Create_PR:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- preview
- develop
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
+1
View File
@@ -1,3 +1,4 @@
"use client";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
@@ -0,0 +1,59 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET;
return (
<>
{isGitlabConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/gitlab"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
@@ -1,5 +1,6 @@
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
export * from "./gitlab-config";
export * from "./github-config";
export * from "./google-config";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
+212
View File
@@ -0,0 +1,212 @@
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// types
import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
// components
import {
ConfirmDiscardModal,
ControllerInput,
CopyField,
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export const InstanceGitlabConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GitlabConfigFormValues>({
defaultValues: {
GITLAB_HOST: config["GITLAB_HOST"],
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITLAB_HOST",
type: "text",
label: "Host",
description: (
<>
This is the <b>GitLab host</b> to use for login, <b>including scheme</b>.
</>
),
placeholder: "https://gitlab.com",
error: Boolean(errors.GITLAB_HOST),
required: true,
},
{
key: "GITLAB_CLIENT_ID",
type: "text",
label: "Application ID",
description: (
<>
Get this from your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
error: Boolean(errors.GITLAB_CLIENT_ID),
required: true,
},
{
key: "GITLAB_CLIENT_SECRET",
type: "password",
label: "Secret",
description: (
<>
The client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
error: Boolean(errors.GITLAB_CLIENT_SECRET),
required: true,
},
];
const GITLAB_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URL",
label: "Callback URL",
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <b>Redirect URI</b> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application
</a>
.
</>
),
},
];
const onSubmit = async (formData: GitlabConfigFormValues) => {
const payload: Partial<GitlabConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "GitLab Configuration Settings updated successfully",
});
reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1">
<div className="pt-2 text-xl font-medium">Configuration</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 py-4 my-2 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Service provider details</div>
{GITLAB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
// local components
import { AuthenticationMethodCard } from "../components";
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<>
<PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? (
<InstanceGitlabConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGitlabAuthenticationPage;
+1
View File
@@ -1,3 +1,4 @@
"use client";
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
+9
View File
@@ -17,12 +17,14 @@ import { useInstance } from "@/hooks/store";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
// local components
import {
AuthenticationMethodCard,
EmailCodesConfiguration,
PasswordLoginConfiguration,
GitlabConfiguration,
GithubConfiguration,
GoogleConfiguration,
} from "./components";
@@ -116,6 +118,13 @@ const InstanceAuthenticationPage = observer(() => {
),
config: <GithubConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to login or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
];
return (
+2
View File
@@ -1,3 +1,5 @@
"use client";
import React, { FC, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
+3 -3
View File
@@ -10,6 +10,6 @@ export const metadata: Metadata = {
title: "Email Settings - God Mode",
};
const EmailLayout = ({ children }: EmailLayoutProps) => <AdminLayout>{children}</AdminLayout>;
export default EmailLayout;
export default function EmailLayout({ children }: EmailLayoutProps) {
return <AdminLayout>{children}</AdminLayout>;
}
+1
View File
@@ -1,3 +1,4 @@
"use client";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
+3 -3
View File
@@ -10,6 +10,6 @@ export const metadata: Metadata = {
title: "Images Settings - God Mode",
};
const ImageLayout = ({ children }: ImageLayoutProps) => <AdminLayout>{children}</AdminLayout>;
export default ImageLayout;
export default function ImageLayout({ children }: ImageLayoutProps) {
return <AdminLayout>{children}</AdminLayout>;
}
+1 -3
View File
@@ -16,7 +16,7 @@ import { UserProvider } from "@/lib/user-provider";
// styles
import "./globals.css";
function RootLayout({ children }: { children: ReactNode }) {
export default function RootLayout({ children }: { children: ReactNode }) {
// themes
const { resolvedTheme } = useTheme();
@@ -44,5 +44,3 @@ function RootLayout({ children }: { children: ReactNode }) {
</html>
);
}
export default RootLayout;
+4 -4
View File
@@ -41,10 +41,10 @@ export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
<div
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
fixed md:relative
${isSidebarCollapsed ? "-ml-[280px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[280px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
${isSidebarCollapsed ? "-ml-[250px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[250px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[250px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
+2
View File
@@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => {
return "Google";
case "github":
return "Github";
case "gitlab":
return "GitLab";
default:
return pathName.toUpperCase();
}
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { Tooltip } from "@plane/ui";
@@ -1,3 +1,5 @@
"use client";
import React from "react";
import Link from "next/link";
// headless ui
@@ -43,33 +45,22 @@ export const ConfirmDiscardModal: React.FC<Props> = (props) => {
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-300"
>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-300">
You have unsaved changes
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-400">
Changes you made will be lost if you go back. Do you
wish to go back?
Changes you made will be lost if you go back. Do you wish to go back?
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end items-center p-4 sm:px-6 gap-2">
<Button
variant="neutral-primary"
size="sm"
onClick={handleClose}
>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Keep editing
</Button>
<Link
href={onDiscardHref}
className={getButtonStyling("primary", "sm")}
>
<Link href={onDiscardHref} className={getButtonStyling("primary", "sm")}>
Go back
</Link>
</div>
+2
View File
@@ -1,3 +1,5 @@
"use client";
import React from "react";
import Image from "next/image";
import { Button } from "@plane/ui";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { useTheme } from "next-themes";
// ui
import { Toast as ToastComponent } from "@plane/ui";
+2
View File
@@ -319,6 +319,8 @@ export const InstanceSetupForm: FC = (props) => {
<div className="relative flex items-center pt-2 gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
-1
View File
@@ -14,7 +14,6 @@
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="80 80 220 220"><defs><style>.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style></defs><g id="LOGO"><path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-2" d="M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-3" d="M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z"/><path class="cls-2" d="M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File
View File
@@ -2,10 +2,6 @@
from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.app.serializers import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
)
from rest_framework import serializers
+5 -1
View File
@@ -222,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer):
class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True)
sub_issues = serializers.IntegerField(read_only=True)
backlog_estimate_points = serializers.IntegerField(read_only=True)
unstarted_estimate_points = serializers.IntegerField(read_only=True)
started_estimate_points = serializers.IntegerField(read_only=True)
cancelled_estimate_points = serializers.IntegerField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
class ModuleUserPropertiesSerializer(BaseSerializer):
+30 -13
View File
@@ -8,6 +8,8 @@ from plane.db.models import (
PageLog,
PageLabel,
Label,
ProjectPage,
Project,
)
@@ -18,6 +20,15 @@ class PageSerializer(BaseSerializer):
write_only=True,
required=False,
)
# Many to many
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
project_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Page
@@ -33,44 +44,53 @@ class PageSerializer(BaseSerializer):
"is_locked",
"archived_at",
"workspace",
"project",
"created_at",
"updated_at",
"created_by",
"updated_by",
"view_props",
"logo_props",
"label_ids",
"project_ids",
]
read_only_fields = [
"workspace",
"project",
"owned_by",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
def create(self, validated_data):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description_html = self.context["description_html"]
# Get the workspace id from the project
project = Project.objects.get(pk=project_id)
# Create the page
page = Page.objects.create(
**validated_data,
description_html=description_html,
project_id=project_id,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
)
# Create the project page
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)
# Create page labels
if labels is not None:
PageLabel.objects.bulk_create(
[
PageLabel(
label=label,
page=page,
project_id=project_id,
workspace_id=page.workspace_id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
@@ -90,7 +110,6 @@ class PageSerializer(BaseSerializer):
PageLabel(
label=label,
page=instance,
project_id=instance.project_id,
workspace_id=instance.workspace_id,
created_by_id=instance.created_by_id,
updated_by_id=instance.updated_by_id,
@@ -120,7 +139,6 @@ class SubPageSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"page",
]
@@ -141,6 +159,5 @@ class PageLogSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"page",
]
]
+6 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path
from plane.app.views import UnsplashEndpoint
from plane.app.views import GPTIntegrationEndpoint
from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpoint
urlpatterns = [
@@ -16,4 +16,9 @@ urlpatterns = [
GPTIntegrationEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/ai-assistant/",
WorkspaceGPTIntegrationEndpoint.as_view(),
name="importer",
),
]
+12
View File
@@ -19,6 +19,8 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
)
urlpatterns = [
@@ -81,6 +83,11 @@ urlpatterns = [
BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
BulkArchiveIssuesEndpoint.as_view(),
name="bulk-archive-issues",
),
##
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
@@ -298,4 +305,9 @@ urlpatterns = [
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
]
+6 -4
View File
@@ -113,9 +113,7 @@ from .issue.activity import (
IssueActivityEndpoint,
)
from .issue.archive import (
IssueArchiveViewSet,
)
from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
from .issue.attachment import (
IssueAttachmentEndpoint,
@@ -154,6 +152,8 @@ from .issue.subscriber import (
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -180,12 +180,14 @@ from .page.base import (
PagesDescriptionViewSet,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
from .external.base import (
GPTIntegrationEndpoint,
UnsplashEndpoint,
WorkspaceGPTIntegrationEndpoint,
)
from .estimate.base import (
ProjectEstimatePointEndpoint,
+7 -1
View File
@@ -1,4 +1,6 @@
# Python imports
import traceback
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
@@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
response = super().handle_exception(exc)
return response
except Exception as e:
print(e) if settings.DEBUG else print("Server Error")
(
print(e, traceback.format_exc())
if settings.DEBUG
else print("Server Error")
)
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
+367 -39
View File
@@ -46,6 +46,7 @@ from plane.db.models import (
Issue,
Label,
User,
Project,
)
from plane.utils.analytics_plot import burndown_plot
@@ -325,7 +326,6 @@ class CycleViewSet(BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
plot_type = request.GET.get("plot_type", "issues")
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@@ -373,8 +373,108 @@ class CycleViewSet(BaseViewSet):
"status",
"created_by",
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if data:
data[0]["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data[0]["estimate_distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=data[0]["id"],
)
)
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
@@ -388,7 +488,10 @@ class CycleViewSet(BaseViewSet):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
@@ -427,8 +530,11 @@ class CycleViewSet(BaseViewSet):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
@@ -464,7 +570,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
plot_type="issues",
cycle_id=data[0]["id"],
)
)
@@ -659,7 +765,6 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "issues")
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
)
@@ -710,12 +815,107 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["estimate_distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if data["start_date"] and data["end_date"]:
data["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=pk,
)
)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
@@ -738,7 +938,10 @@ class CycleViewSet(BaseViewSet):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
@@ -778,7 +981,10 @@ class CycleViewSet(BaseViewSet):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
@@ -815,7 +1021,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type=plot_type,
plot_type="issues",
cycle_id=pk,
)
@@ -932,7 +1138,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id:
return Response(
@@ -1009,14 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"])
if item["assignee_id"]
else None
),
"avatar": item["avatar"],
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
@@ -1032,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
@@ -1086,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
@@ -1112,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
.order_by("label_name")
)
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Label distribution serilization
label_distribution_data = [
{
@@ -1141,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
@@ -1157,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
+130 -90
View File
@@ -2,43 +2,50 @@
import json
# Django imports
from django.db.models import (
Func,
F,
Q,
OuterRef,
Value,
UUIDField,
)
from django.core import serializers
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueLink,
IssueAttachment,
IssueLink,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
queryset = (
issue_queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
@@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet):
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
@@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by(order_by)
)
if self.fields:
issues = IssueSerializer(
queryset, many=True, fields=fields if fields else None
).data
else:
issues = queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
filters = issue_filters(request.query_params, "GET")
return Response(issues, status=status.HTTP_200_OK)
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,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
+39 -38
View File
@@ -1,52 +1,53 @@
# Django imports
from django.db.models import (
Q,
Case,
When,
Value,
CharField,
Count,
F,
Exists,
OuterRef,
Subquery,
JSONField,
Func,
Prefetch,
IntegerField,
)
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
IntegerField,
JSONField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from plane.app.serializers import (
DashboardSerializer,
IssueActivitySerializer,
IssueSerializer,
WidgetSerializer,
)
from plane.db.models import (
Dashboard,
DashboardWidget,
Issue,
IssueActivity,
IssueAttachment,
IssueLink,
IssueRelation,
Project,
ProjectMember,
User,
Widget,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView
from plane.db.models import (
Issue,
IssueActivity,
ProjectMember,
Widget,
DashboardWidget,
Dashboard,
Project,
IssueLink,
IssueAttachment,
IssueRelation,
User,
)
from plane.app.serializers import (
IssueActivitySerializer,
IssueSerializer,
DashboardSerializer,
WidgetSerializer,
)
from plane.utils.issue_filters import issue_filters
def dashboard_overview_stats(self, request, slug):
+18 -9
View File
@@ -62,12 +62,15 @@ class BulkEstimatePointEndpoint(BaseViewSet):
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def create(self, request, slug, project_id):
estimate = request.data.get('estimate')
estimate = request.data.get("estimate")
estimate_name = estimate.get("name", generate_random_name())
estimate_type = estimate.get("type", 'categories')
estimate_type = estimate.get("type", "categories")
last_used = estimate.get("last_used", False)
estimate = Estimate.objects.create(
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
name=estimate_name,
project_id=project_id,
last_used=last_used,
type=estimate_type,
)
estimate_points = request.data.get("estimate_points", [])
@@ -125,8 +128,12 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(pk=estimate_id)
if request.data.get("estimate"):
estimate.name = request.data.get("estimate").get("name", estimate.name)
estimate.type = request.data.get("estimate").get("type", estimate.type)
estimate.name = request.data.get("estimate").get(
"name", estimate.name
)
estimate.type = request.data.get("estimate").get(
"type", estimate.type
)
estimate.save()
estimate_points_data = request.data.get("estimate_points", [])
@@ -204,7 +211,9 @@ class EstimatePointEndpoint(BaseViewSet):
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
def partial_update(
self, request, slug, project_id, estimate_id, estimate_point_id
):
# TODO: add a key validation if the same key already exists
estimate_point = EstimatePoint.objects.get(
pk=estimate_point_id,
@@ -225,7 +234,7 @@ class EstimatePointEndpoint(BaseViewSet):
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
new_estimate_id = request.GET.get("new_estimate_id", None)
new_estimate_id = request.data.get("new_estimate_id", None)
estimate_points = EstimatePoint.objects.filter(
estimate_id=estimate_id,
project_id=project_id,
@@ -236,8 +245,8 @@ class EstimatePointEndpoint(BaseViewSet):
_ = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_id=estimate_point_id,
).update(estimate_id=new_estimate_id)
estimate_point_id=estimate_point_id,
).update(estimate_point_id=new_estimate_id)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
+7 -6
View File
@@ -1,14 +1,14 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import ExporterHistorySerializer
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import ExporterHistory, Project, Workspace
# Module imports
from .. import BaseAPIView
from plane.app.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.app.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
@@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView):
"cursor", False
):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
+59 -1
View File
@@ -11,7 +11,7 @@ from rest_framework import status
# Module imports
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import (
ProjectLiteSerializer,
@@ -83,6 +83,64 @@ class GPTIntegrationEndpoint(BaseAPIView):
)
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def post(self, request, slug):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
# Get the configuration value
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
return Response(
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
if not task:
return Response(
{"error": "Task is required"},
status=status.HTTP_400_BAD_REQUEST,
)
final_text = task + "\n" + prompt
client = OpenAI(
api_key=OPENAI_API_KEY,
)
response = client.chat.completions.create(
model=GPT_ENGINE,
messages=[{"role": "user", "content": final_text}],
)
text = response.choices[0].message.content.strip()
text_html = text.replace("\n", "<br/>")
return Response(
{
"response": text,
"response_html": text_html,
},
status=status.HTTP_200_OK,
)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
+186 -165
View File
@@ -2,52 +2,54 @@
import json
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
UUIDField,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
F,
Func,
OuterRef,
Q,
Prefetch,
Exists,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
IssueFlatSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
IssueDetailSerializer
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
IssueAttachment,
IssueLink,
IssueSubscriber,
IssueReaction,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from .. import BaseViewSet, BaseAPIView
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
@method_decorator(gzip_page)
@@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
return Response(issues, status=status.HTTP_200_OK)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
@@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet):
issue.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class BulkArchiveIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
).select_related("state")
bulk_archive_issues = []
for issue in issues:
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"archived_at": str(timezone.now().date()),
"automation": False,
}
),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
bulk_archive_issues.append(issue)
Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
return Response(
{"archived_at": str(timezone.now().date())},
status=status.HTTP_200_OK,
)
+155 -238
View File
@@ -1,34 +1,30 @@
# Python imports
import json
# Django imports
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 (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -49,11 +45,21 @@ from plane.db.models import (
IssueSubscriber,
Project,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
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.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
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,
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if self.fields or self.expand:
issues = IssueSerializer(
@@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
@method_decorator(gzip_page)
@@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
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"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# Only use serializer when expand or fields else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset()
.filter(pk=serializer.data["id"])
issue_queryset_grouper(
queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
group_by=None,
sub_group_by=None,
)
.values(
"id",
"name",
@@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet):
issue = (
self.get_queryset()
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
@@ -0,0 +1,288 @@
# Python imports
import json
from datetime import datetime
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Project,
Issue,
IssueLabel,
IssueAssignee,
)
from plane.bgtasks.issue_activites_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all the issues
issues = (
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.select_related("state")
.prefetch_related("labels", "assignees")
)
# Current epoch
epoch = int(timezone.now().timestamp())
# Project details
project = Project.objects.get(workspace__slug=slug, pk=project_id)
workspace_id = project.workspace_id
# Initialize arrays
bulk_update_issues = []
bulk_issue_activities = []
bulk_update_issue_labels = []
bulk_update_issue_assignees = []
properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get("target_date", False):
if (
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
):
return Response(
{
"error_code": 4100,
"error_message": "INVALID_ISSUE_DATES",
},
status=status.HTTP_400_BAD_REQUEST,
)
for issue in issues:
# Priority
if properties.get("priority", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"priority": properties.get("priority")}
),
"current_instance": json.dumps(
{"priority": (issue.priority)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.priority = properties.get("priority")
# State
if properties.get("state_id", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"state": properties.get("state")}
),
"current_instance": json.dumps(
{"state": str(issue.state_id)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.state_id = properties.get("state_id")
# Start date
if properties.get("start_date", False):
if (
issue.target_date
and not properties.get("target_date", False)
and issue.target_date
<= datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4101,
"error_message": "INVALID_ISSUE_START_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"start_date": properties.get("start_date")}
),
"current_instance": json.dumps(
{"start_date": str(issue.start_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.start_date = properties.get("start_date")
# Target date
if properties.get("target_date", False):
if (
issue.start_date
and not properties.get("start_date", False)
and issue.start_date
>= datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4102,
"error_message": "INVALID_ISSUE_TARGET_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"target_date": properties.get("target_date")}
),
"current_instance": json.dumps(
{"target_date": str(issue.target_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.target_date = properties.get("target_date")
bulk_update_issues.append(issue)
# Labels
if properties.get("label_ids", []):
for label_id in properties.get("label_ids", []):
bulk_update_issue_labels.append(
IssueLabel(
issue=issue,
label_id=label_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"label_ids": properties.get("label_ids", [])}
),
"current_instance": json.dumps(
{
"label_ids": [
str(label.id)
for label in issue.labels.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Assignees
if properties.get("assignee_ids", []):
for assignee_id in properties.get(
"assignee_ids", issue.assignees
):
bulk_update_issue_assignees.append(
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{
"assignee_ids": properties.get(
"assignee_ids", []
)
}
),
"current_instance": json.dumps(
{
"assignee_ids": [
str(assignee.id)
for assignee in issue.assignees.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
[
"priority",
"start_date",
"target_date",
"state",
],
batch_size=100,
)
# Create new labels
IssueLabel.objects.bulk_create(
bulk_update_issue_labels,
ignore_conflicts=True,
batch_size=100,
)
# Create new assignees
IssueAssignee.objects.bulk_create(
bulk_update_issue_assignees,
ignore_conflicts=True,
batch_size=100,
)
# update the issue activity
[
issue_activity.delay(**activity)
for activity in bulk_issue_activities
]
return Response(status=status.HTTP_204_NO_CONTENT)
+171 -144
View File
@@ -6,18 +6,14 @@ 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 (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueCreateSerializer,
@@ -44,10 +41,17 @@ from plane.db.models import (
IssueSubscriber,
Project,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from .. import BaseViewSet
@@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# Only use serializer when expand else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
many=True,
fields=self.fields,
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(
IssueSerializer(issue).data, status=status.HTTP_201_CREATED
issue_queryset_grouper(
queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
group_by=None,
sub_group_by=None,
)
.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
.first()
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
@@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet):
issue = (
self.get_queryset()
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
+114 -6
View File
@@ -12,8 +12,9 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
Sum
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
# Third party imports
@@ -25,7 +26,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
ModuleDetailSerializer,
)
from plane.db.models import Issue, Module, ModuleLink, UserFavorite
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
@@ -165,7 +166,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None:
queryset = self.get_queryset()
modules = queryset.values( # Required fields
@@ -218,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.values("count")
)
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
data = ModuleDetailSerializer(queryset.first()).data
modules = queryset.first()
data["estimate_distribution"] = {}
if estimate_type:
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["estimate_distribution"]["assignee"] = assignee_distribution
data["estimate_distribution"]["label"] = label_distribution
if modules and modules.start_date and modules.target_date:
data["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
plot_type="points",
module_id=pk,
)
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
@@ -310,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("label_name")
)
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
@@ -318,13 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
}
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
plot_type="issues",
module_id=pk,
)
+192 -7
View File
@@ -157,6 +157,62 @@ class ModuleViewSet(BaseViewSet):
)
.values("total_estimate_points")[:1]
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("cancelled_estimate_point")[:1]
)
return (
super()
.get_queryset()
@@ -211,6 +267,30 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
@@ -346,7 +426,6 @@ class ModuleViewSet(BaseViewSet):
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "burndown")
queryset = (
self.get_queryset()
.filter(archived_at__isnull=True)
@@ -363,6 +442,116 @@ class ModuleViewSet(BaseViewSet):
)
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
data = ModuleDetailSerializer(queryset.first()).data
modules = queryset.first()
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["estimate_distribution"]["assignees"] = assignee_distribution
data["estimate_distribution"]["labels"] = label_distribution
if modules and modules.start_date and modules.target_date:
data["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
plot_type="points",
module_id=pk,
)
)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
@@ -388,7 +577,7 @@ class ModuleViewSet(BaseViewSet):
archived_at__isnull=True,
is_draft=False,
),
)
),
)
.annotate(
completed_issues=Count(
@@ -455,21 +644,17 @@ class ModuleViewSet(BaseViewSet):
.order_by("label_name")
)
data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
plot_type=plot_type,
plot_type="issues",
module_id=pk,
)
+136 -90
View File
@@ -1,37 +1,50 @@
# Python imports
import json
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
# Django Imports
from django.utils import timezone
from django.db.models import F, OuterRef, Func, Q
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
ModuleIssue,
Project,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
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 .. import BaseViewSet
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
ModuleIssue,
Project,
Issue,
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
@@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
if self.fields or self.expand:
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
order_by_param = request.GET.get("order_by", "created_at")
return Response(issues, status=status.HTTP_200_OK)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
+14 -12
View File
@@ -1,26 +1,27 @@
# Django imports
from django.db.models import Q, OuterRef, Exists
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.utils.paginator import BasePaginator
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.db.models import (
Notification,
IssueAssignee,
IssueSubscriber,
Issue,
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import (
NotificationSerializer,
UserNotificationPreferenceSerializer,
)
from plane.db.models import (
Issue,
IssueAssignee,
IssueSubscriber,
Notification,
UserNotificationPreference,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
# Module imports
from ..base import BaseAPIView, BaseViewSet
class NotificationViewSet(BaseViewSet, BasePaginator):
@@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
"cursor", False
):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(
+52 -19
View File
@@ -6,15 +6,19 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
from django.db.models import Exists, OuterRef, Q
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.http import StreamingHttpResponse
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
PageLogSerializer,
@@ -27,6 +31,7 @@ from plane.db.models import (
PageLog,
UserFavorite,
ProjectMember,
ProjectPage,
)
# Module imports
@@ -66,28 +71,53 @@ class PageViewSet(BaseViewSet):
user=self.request.user,
entity_type="page",
entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
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=self.request.user) | Q(access=0))
.select_related("project")
.prefetch_related("projects")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.prefetch_related("labels")
.order_by("-is_favorite", "-created_at")
.annotate(
project=Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"),
project_id=self.kwargs.get("project_id"),
)
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"page_labels__label_id",
distinct=True,
filter=~Q(page_labels__label_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
project_ids=Coalesce(
ArrayAgg(
"projects__id",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(project=True)
.distinct()
)
@@ -107,7 +137,7 @@ class PageViewSet(BaseViewSet):
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -115,7 +145,9 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk,
workspace__slug=slug,
projects__id=project_id,
)
if page.is_locked:
@@ -127,7 +159,9 @@ class PageViewSet(BaseViewSet):
parent = request.data.get("parent", None)
if parent:
_ = Page.objects.get(
pk=parent, workspace__slug=slug, project_id=project_id
pk=parent,
workspace__slug=slug,
projects__id=project_id,
)
# Only update access if the page owner is the requesting user
@@ -187,7 +221,7 @@ class PageViewSet(BaseViewSet):
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = True
@@ -196,7 +230,7 @@ class PageViewSet(BaseViewSet):
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = False
@@ -211,7 +245,7 @@ class PageViewSet(BaseViewSet):
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can archive the page
@@ -238,7 +272,7 @@ class PageViewSet(BaseViewSet):
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can un archive the page
@@ -267,7 +301,7 @@ class PageViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner and admin can delete the page
@@ -293,7 +327,7 @@ class PageViewSet(BaseViewSet):
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, project_id=project_id, workspace__slug=slug
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
@@ -380,7 +414,6 @@ class SubPagesEndpoint(BaseAPIView):
pages = (
PageLog.objects.filter(
page_id=page_id,
project_id=project_id,
workspace__slug=slug,
entity_name__in=["forward_link", "back_link"],
)
@@ -399,7 +432,7 @@ class PagesDescriptionViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
binary_data = page.description_binary
@@ -419,7 +452,7 @@ class PagesDescriptionViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, projects__id=project_id
)
base64_data = request.data.get("description_binary")
+19 -14
View File
@@ -1,26 +1,25 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
# Django imports
from django.db import IntegrityError
from django.db.models import (
Prefetch,
Q,
Exists,
OuterRef,
F,
Func,
OuterRef,
Prefetch,
Q,
Subquery,
)
from django.conf import settings
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
# Module imports
@@ -35,20 +34,19 @@ from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
)
from plane.db.models import (
Project,
ProjectMember,
Workspace,
State,
UserFavorite,
ProjectIdentifier,
Module,
Cycle,
Inbox,
DeployBoard,
IssueProperty,
Issue,
Module,
Project,
ProjectIdentifier,
ProjectMember,
State,
Workspace,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
@@ -168,6 +166,7 @@ class ProjectViewSet(BaseViewSet):
"cursor", False
):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=(projects),
on_results=lambda projects: ProjectListSerializer(
@@ -241,6 +240,12 @@ class ProjectViewSet(BaseViewSet):
)
).first()
if project is None:
return Response(
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -2,14 +2,17 @@
import re
# Django imports
from django.db.models import Q
from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
@@ -18,8 +21,8 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectPage,
)
from plane.utils.issue_search import search_issues
class GlobalSearchEndpoint(BaseAPIView):
@@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
workspace__slug=slug,
)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(
project_identifiers=Coalesce(
ArrayAgg(
"projects__identifier",
distinct=True,
filter=~Q(projects__id=True),
),
Value([], output_field=ArrayField(CharField())),
),
)
)
if workspace_search == "false" and project_id:
pages = pages.filter(project_id=project_id)
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"),
project_id=project_id,
).values_list("project_id", flat=True)[:1]
pages = pages.annotate(
project_id=Subquery(project_subquery)
).filter(project_id=project_id)
return pages.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"project_ids",
"project_identifiers",
"workspace__slug",
)
@@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView):
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get(
"workspace_search", "false"
)
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
return Response(
issues.values(
"name",
"id",
"start_date",
"sequence_id",
"project__name",
"project__identifier",
"project_id",
"workspace__slug",
"state__name",
"state__group",
"state__color",
),
status=status.HTTP_200_OK,
)
+95
View File
@@ -0,0 +1,95 @@
# Python imports
import re
# Django imports
from django.db.models import Q
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
Issue,
Cycle,
Module,
Page,
IssueView,
)
from plane.utils.issue_search import search_issues
class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get(
"workspace_search", "false"
)
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
return Response(
issues.values(
"name",
"id",
"start_date",
"sequence_id",
"project__name",
"project__identifier",
"project_id",
"workspace__slug",
"state__name",
"state__group",
"state__color",
),
status=status.HTTP_200_OK,
)
+1
View File
@@ -250,6 +250,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
+137 -135
View File
@@ -1,47 +1,56 @@
# Django imports
from django.db.models import (
Q,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from plane.app.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.app.serializers import (
IssueViewSerializer,
)
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueView,
Workspace,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
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 .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
)
from plane.db.models import (
Workspace,
IssueView,
Issue,
UserFavorite,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
@@ -143,17 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
@@ -162,103 +160,107 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=self.fields
).data
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=None,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
+136 -141
View File
@@ -1,61 +1,66 @@
# Python imports
from datetime import date
from dateutil.relativedelta import relativedelta
# Django imports
from django.utils import timezone
from django.db.models import (
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Max,
Count,
F,
Func,
IntegerField,
UUIDField,
OuterRef,
Q,
Value,
When,
)
from django.db.models.functions import ExtractWeek, Cast
from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
from django.db.models.functions import Cast, ExtractWeek
from django.utils import timezone
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.serializers import (
WorkSpaceSerializer,
ProjectMemberSerializer,
IssueActivitySerializer,
IssueSerializer,
WorkspaceUserPropertiesSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
User,
Workspace,
ProjectMember,
IssueActivity,
Issue,
IssueLink,
IssueAttachment,
IssueSubscriber,
Project,
WorkspaceMember,
CycleIssue,
WorkspaceUserProperties,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
WorkspaceViewerPermission,
)
# Module imports
from plane.app.serializers import (
IssueActivitySerializer,
ProjectMemberSerializer,
WorkSpaceSerializer,
WorkspaceUserPropertiesSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
CycleIssue,
Issue,
IssueActivity,
IssueAttachment,
IssueLink,
IssueSubscriber,
Project,
ProjectMember,
User,
Workspace,
WorkspaceMember,
WorkspaceUserProperties,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
]
def get(self, request, slug, user_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
@@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at")
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
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"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=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,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
return Response(issues, status=status.HTTP_200_OK)
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
@@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
queryset = queryset.filter(project__in=projects)
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
@@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = {
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
# Oauth
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -62,11 +62,7 @@ class OauthAdapter(Adapter):
response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
code = self._provider_error_code()
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
@@ -83,8 +79,12 @@ class OauthAdapter(Adapter):
except requests.RequestException:
if self.provider == "google":
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "github":
elif self.provider == "github":
code = "GITHUB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitlab":
code = "GITLAB_OAUTH_PROVIDER_ERROR"
else:
code = "OAUTH_NOT_CONFIGURED"
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
@@ -48,7 +48,7 @@ class MagicCodeProvider(CredentialAdapter):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
error_message="SMTP_NOT_CONFIGURED",
payload={"email": str(self.key)},
payload={"email": str(key)},
)
if ENABLE_MAGIC_LINK_LOGIN == "0":
@@ -57,7 +57,7 @@ class MagicCodeProvider(CredentialAdapter):
"MAGIC_LINK_LOGIN_DISABLED"
],
error_message="MAGIC_LINK_LOGIN_DISABLED",
payload={"email": str(self.key)},
payload={"email": str(key)},
)
super().__init__(
@@ -0,0 +1,132 @@
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitLabOAuthProvider(OauthAdapter):
provider = "gitlab"
scope = "read_user"
def __init__(self, request, code=None, state=None, callback=None):
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = (
get_configuration_value(
[
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get("GITLAB_CLIENT_ID"),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get("GITLAB_CLIENT_SECRET"),
},
{
"key": "GITLAB_HOST",
"default": os.environ.get(
"GITLAB_HOST", "https://gitlab.com"
),
},
]
)
)
self.host = GITLAB_HOST
self.token_url = f"{self.host}/oauth/token"
self.userinfo_url = f"{self.host}/api/v4/user"
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
error_message="GITLAB_NOT_CONFIGURED",
)
client_id = GITLAB_CLIENT_ID
client_secret = GITLAB_CLIENT_SECRET
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": self.scope,
"state": state,
}
auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}"
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
callback=callback,
)
def set_token_data(self):
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": self.code,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
token_response = self.get_user_token(
data=data, headers={"Accept": "application/json"}
)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(
token_response.get("created_at")
+ token_response.get("expires_in"),
tz=pytz.utc,
)
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"),
tz=pytz.utc,
)
if token_response.get("refresh_token_expired_at")
else None
),
"id_token": token_response.get("id_token", ""),
}
)
def set_user_data(self):
user_info_response = self.get_user_response()
email = user_info_response.get("email")
super().set_user_data(
{
"email": email,
"user": {
"provider_id": user_info_response.get("id"),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("name"),
"last_name": user_info_response.get("family_name"),
"is_password_autoset": True,
},
}
)
+25
View File
@@ -8,6 +8,8 @@ from .views import (
ChangePasswordEndpoint,
# App
EmailCheckEndpoint,
GitLabCallbackEndpoint,
GitLabOauthInitiateEndpoint,
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
GoogleCallbackEndpoint,
@@ -22,6 +24,8 @@ from .views import (
ResetPasswordSpaceEndpoint,
# Space
EmailCheckSpaceEndpoint,
GitLabCallbackSpaceEndpoint,
GitLabOauthInitiateSpaceEndpoint,
GitHubCallbackSpaceEndpoint,
GitHubOauthInitiateSpaceEndpoint,
GoogleCallbackSpaceEndpoint,
@@ -151,6 +155,27 @@ urlpatterns = [
GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback",
),
## Gitlab Oauth
path(
"gitlab/",
GitLabOauthInitiateEndpoint.as_view(),
name="gitlab-initiate",
),
path(
"gitlab/callback/",
GitLabCallbackEndpoint.as_view(),
name="gitlab-callback",
),
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
name="gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
name="gitlab-callback",
),
# Email Check
path(
"email-check/",
@@ -14,6 +14,10 @@ from .app.github import (
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
)
from .app.gitlab import (
GitLabCallbackEndpoint,
GitLabOauthInitiateEndpoint,
)
from .app.google import (
GoogleCallbackEndpoint,
GoogleOauthInitiateEndpoint,
@@ -34,6 +38,11 @@ from .space.github import (
GitHubOauthInitiateSpaceEndpoint,
)
from .space.gitlab import (
GitLabCallbackSpaceEndpoint,
GitLabOauthInitiateSpaceEndpoint,
)
from .space.google import (
GoogleCallbackSpaceEndpoint,
GoogleOauthInitiateSpaceEndpoint,
@@ -0,0 +1,131 @@
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import (
post_user_auth_workflow,
)
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitLabOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
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(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitLabOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host(request=request, is_app=True),
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
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", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
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),
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
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),
)
return HttpResponseRedirect(url)
try:
provider = GitLabOAuthProvider(
request=request,
code=code,
callback=post_user_auth_workflow,
)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(
base_host,
"?" + urlencode(params),
)
return HttpResponseRedirect(url)
@@ -0,0 +1,109 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GitLabOauthInitiateSpaceEndpoint(View):
def get(self, request):
# 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:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"INSTANCE_NOT_CONFIGURED"
],
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GitLabOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
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)}"
return HttpResponseRedirect(url)
class GitLabCallbackSpaceEndpoint(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", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITLAB_OAUTH_PROVIDER_ERROR"
],
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
provider = GitLabOAuthProvider(
request=request,
code=code,
)
user = provider.authenticate()
# Login the user and record his device info
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)
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)}"
return HttpResponseRedirect(url)
@@ -278,7 +278,6 @@ def create_page_labels(workspace, project, user_id, pages_count):
PageLabel(
page_id=page,
label_id=label,
project=project,
workspace=workspace,
)
)
+36 -18
View File
@@ -69,26 +69,34 @@ def create_zip_file(files):
def upload_to_s3(zip_file, workspace_id, token_id, slug):
file_name = (
f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
expires_in = 7 * 24 * 60 * 60
if settings.USE_MINIO:
s3 = boto3.client(
upload_s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
upload_s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
# Generate presigned url for the uploaded file with different base
presign_s3 = boto3.client(
"s3",
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
presigned_url = presign_s3.generate_presigned_url(
"get_object",
Params={
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
@@ -96,19 +104,27 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
},
ExpiresIn=expires_in,
)
# Create the new url with updated domain and protocol
presigned_url = presigned_url.replace(
f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/",
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
)
else:
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
# If endpoint url is present, use it
if settings.AWS_S3_ENDPOINT_URL:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
else:
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
# Upload the file to S3
s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
@@ -116,6 +132,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
# Generate presigned url for the uploaded file
presigned_url = s3.generate_presigned_url(
"get_object",
Params={
@@ -127,6 +144,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
exporter_instance = ExporterHistory.objects.get(token=token_id)
# Update the exporter instance with the presigned url
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
@@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id):
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
project_id=page.project_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
@@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
@@ -0,0 +1,257 @@
# Generated by Django 4.2.11 on 2024-06-07 12:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def migrate_pages(apps, schema_editor):
ProjectPage = apps.get_model("db", "ProjectPage")
Page = apps.get_model("db", "Page")
ProjectPage.objects.bulk_create(
[
ProjectPage(
workspace_id=page.get("workspace_id"),
project_id=page.get("project_id"),
page_id=page.get("id"),
created_by_id=page.get("created_by_id"),
updated_by_id=page.get("updated_by_id"),
)
for page in Page.objects.values(
"workspace_id",
"project_id",
"id",
"created_by_id",
"updated_by_id",
)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0067_issue_estimate"),
]
operations = [
migrations.AddField(
model_name="page",
name="is_global",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="ProjectPage",
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"
),
),
(
"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",
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_pages",
to="db.page",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_pages",
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",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_pages",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project Page",
"verbose_name_plural": "Project Pages",
"db_table": "project_pages",
"ordering": ("-created_at",),
"unique_together": {("project", "page")},
},
),
migrations.CreateModel(
name="TeamPage",
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"
),
),
(
"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",
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_pages",
to="db.page",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_pages",
to="db.team",
),
),
(
"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",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_pages",
to="db.workspace",
),
),
],
options={
"verbose_name": "Team Page",
"verbose_name_plural": "Team Pages",
"db_table": "team_pages",
"ordering": ("-created_at",),
"unique_together": {("team", "page")},
},
),
migrations.AddField(
model_name="page",
name="projects",
field=models.ManyToManyField(
related_name="pages", through="db.ProjectPage", to="db.project"
),
),
migrations.AddField(
model_name="page",
name="teams",
field=models.ManyToManyField(
related_name="pages", through="db.TeamPage", to="db.team"
),
),
migrations.RunPython(migrate_pages),
migrations.RemoveField(
model_name="page",
name="project",
),
migrations.AlterField(
model_name="page",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pages",
to="db.workspace",
),
),
migrations.RemoveField(
model_name="pagelabel",
name="project",
),
migrations.RemoveField(
model_name="pagelog",
name="project",
),
migrations.AlterField(
model_name="pagelabel",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_page_label",
to="db.workspace",
),
),
migrations.AlterField(
model_name="pagelog",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_page_log",
to="db.workspace",
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2024-06-03 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'),
]
operations = [
migrations.AlterField(
model_name='account',
name='provider',
field=models.CharField(choices=[('google', 'Google'), ('github', 'Github'), ('gitlab', 'GitLab')]),
),
migrations.AlterField(
model_name='socialloginconnection',
name='medium',
field=models.CharField(choices=[('Google', 'google'), ('Github', 'github'), ('GitLab', 'gitlab'), ('Jira', 'jira')], default=None, max_length=20),
),
]
+1 -1
View File
@@ -50,7 +50,7 @@ from .notification import (
Notification,
UserNotificationPreference,
)
from .page import Page, PageFavorite, PageLabel, PageLog
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import (
Project,
ProjectBaseModel,
+63 -3
View File
@@ -9,13 +9,17 @@ from django.db import models
from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel
from .base import BaseModel
def get_view_props():
return {"full_width": False}
class Page(ProjectBaseModel):
class Page(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="pages"
)
name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
@@ -44,6 +48,13 @@ class Page(ProjectBaseModel):
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict)
is_global = models.BooleanField(default=False)
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
teams = models.ManyToManyField(
"db.Team", related_name="pages", through="db.TeamPage"
)
class Meta:
verbose_name = "Page"
@@ -56,7 +67,7 @@ class Page(ProjectBaseModel):
return f"{self.owned_by.email} <{self.name}>"
class PageLog(ProjectBaseModel):
class PageLog(BaseModel):
TYPE_CHOICES = (
("to_do", "To Do"),
("issue", "issue"),
@@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel):
choices=TYPE_CHOICES,
verbose_name="Transaction Type",
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
class Meta:
unique_together = ["page", "transaction"]
@@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel):
return f"{self.user.email} <{self.page.name}>"
class PageLabel(ProjectBaseModel):
class PageLabel(BaseModel):
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_page_label",
)
class Meta:
verbose_name = "Page Label"
@@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel):
def __str__(self):
return f"{self.page.name} {self.label.name}"
class ProjectPage(BaseModel):
project = models.ForeignKey(
"db.Project", on_delete=models.CASCADE, related_name="project_pages"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="project_pages"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="project_pages"
)
class Meta:
unique_together = ["project", "page"]
verbose_name = "Project Page"
verbose_name_plural = "Project Pages"
db_table = "project_pages"
ordering = ("-created_at",)
def __str__(self):
return f"{self.project.name} {self.page.name}"
class TeamPage(BaseModel):
team = models.ForeignKey(
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
)
class Meta:
unique_together = ["team", "page"]
verbose_name = "Team Page"
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
@@ -10,7 +10,7 @@ from .base import BaseModel
class SocialLoginConnection(BaseModel):
medium = models.CharField(
max_length=20,
choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")),
choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")),
default=None,
)
last_login_at = models.DateTimeField(default=timezone.now, null=True)
+1 -1
View File
@@ -182,7 +182,7 @@ class Account(TimeAuditModel):
)
provider_account_id = models.CharField(max_length=255)
provider = models.CharField(
choices=(("google", "Google"), ("github", "Github")),
choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
)
access_token = models.TextField()
access_token_expired_at = models.DateTimeField(null=True)
@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", ""),
},
{
"key": "IS_GITLAB_ENABLED",
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
},
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST", ""),
@@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView):
# Authentication
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
@@ -2,7 +2,7 @@
import os
# Django imports
from django.core.management.base import BaseCommand
from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import InstanceConfiguration
@@ -15,6 +15,12 @@ class Command(BaseCommand):
from plane.license.utils.encryption import encrypt_data
from plane.license.utils.instance_value import get_configuration_value
mandatory_keys = ["SECRET_KEY"]
for item in mandatory_keys:
if not os.environ.get(item):
raise CommandError(f"{item} env variable is required.")
config_keys = [
# Authentication Settings
{
@@ -59,6 +65,24 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_ID",
"value": os.environ.get("GITLAB_CLIENT_ID"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
"category": "GITLAB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
@@ -145,7 +169,7 @@ class Command(BaseCommand):
)
)
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -216,6 +240,46 @@ class Command(BaseCommand):
f"{key} loaded with value from environment variable."
)
)
if key == "IS_GITLAB_ENABLED":
GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = (
get_configuration_value(
[
{
"key": "GITLAB_HOST",
"default": os.environ.get(
"GITLAB_HOST", "https://gitlab.com"
),
},
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get(
"GITLAB_CLIENT_ID", ""
),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get(
"GITLAB_CLIENT_SECRET", ""
),
},
]
)
)
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITLAB_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
self.stdout.write(
self.style.SUCCESS(
f"{key} loaded with value from environment variable."
)
)
else:
for key in keys:
self.stdout.write(
@@ -0,0 +1,43 @@
# Generated by Django 4.2.11 on 2024-06-05 13:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0002_rename_version_instance_current_version_and_more"),
]
operations = [
migrations.AlterField(
model_name="changelog",
name="title",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="changelog",
name="version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="current_version",
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="namespace",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=255),
),
]
+6 -6
View File
@@ -21,15 +21,15 @@ class Instance(BaseModel):
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True)
current_version = models.CharField(max_length=10)
latest_version = models.CharField(max_length=10, null=True, blank=True)
current_version = models.CharField(max_length=255)
latest_version = models.CharField(max_length=255, null=True, blank=True)
product = models.CharField(
max_length=50, default=ProductTypes.PLANE_CE.value
max_length=255, default=ProductTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=50, blank=True, null=True)
namespace = models.CharField(max_length=255, blank=True, null=True)
# telemetry and support
is_telemetry_enabled = models.BooleanField(default=True)
is_support_required = models.BooleanField(default=True)
@@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel):
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
title = models.CharField(max_length=100)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
version = models.CharField(max_length=100)
version = models.CharField(max_length=255)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
+41 -49
View File
@@ -1,57 +1,57 @@
# Python imports
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Prefetch,
Case,
When,
CharField,
IntegerField,
Value,
Max,
)
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
IssueCommentSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
# Third Party imports
from rest_framework.response import Response
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
from plane.db.models import (
Issue,
IssueComment,
Label,
IssueLink,
IssueAttachment,
State,
ProjectMember,
IssueReaction,
CommentReaction,
DeployBoard,
IssueVote,
ProjectPublicMember,
State,
Label,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
# Module imports
from .base import BaseAPIView, BaseViewSet
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
@@ -435,17 +435,14 @@ class IssueVotePublicViewSet(BaseViewSet):
return IssueVote.objects.none()
def create(self, request, anchor, issue_id):
print("hite")
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
print("awer")
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_deploy_board.project_id,
issue_id=issue_id,
)
print("AWer")
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_deploy_board.project_id,
@@ -524,16 +521,14 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
]
def get(self, request, anchor):
if not DeployBoard.objects.filter(
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).exists():
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
filters = issue_filters(request.query_params, "GET")
@@ -549,6 +544,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
order_by_param = request.GET.get("order_by", "-created_at")
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -558,8 +556,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_deploy_board.project_id)
.filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
@@ -666,8 +664,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
states = (
State.objects.filter(
~Q(name="Triage"),
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
@@ -684,15 +682,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
)
labels = Label.objects.filter(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
+3 -1
View File
@@ -37,7 +37,9 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).values_list
projects = (
Project.objects.filter(workspace=deploy_board.workspace)
.annotate(
+8 -1
View File
@@ -1,16 +1,23 @@
# Python imports
import logging
import traceback
# Django imports
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
def log_exception(e):
print(e)
# Log the error
logger = logging.getLogger("plane")
logger.error(e)
if settings.DEBUG:
# Print the traceback if in debug mode
print(traceback.format_exc())
# Capture in sentry if configured
capture_exception(e)
return
+187 -223
View File
@@ -1,240 +1,204 @@
def resolve_keys(group_keys, value):
"""resolve keys to a key which will be used for
grouping
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value
from django.db.models.functions import Coalesce
Args:
group_keys (string): key which will be used for grouping
value (obj): data value
Returns:
string: the key which will be used for
"""
keys = group_keys.split(".")
for key in keys:
value = value.get(key, None)
return value
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def group_results(results_data, group_by, sub_group_by=False):
"""group results data into certain group_by
def issue_queryset_grouper(queryset, group_by, sub_group_by):
Args:
results_data (obj): complete results data
group_by (key): string
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
Returns:
obj: grouped results
"""
if sub_group_by:
main_responsive_dict = dict()
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(
field,
distinct=True,
filter=condition,
),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by
or FIELD_MAPPER.get(key) != sub_group_by
}
if sub_group_by == "priority":
main_responsive_dict = {
"urgent": {},
"high": {},
"medium": {},
"low": {},
"none": {},
}
return queryset.annotate(**default_annotations)
for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if isinstance(main_group_attribute, list) and not isinstance(
group_attribute, list
):
if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if (
str(group_attribute)
in main_responsive_dict[str(attrib)]
):
main_responsive_dict[str(attrib)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(attrib)][
str(group_attribute)
] = []
main_responsive_dict[str(attrib)][
str(group_attribute)
].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if str(group_attribute) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(None)][
str(group_attribute)
] = []
main_responsive_dict[str(None)][
str(group_attribute)
].append(value)
def issue_on_results(issues, group_by, sub_group_by):
elif isinstance(group_attribute, list) and not isinstance(
main_group_attribute, list
):
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
if len(group_attribute):
for attrib in group_attribute:
if (
str(attrib)
in main_responsive_dict[str(main_group_attribute)]
):
main_responsive_dict[str(main_group_attribute)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(attrib)
] = []
main_responsive_dict[str(main_group_attribute)][
str(attrib)
].append(value)
else:
if (
str(None)
in main_responsive_dict[str(main_group_attribute)]
):
main_responsive_dict[str(main_group_attribute)][
str(None)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(None)
] = []
main_responsive_dict[str(main_group_attribute)][
str(None)
].append(value)
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
elif isinstance(group_attribute, list) and isinstance(
main_group_attribute, list
):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if (
str(attrib)
in main_responsive_dict[str(main_attrib)]
):
main_responsive_dict[str(main_attrib)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(main_attrib)][
str(attrib)
] = []
main_responsive_dict[str(main_attrib)][
str(attrib)
].append(value)
else:
if (
str(None)
in main_responsive_dict[str(main_attrib)]
):
main_responsive_dict[str(main_attrib)][
str(None)
].append(value)
else:
main_responsive_dict[str(main_attrib)][
str(None)
] = []
main_responsive_dict[str(main_attrib)][
str(None)
].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(None)][
str(attrib)
] = []
main_responsive_dict[str(None)][
str(attrib)
].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(
value
)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(
value
)
else:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
original_list = ["assignee_ids", "label_ids", "module_ids"]
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
"state__group",
]
if (
str(group_attribute)
in main_responsive_dict[str(main_group_attribute)]
):
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
] = []
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
].append(value)
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
return main_responsive_dict
if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
else:
response_dict = {}
required_fields.extend(original_list)
return issues.values(*required_fields)
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"none": [],
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
def issue_group_values(field, slug, project_id=None, filters=dict):
if field == "state_id":
queryset = State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
is_active=True,
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return [
"low",
"medium",
"high",
"urgent",
"none",
]
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()
)
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()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return response_dict
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []
+21 -17
View File
@@ -1,6 +1,7 @@
import re
import uuid
from datetime import timedelta
from django.utils import timezone
# The date from pattern
@@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries):
"""
for query in queries:
date_query = query.split(";")
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter=date_term,
offset=date_query[2],
)
else:
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
if date_query:
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter=date_term,
offset=date_query[2],
)
else:
filter[f"{date_term}__lte"] = date_query[0]
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
else:
filter[f"{date_term}__lte"] = date_query[0]
else:
filter[f"{date_term}__contains"] = date_query[0]
def filter_state(params, filter, method, prefix=""):
+84
View File
@@ -0,0 +1,84 @@
from django.db.models import (
Case,
CharField,
Min,
Value,
When,
)
# Custom ordering for priority and state
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
STATE_ORDER = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(PRIORITY_ORDER)
],
output_field=CharField(),
)
).order_by("priority_order")
order_by_param = (
"-priority_order"
if order_by_param.startswith("-")
else "priority_order"
)
# State Ordering
elif order_by_param in [
"state__group",
"-state__group",
]:
state_order = (
STATE_ORDER
if order_by_param in ["state__name", "state__group"]
else STATE_ORDER[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
order_by_param = (
"-state_order" if order_by_param.startswith("-") else "state_order"
)
# assignee and label ordering
elif order_by_param in [
"labels__name",
"assignees__first_name",
"issue_module__module__name",
"-labels__name",
"-assignees__first_name",
"-issue_module__module__name",
]:
issue_queryset = issue_queryset.annotate(
min_values=Min(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-min_values" if order_by_param.startswith("-") else "min_values"
)
order_by_param = (
"-min_values" if order_by_param.startswith("-") else "min_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
order_by_param = order_by_param
return issue_queryset, order_by_param
+620 -17
View File
@@ -1,33 +1,49 @@
from rest_framework.response import Response
from rest_framework.exceptions import ParseError
from collections.abc import Sequence
# Python imports
import math
from collections import defaultdict
from collections.abc import Sequence
# Django imports
from django.db.models import Count, F, Window
from django.db.models.functions import RowNumber
# Third party imports
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
# Module imports
class Cursor:
# The cursor value
def __init__(self, value, offset=0, is_prev=False, has_results=None):
self.value = value
self.offset = int(offset)
self.is_prev = bool(is_prev)
self.has_results = has_results
# Return the cursor value in string format
def __str__(self):
return f"{self.value}:{self.offset}:{int(self.is_prev)}"
# Return the cursor value
def __eq__(self, other):
return all(
getattr(self, attr) == getattr(other, attr)
for attr in ("value", "offset", "is_prev", "has_results")
)
# Return the representation of the cursor
def __repr__(self):
return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}"
# Return if the cursor is true
def __bool__(self):
return bool(self.has_results)
@classmethod
def from_string(cls, value):
"""Return the cursor value from string format"""
try:
bits = value.split(":")
if len(bits) != 3:
@@ -50,15 +66,19 @@ class CursorResult(Sequence):
self.max_hits = max_hits
def __len__(self):
# Return the length of the results
return len(self.results)
def __iter__(self):
# Return the iterator of the results
return iter(self.results)
def __getitem__(self, key):
# Return the results based on the key
return self.results[key]
def __repr__(self):
# Return the representation of the results
return f"<{type(self).__name__}: results={len(self.results)}>"
@@ -85,11 +105,14 @@ class OffsetPaginator:
max_offset=None,
on_results=None,
):
# Key tuple and remove `-` if descending order by
self.key = (
order_by
if order_by is None or isinstance(order_by, (list, tuple, set))
else (order_by,)
else (order_by[1::] if order_by.startswith("-") else order_by,)
)
# Set desc to true when `-` exists in the order by
self.desc = True if order_by and order_by.startswith("-") else False
self.queryset = queryset
self.max_limit = max_limit
self.max_offset = max_offset
@@ -101,13 +124,23 @@ class OffsetPaginator:
if cursor is None:
cursor = Cursor(0, 0, 0)
# Get the min from limit and max limit
limit = min(limit, self.max_limit)
# queryset
queryset = self.queryset
if self.key:
queryset = queryset.order_by(*self.key)
queryset = queryset.order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
"-created_at",
)
# The current page
page = cursor.offset
# The offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
@@ -116,20 +149,30 @@ class OffsetPaginator:
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
results = list(queryset[offset:stop])
results = queryset[offset:stop]
if cursor.value != limit:
results = results[-(limit + 1) :]
next_cursor = Cursor(limit, page + 1, False, len(results) > limit)
# Adjust cursors based on the results for pagination
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
results = list(results[:limit])
# Process the results
results = results[:limit]
# Process the results
if self.on_results:
results = self.on_results(results)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
# Return the cursor results
return CursorResult(
results=results,
next=next_cursor,
@@ -138,6 +181,544 @@ class OffsetPaginator:
max_hits=max_hits,
)
def process_results(self, results):
raise NotImplementedError
class GroupedOffsetPaginator(OffsetPaginator):
# Field mappers - list m2m fields here
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
def __init__(
self,
queryset,
group_by_field_name,
group_by_fields,
count_filter,
*args,
**kwargs,
):
# Initiate the parent class for all the parameters
super().__init__(queryset, *args, **kwargs)
# Set the group by field name
self.group_by_field_name = group_by_field_name
# Set the group by fields
self.group_by_fields = group_by_fields
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
self.count_filter = count_filter
def get_result(self, limit=50, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
cursor = Cursor(0, 0, 0)
limit = min(limit, self.max_limit)
# Adjust the initial offset and stop based on the cursor and limit
queryset = self.queryset
page = cursor.offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
# Check if the offset is greater than the max offset
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
# Check if the offset is less than 0
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
# Compute the results
results = {}
# Create window for all the groups
queryset = queryset.annotate(
row_number=Window(
expression=RowNumber(),
partition_by=[F(self.group_by_field_name)],
order_by=(
(
F(*self.key).desc(
nulls_last=True
) # order by desc if desc is set
if self.desc
else F(*self.key).asc(
nulls_last=True
) # Order by asc if set
),
F("created_at").desc(),
),
)
)
# Filter the results by row number
results = queryset.filter(
row_number__gt=offset, row_number__lt=stop
).order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
F("created_at").desc(),
)
# Adjust cursors based on the grouped results for pagination
next_cursor = Cursor(
limit,
page + 1,
False,
queryset.filter(row_number__gte=stop).exists(),
)
# Add previous cursors
prev_cursor = Cursor(
limit,
page - 1,
True,
page > 0,
)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
max_hits = math.ceil(
queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by("-count")[0]["count"]
/ limit
)
else:
max_hits = 0
return CursorResult(
results=results,
next=next_cursor,
prev=prev_cursor,
hits=count,
max_hits=max_hits,
)
def __get_total_queryset(self):
# Get total items for each group
return (
self.queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by()
)
def __get_total_dict(self):
# Convert the total into dictionary of keys as group name and value as the total
total_group_dict = {}
for group in self.__get_total_queryset():
total_group_dict[str(group.get(self.group_by_field_name))] = (
total_group_dict.get(
str(group.get(self.group_by_field_name)), 0
)
+ (1 if group.get("count") == 0 else group.get("count"))
)
return total_group_dict
def __get_field_dict(self):
# Create a field dictionary
total_group_dict = self.__get_total_dict()
return {
str(field): {
"results": [],
"total_results": total_group_dict.get(str(field), 0),
}
for field in self.group_by_fields
}
def __result_already_added(self, result, group):
# Check if the result is already added then add it
for existing_issue in group:
if existing_issue["id"] == result["id"]:
return True
return False
def __query_multi_grouper(self, results):
# Grouping for m2m values
total_group_dict = self.__get_total_dict()
# Preparing a dict to keep track of group IDs associated with each entity ID
result_group_mapping = defaultdict(set)
# Preparing a dict to group result by group ID
grouped_by_field_name = defaultdict(list)
# Iterate over results to fill the above dictionaries
for result in results:
result_id = result["id"]
group_id = result[self.group_by_field_name]
result_group_mapping[str(result_id)].add(str(group_id))
# Adding group_ids key to each issue and grouping by group_name
for result in results:
result_id = result["id"]
group_ids = list(result_group_mapping[str(result_id)])
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in group_ids else group_ids
)
# If a result belongs to multiple groups, add it to each group
for group_id in group_ids:
if not self.__result_already_added(
result, grouped_by_field_name[group_id]
):
grouped_by_field_name[group_id].append(result)
# Convert grouped_by_field_name back to a list for each group
processed_results = {
str(group_id): {
"results": issues,
"total_results": total_group_dict.get(str(group_id)),
}
for group_id, issues in grouped_by_field_name.items()
}
return processed_results
def __query_grouper(self, results):
# Grouping for values that are not m2m
processed_results = self.__get_field_dict()
for result in results:
group_value = str(result.get(self.group_by_field_name))
if group_value in processed_results:
processed_results[str(group_value)]["results"].append(result)
return processed_results
def process_results(self, results):
# Process results
if results:
if self.group_by_field_name in self.FIELD_MAPPER:
processed_results = self.__query_multi_grouper(results=results)
else:
processed_results = self.__query_grouper(results=results)
else:
processed_results = {}
return processed_results
class SubGroupedOffsetPaginator(OffsetPaginator):
# Field mappers this are the fields that are m2m
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
def __init__(
self,
queryset,
group_by_field_name,
sub_group_by_field_name,
group_by_fields,
sub_group_by_fields,
count_filter,
*args,
**kwargs,
):
# Initiate the parent class for all the parameters
super().__init__(queryset, *args, **kwargs)
# Set the group by field name
self.group_by_field_name = group_by_field_name
self.group_by_fields = group_by_fields
# Set the sub group by field name
self.sub_group_by_field_name = sub_group_by_field_name
self.sub_group_by_fields = sub_group_by_fields
# Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters
self.count_filter = count_filter
def get_result(self, limit=30, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
cursor = Cursor(0, 0, 0)
# get the minimum value
limit = min(limit, self.max_limit)
# Adjust the initial offset and stop based on the cursor and limit
queryset = self.queryset
# the current page
page = cursor.offset
# the offset
offset = cursor.offset * cursor.value
# the stop
stop = offset + (cursor.value or limit) + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
# Compute the results
results = {}
# Create windows for group and sub group field name
queryset = queryset.annotate(
row_number=Window(
expression=RowNumber(),
partition_by=[
F(self.group_by_field_name),
F(self.sub_group_by_field_name),
],
order_by=(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
"-created_at",
),
)
)
# Filter the results
results = queryset.filter(
row_number__gt=offset, row_number__lt=stop
).order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
F("created_at").desc(),
)
# Adjust cursors based on the grouped results for pagination
next_cursor = Cursor(
limit,
page + 1,
False,
queryset.filter(row_number__gte=stop).exists(),
)
# Add previous cursors
prev_cursor = Cursor(
limit,
page - 1,
True,
page > 0,
)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
max_hits = math.ceil(
queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by("-count")[0]["count"]
/ limit
)
else:
max_hits = 0
return CursorResult(
results=results,
next=next_cursor,
prev=prev_cursor,
hits=count,
max_hits=max_hits,
)
def __get_group_total_queryset(self):
# Get group totals
return (
self.queryset.order_by(self.group_by_field_name)
.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.distinct()
)
def __get_subgroup_total_queryset(self):
# Get subgroup totals
return (
self.queryset.values(
self.group_by_field_name, self.sub_group_by_field_name
)
.annotate(
count=Count("id", filter=self.count_filter, distinct=True)
)
.order_by()
.values(
self.group_by_field_name, self.sub_group_by_field_name, "count"
)
)
def __get_total_dict(self):
# Use the above to convert to dictionary of 2D objects
total_group_dict = {}
total_sub_group_dict = {}
for group in self.__get_group_total_queryset():
total_group_dict[str(group.get(self.group_by_field_name))] = (
total_group_dict.get(
str(group.get(self.group_by_field_name)), 0
)
+ (1 if group.get("count") == 0 else group.get("count"))
)
# Sub group total values
for item in self.__get_subgroup_total_queryset():
group = str(item[self.group_by_field_name])
subgroup = str(item[self.sub_group_by_field_name])
count = item["count"]
# Create a dictionary of group and sub group
if group not in total_sub_group_dict:
total_sub_group_dict[str(group)] = {}
# Create a dictionary of sub group
if subgroup not in total_sub_group_dict[group]:
total_sub_group_dict[str(group)][str(subgroup)] = {}
# Create a nested dictionary of group and sub group
total_sub_group_dict[group][subgroup] = count
return total_group_dict, total_sub_group_dict
def __get_field_dict(self):
# Create a field dictionary
total_group_dict, total_sub_group_dict = self.__get_total_dict()
# Create a dictionary of group and sub group
return {
str(group): {
"results": {
str(sub_group): {
"results": [],
"total_results": total_sub_group_dict.get(
str(group)
).get(str(sub_group), 0),
}
for sub_group in total_sub_group_dict.get(str(group), [])
},
"total_results": total_group_dict.get(str(group), 0),
}
for group in self.group_by_fields
}
def __query_multi_grouper(self, results):
# Multi grouper
processed_results = self.__get_field_dict()
# Preparing a dict to keep track of group IDs associated with each label ID
result_group_mapping = defaultdict(set)
result_sub_group_mapping = defaultdict(set)
# Iterate over results to fill the above dictionaries
if self.group_by_field_name in self.FIELD_MAPPER:
for result in results:
result_id = result["id"]
group_id = result[self.group_by_field_name]
result_group_mapping[str(result_id)].add(str(group_id))
# Use the same calculation for the sub group
if self.sub_group_by_field_name in self.FIELD_MAPPER:
for result in results:
result_id = result["id"]
sub_group_id = result[self.sub_group_by_field_name]
result_sub_group_mapping[str(result_id)].add(str(sub_group_id))
# Iterate over results
for result in results:
# Get the group value
group_value = str(result.get(self.group_by_field_name))
# Get the sub group value
sub_group_value = str(result.get(self.sub_group_by_field_name))
# Check if the group value is in the processed results
result_id = result["id"]
if (
group_value in processed_results
and sub_group_value
in processed_results[str(group_value)]["results"]
):
if self.group_by_field_name in self.FIELD_MAPPER:
# for multi grouper
group_ids = list(result_group_mapping[str(result_id)])
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in group_ids else group_ids
)
if self.sub_group_by_field_name in self.FIELD_MAPPER:
sub_group_ids = list(
result_sub_group_mapping[str(result_id)]
)
# for multi groups
result[
self.FIELD_MAPPER.get(self.sub_group_by_field_name)
] = ([] if "None" in sub_group_ids else sub_group_ids)
# If a result belongs to multiple groups, add it to each group
processed_results[str(group_value)]["results"][
str(sub_group_value)
]["results"].append(result)
return processed_results
def __query_grouper(self, results):
# Single grouper
processed_results = self.__get_field_dict()
for result in results:
group_value = str(result.get(self.group_by_field_name))
sub_group_value = str(result.get(self.sub_group_by_field_name))
processed_results[group_value]["results"][sub_group_value][
"results"
].append(result)
return processed_results
def process_results(self, results):
if results:
if (
self.group_by_field_name in self.FIELD_MAPPER
or self.sub_group_by_field_name in self.FIELD_MAPPER
):
# if the grouping is done through m2m then
processed_results = self.__query_multi_grouper(results=results)
else:
# group it directly
processed_results = self.__query_grouper(results=results)
else:
processed_results = {}
return processed_results
class BasePaginator:
"""BasePaginator class can be inherited by any View to return a paginated view"""
@@ -171,6 +752,11 @@ class BasePaginator:
cursor_cls=Cursor,
extra_stats=None,
controller=None,
group_by_field_name=None,
group_by_fields=None,
sub_group_by_field_name=None,
sub_group_by_fields=None,
count_filter=None,
**paginator_kwargs,
):
"""Paginate the request"""
@@ -178,15 +764,27 @@ class BasePaginator:
# Convert the cursor value to integer and float from string
input_cursor = None
if request.GET.get(self.cursor_name):
try:
input_cursor = cursor_cls.from_string(
request.GET.get(self.cursor_name)
)
except ValueError:
raise ParseError(detail="Invalid cursor parameter.")
try:
input_cursor = cursor_cls.from_string(
request.GET.get(self.cursor_name, f"{per_page}:0:0"),
)
except ValueError:
raise ParseError(detail="Invalid cursor parameter.")
if not paginator:
if group_by_field_name:
paginator_kwargs["group_by_field_name"] = group_by_field_name
paginator_kwargs["group_by_fields"] = group_by_fields
paginator_kwargs["count_filter"] = count_filter
if sub_group_by_field_name:
paginator_kwargs["sub_group_by_field_name"] = (
sub_group_by_field_name
)
paginator_kwargs["sub_group_by_fields"] = (
sub_group_by_fields
)
paginator = paginator_cls(**paginator_kwargs)
try:
@@ -196,12 +794,14 @@ class BasePaginator:
except BadPaginationError:
raise ParseError(detail="Error in parsing")
# Serialize result according to the on_result function
if on_results:
results = on_results(cursor_result.results)
else:
results = cursor_result.results
if group_by_field_name:
results = paginator.process_results(results=results)
# Add Manipulation functions to the response
if controller is not None:
results = controller(results)
@@ -211,6 +811,9 @@ class BasePaginator:
# Return the response
response = Response(
{
"grouped_by": group_by_field_name,
"sub_grouped_by": sub_group_by_field_name,
"total_count": (cursor_result.hits),
"next_cursor": str(cursor_result.next),
"prev_cursor": str(cursor_result.prev),
"next_page_results": cursor_result.next.has_results,
+2 -1
View File
@@ -60,4 +60,5 @@ zxcvbn==4.4.28
# timezone
pytz==2024.1
# jwt
PyJWT==2.8.0
PyJWT==2.8.0
+1 -1
View File
@@ -147,7 +147,7 @@ services:
plane-redis:
<<: *app-env
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:
+1 -1
View File
@@ -9,7 +9,7 @@ volumes:
services:
plane-redis:
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: unless-stopped
networks:
- dev_env
+1 -1
View File
@@ -116,7 +116,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: always
volumes:
- redisdata:/data
+3 -2
View File
@@ -34,10 +34,11 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.13.2"
"turbo": "^2.0.4"
},
"resolutions": {
"@types/react": "18.2.48"
},
"packageManager": "yarn@1.22.19"
"packageManager": "yarn@1.22.22",
"name": "plane"
}
+91
View File
@@ -0,0 +1,91 @@
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
INVALID_EMAIL = "5005",
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5016",
PASSWORD_LOGIN_DISABLED = "5018",
USER_ACCOUNT_DEACTIVATED = "5019",
// Password strength
INVALID_PASSWORD = "5020",
SMTP_NOT_CONFIGURED = "5025",
// Sign Up
USER_ALREADY_EXIST = "5030",
AUTHENTICATION_FAILED_SIGN_UP = "5035",
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
INVALID_EMAIL_SIGN_UP = "5045",
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
// Sign In
USER_DOES_NOT_EXIST = "5060",
AUTHENTICATION_FAILED_SIGN_IN = "5065",
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
INVALID_EMAIL_SIGN_IN = "5075",
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
// Both Sign in and Sign up for magic
INVALID_MAGIC_CODE_SIGN_IN = "5090",
INVALID_MAGIC_CODE_SIGN_UP = "5092",
EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
// Oauth
OAUTH_NOT_CONFIGURED = "5104",
GOOGLE_NOT_CONFIGURED = "5105",
GITHUB_NOT_CONFIGURED = "5110",
GITLAB_NOT_CONFIGURED = "5111",
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
GITLAB_OAUTH_PROVIDER_ERROR = "5121",
// Reset Password
INVALID_PASSWORD_TOKEN = "5125",
EXPIRED_PASSWORD_TOKEN = "5130",
// Change password
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
// set passowrd
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
// Rate limit
RATE_LIMIT_EXCEEDED = "5900",
}
+1 -5
View File
@@ -2,9 +2,5 @@
"name": "@plane/constants",
"version": "0.21.0",
"private": true,
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
}
"main": "./index.ts"
}
-371
View File
@@ -1,371 +0,0 @@
import { ReactNode } from "react";
import Link from "next/link";
export enum EPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
// TODO: remove this
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
INVALID_EMAIL = "5005",
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5017",
PASSWORD_LOGIN_DISABLED = "5019",
SMTP_NOT_CONFIGURED = "5025",
// Password strength
INVALID_PASSWORD = "5020",
// Sign Up
USER_ACCOUNT_DEACTIVATED = "5019",
USER_ALREADY_EXIST = "5030",
AUTHENTICATION_FAILED_SIGN_UP = "5035",
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
INVALID_EMAIL_SIGN_UP = "5045",
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
// Sign In
USER_DOES_NOT_EXIST = "5060",
AUTHENTICATION_FAILED_SIGN_IN = "5065",
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
INVALID_EMAIL_SIGN_IN = "5075",
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
// Both Sign in and Sign up for magic
INVALID_MAGIC_CODE = "5090",
EXPIRED_MAGIC_CODE = "5095",
EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100",
// Oauth
GOOGLE_NOT_CONFIGURED = "5105",
GITHUB_NOT_CONFIGURED = "5110",
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
// Reset Password
INVALID_PASSWORD_TOKEN = "5125",
EXPIRED_PASSWORD_TOKEN = "5130",
// Change password
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD= "5138",
INVALID_NEW_PASSWORD = "5140",
// set passowrd
PASSWORD_ALREADY_SET = "5145",
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
title: string;
message: ReactNode;
};
const errorCodeMessages: {
[key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// global
[EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: {
title: `Instance not configured`,
message: () => `Instance not configured. Please contact your administrator.`,
},
[EAuthErrorCodes.SIGNUP_DISABLED]: {
title: `Sign up disabled`,
message: () => `Sign up disabled. Please contact your administrator.`,
},
[EAuthErrorCodes.INVALID_PASSWORD]: {
title: `Invalid password`,
message: () => `Invalid password. Please try again.`,
},
[EAuthErrorCodes.SMTP_NOT_CONFIGURED]: {
title: `SMTP not configured`,
message: () => `SMTP not configured. Please contact your administrator.`,
},
// email check in both sign up and sign in
[EAuthErrorCodes.INVALID_EMAIL]: {
title: `Invalid email`,
message: () => `Invalid email. Please try again.`,
},
[EAuthenticationErrorCodes.EMAIL_REQUIRED]: {
title: `Email required`,
message: () => `Email required. Please try again.`,
},
// sign up
[EAuthenticationErrorCodes.USER_ALREADY_EXIST]: {
title: `User already exists`,
message: (email = undefined) => (
<div>
Your account is already registered.&nbsp;
<Link
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
href={`/sign-in${email ? `?email=${encodeURIComponent(email)}` : ``}`}
>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: {
title: `Invalid email`,
message: () => `Invalid email. Please try again.`,
},
[EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: {
title: `Email and code required`,
message: () => `Email and code required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: {
title: `Invalid email`,
message: () => `Invalid email. Please try again.`,
},
// sign in
[EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: {
title: `User account deactivated`,
message: () => <div>Your account is deactivated. Contact support@plane.so.</div>,
},
[EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: {
title: `User does not exist`,
message: (email = undefined) => (
<div>
No account found.&nbsp;
<Link
className="underline underline-offset-4 font-medium hover:font-bold transition-all"
href={`/${email ? `?email=${encodeURIComponent(email)}` : ``}`}
>
Create one
</Link>
&nbsp;to get started.
</div>
),
},
[EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: {
title: `Invalid email`,
message: () => `Invalid email. Please try again.`,
},
[EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: {
title: `Email and code required`,
message: () => `Email and code required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: {
title: `Invalid email`,
message: () => `Invalid email. Please try again.`,
},
// Both Sign in and Sign up
[EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: {
title: `Authentication failed`,
message: () => `Invalid magic code. Please try again.`,
},
[EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: {
title: `Expired magic code`,
message: () => `Expired magic code. Please try again.`,
},
[EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED]: {
title: `Expired magic code`,
message: () => `Expired magic code. Please try again.`,
},
// Oauth
[EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: {
title: `Google not configured`,
message: () => `Google not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: {
title: `GitHub not configured`,
message: () => `GitHub not configured. Please contact your administrator.`,
},
[EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
title: `Google OAuth provider error`,
message: () => `Google OAuth provider error. Please try again.`,
},
[EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: {
title: `GitHub OAuth provider error`,
message: () => `GitHub OAuth provider error. Please try again.`,
},
// Reset Password
[EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: {
title: `Invalid password token`,
message: () => `Invalid password token. Please try again.`,
},
[EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: {
title: `Expired password token`,
message: () => `Expired password token. Please try again.`,
},
// Change password
[EAuthenticationErrorCodes.MISSING_PASSWORD]: {
title: `Password required`,
message: () => `Password required. Please try again.`,
},
[EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: {
title: `Incorrect old password`,
message: () => `Incorrect old password. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: {
title: `Invalid new password`,
message: () => `Invalid new password. Please try again.`,
},
// set password
[EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: {
title: `Password already set`,
message: () => `Password already set. Please try again.`,
},
// admin
[EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
};
export const authErrorHandler = (
errorCode: EAuthenticationErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED,
EAuthenticationErrorCodes.INVALID_EMAIL,
EAuthenticationErrorCodes.EMAIL_REQUIRED,
EAuthenticationErrorCodes.SIGNUP_DISABLED,
EAuthenticationErrorCodes.INVALID_PASSWORD,
EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED,
EAuthenticationErrorCodes.USER_ALREADY_EXIST,
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP,
EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP,
EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP,
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP,
EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED,
EAuthenticationErrorCodes.USER_DOES_NOT_EXIST,
EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN,
EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN,
EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN,
EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN,
EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED,
EAuthenticationErrorCodes.INVALID_MAGIC_CODE,
EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE,
EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED,
EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED,
EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED,
EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR,
EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR,
EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN,
EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN,
EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD,
EAuthenticationErrorCodes.INVALID_NEW_PASSWORD,
EAuthenticationErrorCodes.PASSWORD_ALREADY_SET,
EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
@@ -3,6 +3,7 @@ import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
import { replaceCodeWithText } from "src/ui/extensions/code/utils/replace-code-block-with-text";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -54,69 +55,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleUnderline().run();
};
const replaceCodeBlockWithContent = (editor: Editor) => {
try {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
let replaced = false;
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// split the textContent by new lines to handle each line as a separate paragraph
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
// Calculate the position for inserting the first paragraph
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
const paragraphNode = paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
// Update insertPos for the next insertion
insertPos += paragraphNode.nodeSize;
});
// Dispatch the transaction
editor.view.dispatch(tr);
replaced = true;
};
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
if (node.type === schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
if (textContent.length === 0) {
editor.chain().focus().toggleCodeBlock().run();
}
replaceCodeBlock(startPos, endPos, textContent);
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
};
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
try {
// if it's a code block, replace it with the code with paragraphs
if (editor.isActive("codeBlock")) {
replaceCodeBlockWithContent(editor);
replaceCodeWithText(editor);
return;
}
@@ -124,11 +67,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
const text = editor.state.doc.textBetween(from, to, "\n");
const isMultiline = text.includes("\n");
// if the selection is not a range i.e. empty, then simply convert it into a code block
if (editor.state.selection.empty) {
editor.chain().focus().toggleCodeBlock().run();
} else if (isMultiline) {
// if the selection is multiline, then also replace the text content with
// a code block
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
} else {
// if the selection is single line, then simply convert it into inline
// code
editor.chain().focus().toggleCode().run();
}
} catch (error) {
@@ -110,6 +110,11 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
}
}
/* the p tag just after the ul tag */
ul[data-type="taskList"] + p {
margin-top: 0.4rem !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
@@ -152,6 +157,10 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
}
}
ul[data-type="taskList"] li > div > p {
margin-top: 10px;
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-400));
text-decoration: line-through;
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};
@@ -1,3 +1,5 @@
"use client";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { common, createLowlight } from "lowlight";
@@ -0,0 +1,124 @@
import { Editor, findParentNode } from "@tiptap/core";
type ReplaceCodeBlockParams = {
editor: Editor;
from: number;
to: number;
textContent: string;
cursorPosInsideCodeblock: number;
};
export function replaceCodeWithText(editor: Editor): void {
try {
const { from, to } = editor.state.selection;
const cursorPosInsideCodeblock = from;
let replaced = false;
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type === editor.state.schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
if (textContent.length === 0) {
editor.chain().focus().toggleCodeBlock().run();
} else {
transformCodeBlockToParagraphs({
editor,
from: startPos,
to: endPos,
textContent,
cursorPosInsideCodeblock,
});
}
replaced = true;
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
}
function transformCodeBlockToParagraphs({
editor,
from,
to,
textContent,
cursorPosInsideCodeblock,
}: ReplaceCodeBlockParams): void {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
// if the line is empty, create a paragraph node with no content
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
insertPos += paragraphNode.nodeSize;
});
// Now persist the focus to the converted paragraph
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
editor.view.dispatch(tr);
editor.chain().focus(cursorPosOutsideCodeblock).run();
}
/**
* Calculates the line number where the cursor is located inside the code block.
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
*
* @param {string} textContent - The content of the code block.
* @param {number} cursorPosition - The absolute cursor position in the document.
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
* @returns {number} The 1-based line number where the cursor is located.
*/
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
// Split the text content into lines, handling both Unix and Windows newlines
const lines = textContent.split(/\r?\n/);
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
let startPosition = 0;
let lineNumber = 0;
for (let i = 0; i < lines.length; i++) {
// Calculate the end position of the current line
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
// Check if the cursor position is within the current line
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
lineNumber = i + 1; // Line numbers are 1-based
break;
}
// Update the start position for the next line
startPosition = endPosition;
}
return lineNumber;
}
@@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
// Increment depth for each list ancestor found
depth++;
}
@@ -146,6 +146,8 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
if (!isAtStartOfNode(editor.state)) {
return false;
}
// is the paragraph node inside of the current list item (maybe with a hard break)
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
@@ -306,7 +308,10 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
if (
currentParagraphNode.type.name === "paragraph" &&
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
) {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === "paragraph") {
@@ -327,16 +332,19 @@ export function isCursorInSubList(editor: Editor) {
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
const taskItem = editor.schema.nodes.taskItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem) {
if (node.type === listItem || node.type === taskItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
(parent.type === editor.schema.nodes.bulletList ||
parent.type === editor.schema.nodes.orderedList ||
parent.type === editor.schema.nodes.taskList)
) {
return true;
}
@@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
} catch (e) {
console.log("error in handling Backspac:", e);
console.log("Error in handling Delete:", e);
return false;
}
},
@@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
} catch (e) {
console.log("error in handling Backspac:", e);
console.log("Error in handling Backspace:", e);
return false;
}
},
@@ -15,9 +15,7 @@ declare module "@tiptap/core" {
}
}
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!tr.isGeneric) return false;
function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
// Find all ranges where we might want to join.
const ranges: Array<number> = [];
for (let i = 0; i < tr.mapping.maps.length; i++) {
@@ -28,7 +26,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
const joinable = [];
const joinable: number[] = [];
for (let i = 0; i < ranges.length; i += 2) {
const from = ranges[i],
to = ranges[i + 1];
@@ -40,7 +38,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
const before = parent.child(index - 1);
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos);
}
pos += after.nodeSize;
}
@@ -88,25 +86,15 @@ export const CustomKeymap = Extension.create({
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
new Plugin({
key: new PluginKey("unordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
const joinableNodes = [
newState.schema.nodes["orderedList"],
newState.schema.nodes["taskList"],
newState.schema.nodes["bulletList"],
];
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
joined = anotherJoin || joined;
}
if (joined) {

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