Compare commits

...

252 Commits

Author SHA1 Message Date
pablohashescobar
08ec379e9c dev: update migration to only use file asset name 2024-04-30 20:44:21 +05:30
pablohashescobar
d560d1f7df Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-04-02 12:51:14 +05:30
Anmol Singh Bhatia
84e73291cc fix: draft issue title logic (#4104) 2024-04-01 17:53:40 +05:30
Sam
1423955a34 docs: fix incorrect Docker guide link (#4098) 2024-04-01 16:13:36 +05:30
Prateek Shourya
a771bab64c fix: display error message in project settings page. (#4099)
* chore: update `project title` to `project name` in create project modal.
2024-04-01 13:48:12 +05:30
M. Palanikannan
fafdac68f0 [WEB-876] fix: Multiple editor instances cause weird issues with Tippy (#4092)
* fix: tippy rendered properly when there are multiple editor instances

* enhancement: using cn to merge classes conditionally
2024-04-01 12:04:01 +05:30
Anmol Singh Bhatia
3a466cfe40 [WEB-838] fix: cycle dropdown fetch (#4076)
* fix: workspace issue properties fetch

* fix: issue modal cycle fetch issue

* fix: issue modal cycle fetch issue

* chore: project issue properties hook updated
2024-03-28 20:29:35 +05:30
Manish Gupta
86715f5b20 Chore: self host enhancements (#4091)
* latest changed to stable and .env changed to plane.env

* env changes

* script fix

* env changes

* sed fix

* codacy proposed fixes
2024-03-28 15:53:37 +05:30
Prateek Shourya
97fb08928f [WEB-834] fix: close shortcut modle on outside click. (#4078) 2024-03-28 15:04:56 +05:30
Prateek Shourya
8bdf8fa24f [WEB-836] fix: color picker overlapping with themes dropdown issue. (#4079) 2024-03-28 15:04:48 +05:30
Prateek Shourya
f1530688f8 [WEB-843] fix: chat with us option not woking in command k modal. (#4080) 2024-03-28 15:04:41 +05:30
Prateek Shourya
265ce7778a [WEB-840] chore: remove make a copy option from draft issue quick action menu. (#4081) 2024-03-28 15:04:26 +05:30
Prateek Shourya
79d227b906 [WEB-839] chore: limit project identifier to 5 letters in project settings page. (#4082) 2024-03-28 15:04:16 +05:30
Prateek Shourya
5d4b3cea4c [WEB-833] chore: remove create a new issue option from command k modal if no projects are there. (#4083) 2024-03-28 14:32:16 +05:30
Prateek Shourya
2c510a7cb9 [WEB-852] chore: remove Estimate Demand data from the anaylytics. (#4084) 2024-03-28 14:31:52 +05:30
Prateek Shourya
478535422b [WEB-851] chore: update all instances of Dashboard with Home. (#4085) 2024-03-28 14:31:28 +05:30
sriram veeraghanta
35ca495f40 chore: remove develop builds on push 2024-03-28 14:05:04 +05:30
sriram veeraghanta
68fa7a485b chore: apiserver version changes 2024-03-28 13:59:52 +05:30
sriram veeraghanta
ecfd3b68e3 chore: package version upgrade 2024-03-28 13:31:45 +05:30
pablohashescobar
6a6d0d881c dev: fix imports 2024-03-27 15:57:05 +05:30
pablohashescobar
6cc29dca52 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-03-27 15:45:23 +05:30
Anmol Singh Bhatia
9249e6d5b9 [WEB-849] fix: issue detail identifier and workspace settings (#4073)
* fix: issue detail identifier

* fix: workspace settings user role validation
2024-03-26 20:38:54 +05:30
Nikhil
30cee78170 dev: fix api security error (#4072) 2024-03-26 20:38:25 +05:30
Nikhil
4c46b075b4 fix: module endpoint validations (#4071) 2024-03-26 19:48:52 +05:30
sriram veeraghanta
f3d9053d81 fix: unsafe external link fixes 2024-03-26 19:43:22 +05:30
sriram veeraghanta
a136931168 fix: codeql workflow push and pull request chanages 2024-03-26 19:40:48 +05:30
sriram veeraghanta
5235b78cb8 fix: sentry config fixes 2024-03-26 19:24:42 +05:30
Bavisetti Narayan
1c3619a4d6 chore: workspace estimate project filter (#4070) 2024-03-26 16:59:23 +05:30
Anmol Singh Bhatia
f3fd48dd43 chore: image picker popover improvement (#4068) 2024-03-26 16:30:57 +05:30
sriram veeraghanta
c6f36a056b fix: adding workflow dispatch to codeql 2024-03-26 15:55:47 +05:30
Anmol Singh Bhatia
51683e6b2f fix: active cycle stats empty state (#4067) 2024-03-26 15:53:52 +05:30
Nikhil
fb189ca447 fix: cycle issue subquery filter when adding multiple assignees (#4066) 2024-03-26 15:49:44 +05:30
Anmol Singh Bhatia
ea728a385f [WEB-831] fix: sentry issue and code refactor (#4063)
* chore: unnecessary console log removed

* chore: in_use sentry issue resolved

* chore: detail sentry issue resolved

* fix: updated project logo validation in project icon

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-26 15:13:26 +05:30
Nikhil
7452e40134 fix: optimize cycles endpoint for sql (#4065) 2024-03-26 15:08:00 +05:30
Bavisetti Narayan
599809dd2e chore: removed project from workspace estimate (#4064) 2024-03-26 14:59:28 +05:30
Anmol Singh Bhatia
ad5559afe4 fix: peek overview delete modal (#4062) 2024-03-25 19:21:00 +05:30
Anmol Singh Bhatia
11c3d4fbd8 chore: issue reaction keys (#4061)
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-25 19:20:33 +05:30
Ramesh Kumar Chandra
20795db9e8 chore: readme update (#4060) 2024-03-25 19:19:55 +05:30
sriram veeraghanta
7e9daf8a20 fix: bad html filtering regexp 2024-03-25 18:54:55 +05:30
igeni
07106f9161 fixed bug with missed color #FFFFFF (#4050) 2024-03-25 16:22:20 +05:30
Anmol Singh Bhatia
4b8a1353fc fix: kanban layout quick add (#4058) 2024-03-25 16:20:26 +05:30
Anmol Singh Bhatia
8205961d2a [WEB-826] chore: integrations (#4057)
* fix: workspace integration page

* chore: remove integrations and code refactor
2024-03-25 16:19:45 +05:30
sriram veeraghanta
53c63c8a2b Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-25 14:19:35 +05:30
dependabot[bot]
564d64a30e chore(deps): bump follow-redirects from 1.15.4 to 1.15.6 (#4056)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-25 14:19:02 +05:30
dependabot[bot]
961fdc17d5 chore(deps): bump django in /apiserver/requirements (#4055)
Bumps [django](https://github.com/django/django) from 4.2.10 to 4.2.11.
- [Commits](https://github.com/django/django/compare/4.2.10...4.2.11)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-25 14:18:24 +05:30
Anmol Singh Bhatia
7e0037b06b [WEB-823] fix: quick add flicker and dropdown placement in calendar layout (#4054)
* chore: placement prop added in quick action menu

* fix: calenar layout quick action menu placement

* fix: calendar quick add flicker
2024-03-25 14:17:34 +05:30
Anmol Singh Bhatia
9f9e508bb7 [WEB-812] chore: project active cycle stats empty state (#4053)
* chore: empty state asset and config file updated

* chore: empty state asset and config file updated

* chore: active cycle empty state implementation
2024-03-25 13:16:53 +05:30
Bavisetti Narayan
2429ac053d [WEB-820] fix: state count in cycle and module (#4052)
* fix: state count in cycle and module

* fix: issue manager change
2024-03-25 13:15:53 +05:30
Aaryan Khandelwal
31b2fa2c14 fix: profile activity load more button (#4049) 2024-03-22 20:46:13 +05:30
guru_sainath
ec837a42d5 fix: handled undefined condition on issue title when we are converting into draft (#4048) 2024-03-22 20:06:49 +05:30
guru_sainath
b5ed602e05 fix: disbaled the issue property check in the issue create/edit modal (#4038) 2024-03-22 18:46:38 +05:30
sriram veeraghanta
9652d56e49 chore: removing deepsource.toml file (#4046) 2024-03-22 18:42:33 +05:30
Prateek Shourya
2f637fc9f1 fix: project total issue count mutation. (#4045) 2024-03-22 18:39:26 +05:30
Prateek Shourya
95d2b6f1c1 style: hide cycle and module dropdown placeholder from list and kanban view. (#4044) 2024-03-22 18:39:06 +05:30
Anmol Singh Bhatia
5c7886d7f3 [WEB-811] chore: filter dropdown custom date select improvement (#4043)
* fix: handled custom start_date and target_date custom filter in project-issues

* chore: filter dropdown improvement

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-22 18:38:39 +05:30
sriram veeraghanta
e4211e5817 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-22 18:38:03 +05:30
Aaryan Khandelwal
1caceca1e7 [WEB-818] fix: sub-issue and attachment delete not working in the peek overview (#4042)
* fix: delete attachment not working in the peek overview

* fix: sub-issue delete not working

* fix: lint errors
2024-03-22 18:37:11 +05:30
Anmol Singh Bhatia
53f3357149 chore: cycle list page alignment (#4041) 2024-03-22 18:36:34 +05:30
Anmol Singh Bhatia
cdf86391f0 fix: module filter and order by (#4040) 2024-03-22 18:36:09 +05:30
guru_sainath
72392d5731 fix: resetting the changesMage when the createmore toggle is turned on in issue create-edit modal (#4039) 2024-03-22 18:35:40 +05:30
guru_sainath
db8cf1fb24 fix: handled quick actions in the project draft issues (#4034) 2024-03-22 18:35:06 +05:30
Lakhan Baheti
d262eb4ffb [WEB-800] fix: issue peekview, and detail page create label colour selector autoplacement (#4032)
* fix: issue peekview, and detail label colour selector autoplacement

* fix: build errors
2024-03-22 18:30:23 +05:30
guru_sainath
60aea62739 fix: handled inital empty state close in the issue create/edit odal (#4033) 2024-03-22 18:28:44 +05:30
Anmol Singh Bhatia
baab6ce99f fix: project order by dropdown (#4037) 2024-03-22 18:26:44 +05:30
Anmol Singh Bhatia
5aed04eb41 fix: module and cycle list page quick action (#4031) 2024-03-21 21:16:06 +05:30
guru_sainath
165bec9aa4 fix: handled the empty issue propety, create more in edit modal, and moving drafts from issue functionality (#4030) 2024-03-21 21:06:55 +05:30
Prateek Shourya
231fd52992 [WEB-447] feat: projects archive. (#4014)
* dev: project archive response

* feat: projects archive.

* dev: response changes for cycle and module

* chore: status message changed

* chore: update clear all applied display filters logic.

* style: archived project card UI update.

* chore: archive/ restore taost message update.

* fix: clear all applied display filter logic.

* chore: project empty state update to handle archived projects.

* chore: minor typo fix in cycles and modules archive.

* chore: close cycle/ module overview sidebar if it's already open when clicked on overview button.

* chore: optimize current workspace applied display filter logic.

* chore: update all `archived_at` type from `Date` to `string`.

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-21 20:59:34 +05:30
Lakhan Baheti
9642b761b7 fix: issue card assignee empty state tooltip (#4029) 2024-03-21 20:59:08 +05:30
guru_sainath
86ae79f851 fix: resolved rendering issues in list and kanban layout when we apply group-by filter (#4028) 2024-03-21 20:47:36 +05:30
Anmol Singh Bhatia
991b8e7703 chore: custom month select modal validation added (#4027) 2024-03-21 20:47:22 +05:30
Anmol Singh Bhatia
56d4e25431 fix: project list view dropdown placement (#4026) 2024-03-21 20:47:07 +05:30
Prateek Shourya
23f77328d4 fix: prevent custom menu button from opening cycle/ module details page when closed. (#4025) 2024-03-21 20:46:33 +05:30
guru_sainath
58a45c96c0 fix: rendering default state in the issue create update modal whenever we switch between the projects in the issue modal (#4024) 2024-03-21 20:46:18 +05:30
Anmol Singh Bhatia
15e04e55bb [WEB-692] fix: project modal cover image popover z-index fix (#4018)
* fix: project modal cover image popover z-index fix

* fix: project modal cover image popover z-index fix
2024-03-21 20:46:02 +05:30
pablohashescobar
14c6cc1420 Merge branch 'update-file-uploads' of github.com:makeplane/plane into update-file-uploads 2024-03-21 17:24:04 +05:30
pablohashescobar
9bc144649a dev: fix conflicts and errors 2024-03-21 17:21:44 +05:30
Anmol Singh Bhatia
8911eaf676 fix: profile cover fix (#4010) 2024-03-21 15:36:16 +05:30
sriram veeraghanta
0d2a8cca50 chore: project structuring 2024-03-21 14:58:16 +05:30
pablohashescobar
6037321572 dev: fix initial set of errora 2024-03-21 14:51:08 +05:30
pablohashescobar
4165111ce1 dev: remove unused imports 2024-03-21 14:10:22 +05:30
pablohashescobar
4e88854d46 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-03-21 14:08:41 +05:30
Prateek Shourya
061be85a5d feat: cycles and modules archive. (#4005)
* fix: GET request changes

* fix: filtering changes

* feat: cycles and modules archive.

* chore: disable fetching of cycle/ module details when clicked on the card in archives page.

* chore: remove copy link button from archived modules/ cycles.

* fix: archived cycle and module loading fliker issue.

* chore: add validation to only archive completed cycles.

* chore: add validation to only archive completed or cancelled module.

* chore: archived issues/ cycles/ modules empty state update.

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-20 21:02:58 +05:30
Bavisetti Narayan
4d1b5adfc4 dev: assignee leave changes (#4009) 2024-03-20 20:42:14 +05:30
Anmol Singh Bhatia
3ff0f6187a [WEB-762] chore: cycle, module modal improvement and cycle, module (#4000)
* chore: cycle modal description improvement

* chore: updated the description ui in cycles and modules

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-20 20:41:49 +05:30
Anmol Singh Bhatia
37c5ce54d5 fix: peek overview sub issue operation (#4002) 2024-03-20 20:00:34 +05:30
Anmol Singh Bhatia
054dd2bb7d fix: module empty state (#4004) 2024-03-20 20:00:13 +05:30
Bavisetti Narayan
180f1d74e8 dev: removed double issue count (#4008) 2024-03-20 19:59:31 +05:30
Lakhan Baheti
6ab8588afb fix: create label inline overflow (#4006) 2024-03-20 19:32:42 +05:30
Nikhil
e1f04356fc dev: add -p flag for the logs folder (#4007) 2024-03-20 19:28:19 +05:30
Lakhan Baheti
4eefc7f692 [WEB-784] chore: create workspace fields as required indication (#4003)
* chore: create workspace fields as required indication

* style: gap between asterisk & text
2024-03-20 19:22:02 +05:30
Anmol Singh Bhatia
7142889c23 [WEB-413] chore: project active cycle UI revamp (#3997)
* chore: project active cycle ui revamp

* chore: resolved liniting issues

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-20 19:19:14 +05:30
guru_sainath
bca3e13242 fix: project inifinite loader when we go the workspace projects for the first time (#3999) 2024-03-20 18:08:42 +05:30
guru_sainath
e02fa4d9e7 fix: autofocus on the title element when we are creating the bulk issues in the issue create modal (#3998) 2024-03-20 18:08:21 +05:30
guru_sainath
621624e29f [WEB-383] fix: rendering state and members from different projects in sub-issues (#3996)
* fix: rendering the state and memebers from different projects in sub issues and exception handling while creating an issue from different project

* chore: handled different project issue properties in a new function handler
2024-03-20 18:07:35 +05:30
Lakhan Baheti
4ea616f1cd fix: Labels overflow in peek, detail view (#3995) 2024-03-20 18:07:11 +05:30
Lakhan Baheti
901a7703ff fix: project, module, cycle filter store infinite loop (#3994) 2024-03-20 18:06:45 +05:30
Bavisetti Narayan
2f883e4939 [WEB-780]feat: project cycle module archive (#3990)
* dev: project cycle module archive

* fix: update filter logic updated
2024-03-20 13:59:31 +05:30
rahulramesha
cb4cfa1dd5 [WEB-782] fix: Date timezone changes error (#3992)
* fix date related exceptions

* replace new Date from develop branch changes as well

* changes from self review

* wrap getDate logic with try catch to handle in case of error scenarios

* fix formatted time
2024-03-20 13:44:08 +05:30
guru_sainath
7d3a96b3d0 [WEB-763] fix: workspace remains listed after leaving the workspace in the user profile (#3993)
* chore: build error

* fix: workspace not getting removed when user leaves the workspace
2024-03-20 13:43:18 +05:30
Wiebe Verweij
0f79c6d7d8 Fix test_email command to use new SSL setting (#3988)
This was missed in pull request #3732 and/or the command didn't exist yet then ...
2024-03-20 12:46:40 +05:30
Lakhan Baheti
6a245e121a fix: analytics dialog close on outside click (#3987) 2024-03-20 12:42:10 +05:30
Nikhil
293854fb3a fix: user caching when the user leaves/deletes the workspace (#3989) 2024-03-20 12:38:00 +05:30
sriram veeraghanta
53ddef1cd5 fix: eslint fixes and file formatting 2024-03-19 20:08:35 +05:30
sriram veeraghanta
473dfc7a5b fix: merge conflicts resolved 2024-03-19 18:00:43 +05:30
rahulramesha
1a462711e1 [WEB-761] fix: Time zone date misalignment (#3986)
* fix date time misalignment

* fix failing build

* fix gantt chart view position fix

* comments for getDate method

* remove new Date() where not required

* changes from my self review
2024-03-19 17:40:20 +05:30
sriram veeraghanta
3139f8e109 fix: merge conflicts resolved 2024-03-18 19:58:39 +05:30
sriram veeraghanta
aa3702cd46 fix: codeql check workflow changes 2024-03-18 19:50:58 +05:30
sriram veeraghanta
aed87ef472 fix: build branch workflow dispatch trigger changes 2024-03-18 19:48:44 +05:30
rouja
1b742f66c6 Fix email configuration issue (#3732) 2024-03-18 19:43:00 +05:30
Lakhan Baheti
568701881e fix: calendar-layout draggable wrapper condition (#3984) 2024-03-18 19:35:17 +05:30
Lakhan Baheti
2dbea54d2a fix: Mobile header visibility (#3983) 2024-03-18 16:54:45 +05:30
Nikhil
82ba9833f2 dev: enable api logging (#2538)
* dev: enable api logging and control worker count through env

* dev: enable logger instead of printing

* dev: remove worker counts

* dev: enable global level log settings

* dev: add rotating logger

* fix: logging configuration

* dev: api logging and moving the capture exception to utils for logging and then capturing

* fix: information leaking through print logs

* dev: linting fix

* dev: logging configuration for django

* fix: linting errors

* dev: add logs for migrator

* dev: logging cofiguration

* dev: add permision for captain user in Plane

* dev: add log paths in compose

* dev: create directory for logs

* dev: fix linting errors
2024-03-18 14:27:02 +05:30
M. Palanikannan
0759666b75 [WEB-755] fix: Clearing nodes to default node i.e. paragraph before converting it to other type (#3974)
* fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes

For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes

* chore: clearNodes after delete in case of selections being present

* fix: hiding link selector in the bubble menu if inline code block is selected
2024-03-18 12:51:35 +05:30
Lakhan Baheti
e9774e1af3 [WEB-759] style: added calendar layout responsiveness (#3969)
* style: added calendar layout responsiveness

* fix: quick-add-form

* fix: popover menu close on item select

* fix: class conditiion

* code review
2024-03-18 12:51:19 +05:30
Ramesh Kumar Chandra
4a93fdbdb4 [WEB-700] chore: sidebar hamburger refactor (#3960) 2024-03-18 12:50:33 +05:30
Aaryan Khandelwal
861a1c4132 [WEB-738] chore: conditionally render projects in the dropdown (#3952)
* chore: show authorized projects in the dropdown

* refactor: command palette logic

* chore: add helper function to check for project role

* fix: add preventDefault for shortcuts
2024-03-15 17:44:01 +05:30
Anmol Singh Bhatia
5244ba72c9 [WEB-746] chore: draft issue modal improvement (#3966)
* chore: draft issue modal improvement

* chore: draft issue modal improvement

* chore: draft issue modal improvement

* chore: draft issue modal improvement
2024-03-15 17:43:42 +05:30
Anmol Singh Bhatia
36bc7bf996 chore: app sidebar collapsed state alignment improvement (#3976) 2024-03-15 17:36:20 +05:30
Aaryan Khandelwal
0789238282 fix: cycle layout not getting initialized (#3961) 2024-03-15 17:31:28 +05:30
Anmol Singh Bhatia
c17748eec2 chore: completed cycle empty state valdidation updated (#3959) 2024-03-15 17:30:26 +05:30
Anmol Singh Bhatia
94f1e6d957 [WEB-698] chore: profile activity page scrollbar added (#3958)
* chore: profile activity page scrollbar added

* chore: profile activity page padding improvement
2024-03-15 17:29:46 +05:30
Aaryan Khandelwal
13bbb9cde4 [WEB-699] chore: implement sub-issues and attachments in the peek overview (#3956)
* chore: implement sub-issues and attachments in the peek overview

* chore: add the same to full-screen view
2024-03-15 17:29:22 +05:30
Anmol Singh Bhatia
92a077dce1 [WEB-504] chore: command k and issue relation modal empty state (#3955)
* chore: empty state asset updated

* chore: empty state asset updated

* chore: empty state config file updated

* chore: notification empty state updated

* chore: command-k, bulk delete and issue relation modal empty state updated

* chore: code refactor

* chore: code refactor
2024-03-15 17:28:45 +05:30
Lakhan Baheti
ed2e4ad6f7 fix: cycle & module peekview scroll (#3953) 2024-03-15 17:28:03 +05:30
Anmol Singh Bhatia
cbe5d9a047 chore: issue reaction response updated (#3951) 2024-03-15 17:26:38 +05:30
Ramesh Kumar Chandra
d25fcfdd88 [WEB-715] chore: closing custom dropdown on button click (#3950) 2024-03-15 17:25:57 +05:30
Aaryan Khandelwal
aaad37575b fix: cycle date check wrong projectId (#3972) 2024-03-14 20:11:10 +05:30
Aaryan Khandelwal
6bc133e3b1 chore: swapped dates and status properties in the cycle list item (#3957) 2024-03-14 20:02:18 +05:30
Bavisetti Narayan
42b524b16a chore: project active filter (#3971) 2024-03-14 19:22:54 +05:30
Anmol Singh Bhatia
0bc4b6cece chore: ai modal disclamier added (#3970) 2024-03-14 19:16:07 +05:30
guru_sainath
43c75f4457 fix: rendering Issue in kanban swimlanes view for unassigned cycle or Modules (#3965) 2024-03-14 12:43:29 +05:30
sriram veeraghanta
80761d3507 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-14 12:42:33 +05:30
sriram veeraghanta
9c13dbd957 chore: 1click docs readme update 2024-03-13 17:26:52 +05:30
sriram veeraghanta
8d9adf4d87 chore: 1click docs readme update 2024-03-13 17:26:04 +05:30
sriram veeraghanta
4ccb505f36 chore: 1click docs changes 2024-03-13 17:24:37 +05:30
sriram veeraghanta
884856b021 chore: 1click docs update 2024-03-13 17:18:11 +05:30
sriram veeraghanta
552c66457a chore: 1click docs update 2024-03-13 17:17:40 +05:30
P B
1363ef0b2d Updated one-click deploy's readme for readability and presentation (#3962)
Changed title, first paragraph, and bullets.
2024-03-13 17:13:11 +05:30
guru_sainath
898cf98be3 [WEB-749] fix: rendering empty states with "showEmptyGroup" filter in issue grouping (#3954)
* fix: Fixed show empty states in groupBy and subGroupBy in kanban

* lint: lint issue resolved
2024-03-13 16:59:12 +05:30
Ramesh Kumar Chandra
cb632408f9 [WEB-713] style: remove tooltips in mobile responsiveness (#3948)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-12 20:39:36 +05:30
Aaryan Khandelwal
b930d98665 [WEB-554] feat: modules filtering, searching and ordering (#3947)
* feat: modules filtering, searching and ordering implemented

* fix: modules ordering

* chore: total issues in list endpoint

* fix: modules ordering

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-12 20:24:21 +05:30
Aaryan Khandelwal
69e110f4a8 [WEB-578] feat: projects list filtering and ordering (#3926)
* style: project card UI updated

* dev: initialize project filter store and types

* chore: implemented filtering logic

* chore: implemented ordering

* chore: my projects filter added

* chore: update created at date filter options

* refactor: order by dropdown

* style: revert project card UI

* fix: project card z-index

* fix: members filtering

* fix: build errors
2024-03-12 19:36:40 +05:30
Lakhan Baheti
c3c6ef8830 chore: removed module tooltip from peek-overview & issue detail (#3946) 2024-03-12 19:13:04 +05:30
Anmol Singh Bhatia
8aca74c68d [WEB-720] chore: reaction tooltip added (#3945)
* chore: reaction tooltip added

* chore: reaction tooltip updated

* chore: issue reaction tooltip updated

* chore: helper function updated
2024-03-12 19:12:25 +05:30
Prateek Shourya
c97b994311 [WEB-717] fix: delete issue modal UI fixes. (#3944)
* fix: avoid closing the peek overview when the close button within the delete model popup is clicked.

* fix: distortion in issue detail sidebar UI when any modal is open.
2024-03-12 19:11:36 +05:30
Lakhan Baheti
443b93f897 fix: label mutation in peek-overview & issue detail (#3942) 2024-03-12 19:10:35 +05:30
Aaryan Khandelwal
730e556bea fix: emoji picker unnecessary scroll (#3941) 2024-03-12 19:09:51 +05:30
Aaryan Khandelwal
f77f4b8221 fix: gantt sidebar links (#3940) 2024-03-12 19:07:12 +05:30
Anmol Singh Bhatia
5c4c3f5c04 fix: project page empty state (#3939) 2024-03-12 19:05:15 +05:30
Ramesh Kumar Chandra
73c91654eb [WEB-716] style: list view issues responsiveness (#3938) 2024-03-12 12:29:00 +05:30
Bavisetti Narayan
578bd29f6f chore: active issue total count (#3936) 2024-03-12 12:24:58 +05:30
Nikhil
2e5e14556d feat: test email script (#3937)
* feat: script for testing emails

* chore: rename subject and body for the mail
2024-03-12 12:00:18 +05:30
sriram veeraghanta
ecac45e885 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-12 01:12:57 +05:30
sriram veeraghanta
6ec9c64f7c fix: build branch optional builds workflow changes 2024-03-12 01:06:37 +05:30
sriram veeraghanta
f493a03f56 fix: adding github workflow events 2024-03-12 01:01:03 +05:30
Anmol Singh Bhatia
48c9b78397 [WEB-545] chore: completed cycle empty state (#3931)
* chore: completed cycle issue transfer empty state added

* chore: cycle empty state variable updated

* chore: empty state config file updated
2024-03-11 21:23:09 +05:30
Anmol Singh Bhatia
9c29ad1a28 chore: list layout scrollbar improvement (#3933) 2024-03-11 21:12:59 +05:30
Aaryan Khandelwal
e3ac075ee2 chore: error state for the issues widgets (#3934) 2024-03-11 21:12:28 +05:30
Anmol Singh Bhatia
8c9d328c24 chore: profile setting scroll fix and scrollbar added in activity section (#3929) 2024-03-11 21:09:41 +05:30
Aaryan Khandelwal
b2146098e2 chore: added view less button to the collaborators widget (#3928) 2024-03-11 21:09:16 +05:30
Nikhil
4a06572f73 chore: remove deprecated envs (#3927) 2024-03-11 21:08:10 +05:30
M. Palanikannan
d34df5c805 fix: updated extensions in read only instance of the editor (#3925) 2024-03-11 21:05:27 +05:30
Anmol Singh Bhatia
01702e9f66 [WEB-402] chore: project issues count (#3911)
* chore: added project issues count

* chore: project module and cycle issue count

* chore: resolved merge conflicts

* chore: added import statement

* chore: issue count type added

* chore: issue count added in project, cycle and module issues

* fix: lint fixes

* chore: tooltip added in issue count badge

* chore: tooltip added in issue count badge

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 21:05:00 +05:30
Nikhil
b57c389c75 fix: caching (#3923) 2024-03-11 21:04:43 +05:30
Lakhan Baheti
27acd1bec1 fix: cycle & module sidebar progress-stats scroll (#3932) 2024-03-11 21:03:30 +05:30
Anmol Singh Bhatia
539afb0792 chore: issue quick action dropdown max height updated (#3919) 2024-03-11 21:02:28 +05:30
Anmol Singh Bhatia
6c7ba3bc79 chore: applied filter ui improvement (#3918) 2024-03-11 21:01:59 +05:30
Anmol Singh Bhatia
c8ab650008 fix: module empty state create issue (#3916) 2024-03-11 21:01:27 +05:30
Anmol Singh Bhatia
89d019df57 fix: parent select modal params updated (#3914) 2024-03-11 21:01:07 +05:30
Aaryan Khandelwal
535731141f [WEB-682] feat: cycles list filtering and searching (#3910)
* chore: implemented cycles list filters and ordering

* chore: active cycle tab updated

* refactor: cycles folder structure

* fix: name search inout auto-focus

* fix: cycles ordering

* refactor: move cycle filters logic to mobx store from local storage

* chore: show completed cycles in a disclosure

* chore: added completed cycles count

* refactor: cycles mapping logic

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 21:00:05 +05:30
M. Palanikannan
4b30339a59 [WEB-638] feat: add tabIndex prop support to navigate using Tab key in all the editors (#3902)
* feat: added tab index support to navigate using Tab key in all the editors

* chore: changed the name of Table of Contents in Pages

* chore: file formatting

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 20:58:23 +05:30
M. Palanikannan
899771a678 [WEB-434] feat: add support to insert a new empty line on clicking at bottom of the editor (#3856)
* fix: horizontal rule no more causes issues on last node

* fixed the mismatched transaction by using native tiptap stuff

* added support to add new line onclick at bottom if last node is an image

TODO: blockquote at last node

* fix: simplified adding node at end of the document logic

* feat: rewrite entire logic handling all cases

* feat: arrow down and arrow up keys add empty node at top and bottom of doc from first/last row's cells

* feat: added arrow up and down key support to images too

* remove unnecessary console logs

* chore: formatting components

* fix: reduced bottom padding to increase onclick area

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 20:55:24 +05:30
sriram veeraghanta
8997ee2e3e fix: eslint fixes 2024-03-11 20:45:51 +05:30
Anmol Singh Bhatia
e05bc3965c chore: update the label of the app sidebar dashboard to home (#3912) 2024-03-08 17:59:02 +05:30
Aaryan Khandelwal
b07fec533c fix: date filter modal selection (#3913) 2024-03-08 17:58:27 +05:30
Anmol Singh Bhatia
cc069b61aa fix: empty state error handling added and page empty state key updated (#3915) 2024-03-08 17:57:48 +05:30
Prateek Shourya
2074bb97db [WEB-440] feat: create project feature selection modal. (#3909)
* [WEB-440] feat: create project feature selection modal.
* [WEB-399] chore: explain project identifier.

* chore: use `Link` component for redirection to project page.
2024-03-08 17:38:42 +05:30
Prateek Shourya
f5151ae717 [WEB-666] chore: rename View profile to My activity. (#3906) 2024-03-08 17:37:01 +05:30
Prateek Shourya
deb5ac0578 [WEB-665] fix Padding in the project dropdown. (#3905) 2024-03-08 17:36:28 +05:30
Ramesh Kumar Chandra
cead56cc52 [WEB - 671] chore: remove unused images in the app (#3901) 2024-03-08 17:32:00 +05:30
sriram veeraghanta
cb78ccad1f fix: 1click deployment fixes 2024-03-08 16:58:37 +05:30
Manish Gupta
6c6b7156bb helm variable update 2024-03-08 16:00:18 +05:30
Manish Gupta
8cc372679c Web Build fixes 2024-03-08 15:56:05 +05:30
Manish Gupta
94327b8311 chore: feature build process optimization (#3907)
* process changed to build tar and use for deployment

* fixes
2024-03-08 15:16:32 +05:30
Anmol Singh Bhatia
7b88a2a88c [WEB-469] chore: selected filter sorting added in filter dropdown (#3869)
* chore: selected filter sorting added in filter dropdown

* chore: handleClearAllFilters function updated

* chore: filter dropdown sorting updated

* chore: filter dropdown sorting updated

* chore: filter dropdown sorting updated

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-07 16:53:59 +05:30
Lakhan Baheti
47a7f60611 [WEB-667] fix: unable to deselect project lead in create project modal (#3898)
* fix: unable to deselect project lead in create project modal

* removed unneccessary code
2024-03-07 16:53:43 +05:30
guru_sainath
7a8aef4599 Fix: rendering issue in kanban swimlanes when the cycle or module is not assigned to an issue (#3899) 2024-03-07 15:56:02 +05:30
Bavisetti Narayan
bc02e56e3c [WEB-664] refactor: folder structure (#3884)
* refactor: folder structure

* chore: resolved merge conflicts
2024-03-07 13:33:12 +05:30
Lakhan Baheti
b03f6a81e2 fix: project members settings flickering (#3894) 2024-03-07 13:26:58 +05:30
guru_sainath
b535d8a23c [WED-634] fix: profile issues not rendering correctly with GroupBy display filter (#3886)
* fix: Handled issue render in the display filters groupBy stateGroup and labels

* chore: Optimized state_group filter and typo

* Fix: removed workspaceLevel boolean and handled the states from stateMap
2024-03-07 13:25:23 +05:30
sriram veeraghanta
bce69bcbe1 fix: formatting files 2024-03-06 20:50:38 +05:30
Nikhil
1fa47a6c04 [WEB - 549] dev: formatting and removing unused imports (#3782)
* dev: formatting and removing unused imports

* dev: remove unused imports and format all the files

* fix: linting errors

* dev: format using ruff

* dev: remove unused variables
2024-03-06 20:48:30 +05:30
rahulramesha
c16a5b9b71 [WEB-626] chore: fix sentry issues and refactor issue actions logic for issue layouts (#3650)
* restructure the logic to avoid throwing error if any dat is not found

* updated files for previous commit

* fix build errors

* remove throwing error if userId is undefined

* optionally chain display_name property to fix sentry issues

* add ooptional check

* change issue action logic to increase code maintainability and make sure to send only the updated date while updating the issue

* fix issue updation bugs

* fix module issues build error

* fix runtime errors
2024-03-06 20:47:38 +05:30
Nikhil
a852e3cc52 chore: integrations and importers (#3630)
* dev: update imports to use jira oauth

* dev: remove integration and importer folders and files
2024-03-06 20:41:45 +05:30
Nikhil
ed8782757d [WEB - 471] dev: caching users and workspace apis (#3707)
* dev: caching users and workspace apis

* dev: cache user and config apis

* dev: update caching function to use user_id instead of token

* dev: update caching layer

* dev: update caching logic

* dev: format caching file

* dev: refactor caching to include name space and user id as key

* dev: cache project cover image endpoint
2024-03-06 20:39:50 +05:30
M. Palanikannan
549f6d0943 [WEB-438] fix: ai insertion behaviour (#3872)
* fixed ai insertion behaviour

* replaced all ai popover references to have similar behavior

* chore: removed debug statements
2024-03-06 20:38:57 +05:30
Aaryan Khandelwal
cb5198c883 fix: breadcrumb loading state for the issue details page (#3892)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-06 20:38:21 +05:30
sriram veeraghanta
b4adb82d40 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-03-06 20:19:01 +05:30
sriram veeraghanta
3f1ce9907d fix: auto merge fixes 2024-03-06 20:18:47 +05:30
Anmol Singh Bhatia
da735f318a [WEB-404] chore: calendar layout add existing issue workflow improvement (#3877)
* chore: target date none filter

* chore: calendar layout add existing issue functionality added for cycle and module

* fix: enums export in the types package

* chore: remove NestedKeyOf type

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-03-06 20:17:32 +05:30
Anmol Singh Bhatia
a08f401452 [WEB-630] refactor: empty state (#3858)
* refactor: empty state global config file added and empty state component refactor

* refactor: empty state component refactor

* chore: empty state refactor

* chore: empty state config file updated

* chore: empty state action button permission logic updated

* chore: empty state config file updated

* chore: cycle and module empty filter state updated

* chore: empty state asset updated

* chore: empty state config file updated

* chore: empty state config file updated

* chore: empty state component improvement

* chore: empty state action button improvement

* fix: merge conflict
2024-03-06 20:16:54 +05:30
sriram veeraghanta
4861be2773 fix: adding missing deps 2024-03-06 20:15:42 +05:30
sriram veeraghanta
6a6ab5544a fix: build issues 2024-03-06 20:01:14 +05:30
sriram veeraghanta
466f69a0b9 Merge branch 'develop' of github.com:makeplane/plane into develop 2024-03-06 19:52:46 +05:30
sriram veeraghanta
f188c9fdc5 fix: build issues 2024-03-06 19:52:40 +05:30
guru_sainath
dd579f83ee chore: enabled module and cycle display properties in module and cycle issues (#3885) 2024-03-06 19:27:04 +05:30
Anmol Singh Bhatia
66f2492e60 [WEB-655] chore: peek overview dropdowns keyboard navigation improvement (#3888)
* fix: enums export in the types package

* chore: remove NestedKeyOf type

* chore: peek overview keyboard accessibility improvement

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-03-06 19:17:50 +05:30
Aaryan Khandelwal
c08d6987d0 [WEB-658] fix: multiple toast alerts on adding existing issues (#3889)
* fix: enums export in the types package

* chore: remove NestedKeyOf type

* fix: multiple toast alerts on adding existing issues

* chore: added success toast alerts
2024-03-06 19:17:00 +05:30
Aaryan Khandelwal
e4f48d6878 [WEB-393] feat: new emoji picker using emoji-picker-react (#3868)
* chore: emoji-picker-react package added

* chore: emoji and emoji picker component added

* chore: emoji picker custom style added

* chore: migration of the emoji's

* chore: migration changes

* chore: project logo prop

* chore: added logo props in the serializer

* chore: removed unused keys

* chore: implement emoji picker throughout the web app

* style: emoji icon picker

* chore: update project logo renderer in the space app

* chore: migrations fixes

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-06 19:15:48 +05:30
Aaryan Khandelwal
b3d3c0fb06 [WEB-654] fix: enums export in the types package (#3887)
* fix: enums export in the types package

* chore: remove NestedKeyOf type
2024-03-06 18:46:36 +05:30
M. Palanikannan
cace132a2a [WEB-372] fix: horizontal rule extension now always divider adds below nodes (#3890)
* fix: horizontal rule extension now always divider adds below nodes

* chore: removing duplicate horizontal rule extension
2024-03-06 18:43:19 +05:30
sriram veeraghanta
3d09a69d58 fix: eslint issues and reconfiguring (#3891)
* fix: eslint fixes

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-06 18:39:14 +05:30
sriram veeraghanta
921b9078f1 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-06 14:33:15 +05:30
M. Palanikannan
e1db39ffc8 [WEB-566] feat: Added text to empty color picker boxes and other fixes (#3867)
* fix: z index issues with modals and on hover color in table item picker menu

* feat: added text indicators inside the table colors to give a gist of how text would look
2024-03-06 14:29:52 +05:30
Anmol Singh Bhatia
2b05d23470 fix: kanban card state overflow fix (#3866) 2024-03-06 14:28:24 +05:30
Anmol Singh Bhatia
69fa1708cc fix: module quick action dropdown overflow fix (#3865) 2024-03-06 14:27:59 +05:30
Aaryan Khandelwal
666b7ea577 fix: remove member button alignment (#3862) 2024-03-06 14:27:41 +05:30
Anmol Singh Bhatia
7b76df6868 [WEB-581] chore: project states setting page improvement (#3864)
* chore: project states setting page improvement

* chore: code refactor

* chore: observer added in project state setting page
2024-03-06 14:26:34 +05:30
Lakhan Baheti
dbdd14493b [WEB-662] chore: posthog debugging based on env variable (#3860)
* chore: posthog debugging based on env variable

* updated turbo.json

* chore: true to 1

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-03-06 14:25:15 +05:30
Bavisetti Narayan
4572b7378d [WEB-621] chore: 404 page not found (#3859)
* chore: custom 404 error

* chore: moved from middleware to view
2024-03-06 14:24:58 +05:30
Aaryan Khandelwal
5a32d10f96 [WEB-373] chore: new dashboard updates (#3849)
* chore: replaced marimekko graph with a bar graph

* chore: add bar onClick handler

* chore: custom date filter for widgets

* style: priority graph

* chore: workspace profile activity pagination

* chore: profile activity pagination

* chore: user profile activity pagination

* chore: workspace user activity csv download

* chore: download activity button added

* chore: workspace user pagination

* chore: collabrator pagination

* chore: field change

* chore: recent collaborators pagination

* chore: changed the collabrators

* chore: collabrators list changed

* fix: distinct users

* chore: search filter in collaborators

* fix: import error

* chore: update priority graph x-axis values

* chore: admin and member request validation

* chore: update csv download request method

* chore: search implementation for the collaborators widget

* refactor: priority distribution card

* chore: add enum for duration filters

* chore: update inbox types

* chore: add todos for refactoring

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-03-06 14:24:36 +05:30
M. Palanikannan
126d01bdc5 [WEB-617] fix: link behaviour fixed on formatting (#3855)
* fix: link behaviour fixed on formatting

* chore: added harmful script checks for links
2024-03-06 14:22:02 +05:30
Bavisetti Narayan
87eadc3c5d chore: issue link model field change (#3852) 2024-03-06 14:21:07 +05:30
Prateek Shourya
53367a6bc4 [WEB-570] chore: toast refactor (#3836)
* new toast setup

* chore: new toast implementation.

* chore: move toast component to ui package.

* chore: replace `setToast` with `setPromiseToast` in required places for better UX.
* chore: code cleanup.

* chore: update theme.

* fix: theme switching issue.

* chore: remove toast from issue update operations.

* chore: add promise toast for add/ remove issue to cycle/ module and remove local spinners.

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-03-06 14:18:41 +05:30
Anmol Singh Bhatia
c06ef4d1d7 [WEB-579] style: scrollbar implementation (#3835)
* style: scrollbar added in profile summary and sidebar

* style: scrollbar added in modals

* style: scrollbar added in project setting screens

* style: scrollbar added in workspace and profile settings

* style: scrollbar added in dropdowns and filters
2024-03-06 14:18:19 +05:30
sriram veeraghanta
73334130be Merge branch 'chore/auto-merge' of github.com:makeplane/plane into preview 2024-03-06 14:07:34 +05:30
Prateek Shourya
4d0f641ee0 [WEB-588] chore: remove the word title from the issue title tooltip. (#3874)
* [WEB-588] chore: remove the word `title` from the issue title tooltip.

* fix: github url fixes in feature deploy action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-06 14:02:14 +05:30
sriram veeraghanta
50318190f5 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-03-06 12:59:24 +05:30
Manish Gupta
d07dd65022 feat: feature preview deploys for web and space nextjs applications (#3881)
* feature preview deploy

* chore: variable name changes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-06 12:57:14 +05:30
Henit Chobisa
f8f9dd3331 [CHANG-8] chore: Upgraded Build Pull Request CI for Faster Parallel Build with Linting Capabilities (#3838)
* chore: upgraded build pull request ci for multi stage parallel builds

* Update build-test-pull-request.yml
2024-03-05 13:14:00 +05:30
Henit Chobisa
af70722e34 chore: added workflow for checking version before merge to master (#3847) 2024-03-05 13:02:13 +05:30
M. Palanikannan
d99529b109 fix: crash while updating link text on the last node (#3871) 2024-03-04 20:33:16 +05:30
Vihar Kurama
6eb7014ea4 chore: update github readme - content edits to feature section, new screenshots and added repo activity, contributors. (#3870)
* update github readme, add new features and deployment options

* add new product screenshots

* add contributors section to readme

* chore: update formatting
2024-03-04 20:06:53 +05:30
NarayanBavisetti
6719738a9f chore: issue attachment and description endpoint for space 2024-02-13 15:23:24 +05:30
pablohashescobar
79685b33d6 dev: fix migrations and update nginx migration 2024-02-12 13:19:26 +05:30
pablohashescobar
0f0e86fdb4 dev: update migrations 2024-02-12 12:35:11 +05:30
pablohashescobar
9e310994cd dev: add cycle snapshot migration file 2024-02-12 12:33:08 +05:30
pablohashescobar
c25ac3b3a6 Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads 2024-02-12 11:37:47 +05:30
Palanikannan1437
ab5e698065 feat: init working version of image blobs rendering 2024-02-09 21:23:53 +05:30
Nikhil
e09d7f9533 Merge branch 'develop' into chore/file-uploads 2024-02-08 16:23:34 +05:30
pablohashescobar
16c78100c0 dev: docker variables upgrade and nginx template update 2024-02-08 13:22:20 +05:30
pablohashescobar
2688e41cef dev: update the default create bucket script to create private bucket 2024-02-07 20:33:37 +05:30
pablohashescobar
6776c2d2d1 dev: add nginx headers for request host 2024-02-07 20:20:54 +05:30
pablohashescobar
e19eb3f074 dev: update the issue attachment default type 2024-02-05 16:00:13 +05:30
pablohashescobar
f95f24231f dev: comment assets 2024-02-05 15:12:56 +05:30
pablohashescobar
02e5e0da4b dev: update attachments for issues 2024-02-05 14:30:49 +05:30
pablohashescobar
cedc08bc08 dev: sync data for issue attachments 2024-02-05 13:10:24 +05:30
pablohashescobar
31cca8d07e dev: back migration for assets in issue, comments and page 2024-02-05 12:04:20 +05:30
pablohashescobar
6c97bcefbf dev: back migration for urls 2024-02-04 11:27:50 +05:30
pablohashescobar
e1f0da5e6c dev: update file image urls to backend apis 2024-02-02 14:48:50 +05:30
pablohashescobar
94f445cc08 dev: update user assets with backend streaming 2024-02-01 15:55:43 +05:30
pablohashescobar
42f307421a dev: update the response for assets 2024-01-31 18:43:02 +05:30
1469 changed files with 39007 additions and 30752 deletions

View File

@@ -1,23 +0,0 @@
version = 1
exclude_patterns = [
"bin/**",
"**/node_modules/",
"**/*.min.js"
]
[[analyzers]]
name = "shell"
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
environment = ["nodejs"]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"

View File

@@ -8,13 +8,13 @@ on:
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ secrets.TARGET_BRANCH }} # The target branch that you would like to merge changes like develop
SOURCE_BRANCH: ${{ secrets.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ secrets.REVIEWER }}
REVIEWER: ${{ secrets.SYNC_PR_REVIEWER }}
jobs:
Check_Branch:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
@@ -27,7 +27,7 @@ jobs:
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Auto_Merge:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
@@ -41,6 +41,11 @@ jobs:
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
- name: Setup GH CLI and Git Config
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
@@ -50,20 +55,6 @@ jobs:
sudo apt update
sudo apt install gh -y
- id: git-author
name: Setup Git CLI from Github Token
run: |
VIEWER_JSON=$(gh api graphql -f query='query { viewer { name login databaseId }}' --jq '.data.viewer')
VIEWER_NAME=$(jq --raw-output '.name | values' <<< "${VIEWER_JSON}")
VIEWER_LOGIN=$(jq --raw-output '.login' <<< "${VIEWER_JSON}")
VIEWER_DATABASE_ID=$(jq --raw-output '.databaseId' <<< "${VIEWER_JSON}")
USER_NAME="${VIEWER_NAME:-${VIEWER_LOGIN}}"
USER_EMAIL="${VIEWER_DATABASE_ID}+${VIEWER_LOGIN}@users.noreply.github.com"
git config --global user.name ${USER_NAME}
git config --global user.email ${USER_EMAIL}
- name: Check for merge conflicts
id: conflicts
run: |
@@ -88,10 +79,6 @@ jobs:
- name: Create PR to Target Branch
if: env.HAS_CONFLICTS == 'true'
run: |
# Use GitHub CLI to create PR and specify author and committer
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH \
--title "sync: merge conflicts need to be resolved" \
--body "" \
--reviewer $REVIEWER )
# Replace 'username' with the actual GitHub username of the reviewer.
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "" --reviewer $REVIEWER)
echo "Pull Request created: $PR_URL"

View File

@@ -6,7 +6,6 @@ on:
branches:
- master
- preview
- develop
release:
types: [released, prereleased]
@@ -18,7 +17,7 @@ jobs:
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
@@ -74,7 +73,7 @@ jobs:
- nginx/**
branch_build_push_frontend:
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -126,7 +125,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -178,7 +177,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -230,7 +229,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -280,4 +279,3 @@ jobs:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@@ -1,27 +1,19 @@
name: Build Pull Request Contents
name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize"]
jobs:
build-pull-request-contents:
name: Build Pull Request Contents
runs-on: ubuntu-20.04
permissions:
pull-requests: read
get-changed-files:
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }}
steps:
- name: Checkout Repository to Actions
uses: actions/checkout@v3.3.0
with:
token: ${{ secrets.ACCESS_TOKEN }}
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- uses: actions/checkout@v3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v41
@@ -31,17 +23,82 @@ jobs:
- apiserver/**
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
deploy:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
yarn
yarn build --filter=web
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x' # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Apiserver Dependencies
run: cd apiserver && pip install -r requirements.txt
- name: Lint apiserver
run: ruff check --fix apiserver
- name: Build Plane's Deploy App
if: steps.changed-files.outputs.deploy_any_changed == 'true'
run: |
yarn
yarn build --filter=space
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=web
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=space
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=web
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=space

45
.github/workflows/check-version.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Version Change Before Release
on:
pull_request:
branches:
- master
jobs:
check-version:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: Fetch base branch
run: git fetch origin master:master
- name: Get Master Branch version
run: |
git checkout master
echo "MASTER_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: Get master branch version and compare
run: |
echo "Comparing versions: PR version is $PR_VERSION, Master version is $MASTER_VERSION"
if [ "$PR_VERSION" == "$MASTER_VERSION" ]; then
echo "Version in PR branch is the same as in master. Failing the CI."
exit 1
else
echo "Version check passed. Versions are different."
fi
env:
PR_VERSION: ${{ env.PR_VERSION }}
MASTER_VERSION: ${{ env.MASTER_VERSION }}

View File

@@ -1,13 +1,13 @@
name: "CodeQL"
on:
workflow_dispatch:
push:
branches: [ 'develop', 'preview', 'master' ]
branches: ["develop", "preview", "master"]
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'develop', 'preview', 'master' ]
branches: ["develop", "preview", "master"]
schedule:
- cron: '53 19 * * 5'
- cron: "53 19 * * 5"
jobs:
analyze:
@@ -21,45 +21,44 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'python', 'javascript' ]
language: ["python", "javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

199
.github/workflows/feature-deployment.yml vendored Normal file
View File

@@ -0,0 +1,199 @@
name: Feature Preview
on:
workflow_dispatch:
inputs:
web-build:
required: false
description: 'Build Web'
type: boolean
default: true
space-build:
required: false
description: 'Build Space'
type: boolean
default: false
env:
BUILD_WEB: ${{ github.event.inputs.web-build }}
BUILD_SPACE: ${{ github.event.inputs.space-build }}
jobs:
setup-feature-build:
name: Feature Build Setup
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
echo "BUILD_WEB=$BUILD_WEB"
echo "BUILD_SPACE=$BUILD_SPACE"
outputs:
web-build: ${{ env.BUILD_WEB}}
space-build: ${{env.BUILD_SPACE}}
feature-build-web:
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
needs: setup-feature-build
name: Feature Build Web
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Checkout
uses: actions/checkout@v4
with:
path: plane
- name: Install Dependencies
run: |
cd $GITHUB_WORKSPACE/plane
yarn install
- name: Build Web
id: build-web
run: |
cd $GITHUB_WORKSPACE/plane
yarn build --filter=web
cd $GITHUB_WORKSPACE
TAR_NAME="web.tar.gz"
tar -czf $TAR_NAME ./plane
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-build-space:
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
needs: setup-feature-build
name: Feature Build Space
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
outputs:
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }}
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Checkout
uses: actions/checkout@v4
with:
path: plane
- name: Install Dependencies
run: |
cd $GITHUB_WORKSPACE/plane
yarn install
- name: Build Space
id: build-space
run: |
cd $GITHUB_WORKSPACE/plane
yarn build --filter=space
cd $GITHUB_WORKSPACE
TAR_NAME="space.tar.gz"
tar -czf $TAR_NAME ./plane
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-deploy:
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
needs: [feature-build-web, feature-build-space]
name: Feature Deploy
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }}
steps:
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Tailscale
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
tags: tag:ci
- name: Kubectl Setup
run: |
curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl"
chmod +x kubectl
mkdir -p ~/.kube
echo "$KUBE_CONFIG_FILE" > ~/.kube/config
chmod 600 ~/.kube/config
- name: HELM Setup
run: |
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh
- name: App Deploy
run: |
WEB_S3_URL=""
if [ ${{ env.BUILD_WEB }} == true ]; then
WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600)
fi
SPACE_S3_URL=""
if [ ${{ env.BUILD_SPACE }} == true ]; then
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
fi
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}"
METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \
--generate-name \
--namespace $APP_NAMESPACE \
--set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
--set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set web.enabled=${{ env.BUILD_WEB || false }} \
--set web.artifact_url=$WEB_S3_URL \
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set space.enabled=${{ env.BUILD_SPACE || false }} \
--set space.artifact_url=$SPACE_S3_URL \
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
--output json \
--timeout 1000s)
APP_NAME=$(echo $METADATA | jq -r '.name')
INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
-o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
jq -r '.spec.rules[0].host')
echo "****************************************"
echo "APP NAME ::: $APP_NAME"
echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
echo "****************************************"
fi

1
.gitignore vendored
View File

@@ -51,6 +51,7 @@ staticfiles
mediafiles
.env
.DS_Store
logs/
node_modules/
assets/dist/

View File

@@ -50,7 +50,6 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.

View File

@@ -53,7 +53,6 @@ NGINX_PORT=80
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/apiserver/.env

155
README.md
View File

@@ -7,7 +7,7 @@
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Flexible, extensible open-source project management</b></p>
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -16,6 +16,13 @@
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<p align="center">
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a>
<a href="https://git.new/releases"><b>Releases</b></a>
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a>
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
</p>
<p>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img
@@ -33,56 +40,90 @@
</a>
</p>
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve in our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
## ⚡ Installation
## ⚡️ Contributors Quick Start
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
### Prerequisite
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
Development system must have docker engine installed and running.
| Installation Methods | Documentation Link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) |
### Steps
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
1. Clone the code locally using `git clone https://github.com/makeplane/plane.git`
1. Switch to the code folder `cd plane`
1. Create your feature or fix branch you plan to work on using `git checkout -b <feature-branch-name>`
1. Open terminal and run `./setup.sh`
1. Open the code on VSCode or similar equivalent IDE
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
Thats it!
## 🍙 Self Hosting
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
## 🚀 Features
- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking.
- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents.
- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you.
- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features.
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress.
- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking.
- **Cycles**:
Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features.
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.
- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues.
- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location.
- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration.
- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue.
- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work.
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
## 🛠️ Quick start for contributors
> Development system must have docker engine installed and running.
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute -
1. Clone the code locally using:
```
git clone https://github.com/makeplane/plane.git
```
2. Switch to the code folder:
```
cd plane
```
3. Create your feature or fix branch you plan to work on using:
```
git checkout -b <feature-branch-name>
```
4. Open terminal and run:
```
./setup.sh
```
5. Open the code on VSCode or similar equivalent IDE.
6. Review the `.env` files available in various folders.
Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system.
7. Run the docker command to initiate services:
```
docker compose -f docker-compose-local.yml up -d
```
You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload).
Thats it!
## ❤️ Community
The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels.
Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects.
### Repo Activity
![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image")
## 📸 Screenshots
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_views_dark_mode.webp"
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880"
alt="Plane Views"
width="100%"
/>
@@ -91,8 +132,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_issue_detail_dark_mode.webp"
alt="Plane Issue Details"
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
width="100%"
/>
</a>
@@ -100,7 +140,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_cycles_modules_dark_mode.webp"
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783"
alt="Plane Cycles and Modules"
width="100%"
/>
@@ -109,7 +149,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_analytics_dark_mode.webp"
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522"
alt="Plane Analytics"
width="100%"
/>
@@ -118,7 +158,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_pages_dark_mode.webp"
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389"
alt="Plane Pages"
width="100%"
/>
@@ -128,7 +168,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_commad_k_dark_mode.webp"
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
alt="Plane Command Menu"
width="100%"
/>
@@ -136,20 +176,23 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.
</p>
</p>
## 📚Documentation
For full documentation, visit [docs.plane.so](https://docs.plane.so/)
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
## ❤️ Community
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
## ⛓️ Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
Email squawk@plane.so to disclose any security vulnerabilities.
## ❤️ Contribute
There are many ways to contribute to Plane, including:
- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components.
- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features.
- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)!
- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support.
### We couldn't have done this without you.
<a href="https://github.com/makeplane/plane/graphs/contributors">
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
</a>

View File

@@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings
REDIS_HOST="plane-redis"
@@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
@@ -48,19 +39,8 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2

View File

@@ -48,8 +48,10 @@ USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
RUN chown -R captain:plane /code
USER captain

View File

@@ -35,6 +35,7 @@ RUN addgroup -S plane && \
COPY . .
RUN mkdir -p /code/plane/logs
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code

View File

@@ -182,7 +182,7 @@ def update_label_color():
labels = Label.objects.filter(color="")
updated_labels = []
for label in labels:
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}"
updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)

View File

@@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
# Clear Cache before starting to remove stale values
python manage.py clear_cache
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local

View File

@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.16.0"
"version": "0.17.0"
}

View File

@@ -5,9 +5,7 @@ from .issue import (
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueAttachmentSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
)

View File

@@ -1,31 +1,32 @@
from lxml import html
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
# Django imports
from django.utils import timezone
from lxml import html
# Third party imports
from rest_framework import serializers
# Module imports
from plane.db.models import (
User,
Issue,
State,
IssueActivity,
IssueAssignee,
Label,
IssueComment,
IssueLabel,
IssueLink,
IssueComment,
IssueAttachment,
IssueActivity,
Label,
ProjectMember,
State,
User,
)
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
from .module import ModuleLiteSerializer, ModuleSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
class IssueSerializer(BaseSerializer):
@@ -77,8 +78,8 @@ class IssueSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["description_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
if data.get("assignees", []):
@@ -284,6 +285,20 @@ class IssueLinkSerializer(BaseSerializer):
"updated_at",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
try:
validate_url(value)
except ValidationError:
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
@@ -295,21 +310,16 @@ class IssueLinkSerializer(BaseSerializer):
)
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
return super().update(instance, validated_data)
class IssueCommentSerializer(BaseSerializer):
@@ -339,8 +349,8 @@ class IssueCommentSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["comment_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
return data

View File

@@ -6,8 +6,6 @@ from plane.db.models import (
Project,
ProjectIdentifier,
WorkspaceMember,
State,
Estimate,
)
from .base import BaseSerializer

View File

@@ -4,6 +4,7 @@ from plane.api.views.cycle import (
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [
@@ -32,4 +33,14 @@ urlpatterns = [
TransferCycleIssueAPIEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/archive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
CycleArchiveUnarchiveAPIEndpoint.as_view(),
name="cycle-archive-unarchive",
),
]

View File

@@ -1,6 +1,10 @@
from django.urls import path
from plane.api.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
from plane.api.views import (
ModuleAPIEndpoint,
ModuleIssueAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [
path(
@@ -23,4 +27,14 @@ urlpatterns = [
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
name="module-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
name="module-archive-unarchive",
),
]

View File

@@ -1,6 +1,9 @@
from django.urls import path
from plane.api.views import ProjectAPIEndpoint
from plane.api.views import (
ProjectAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [
path(
@@ -13,4 +16,9 @@ urlpatterns = [
ProjectAPIEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
ProjectArchiveUnarchiveAPIEndpoint.as_view(),
name="project-archive-unarchive",
),
]

View File

@@ -1,4 +1,4 @@
from .project import ProjectAPIEndpoint
from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
from .state import StateAPIEndpoint
@@ -14,8 +14,13 @@ from .cycle import (
CycleAPIEndpoint,
CycleIssueAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint
from .module import (
ModuleAPIEndpoint,
ModuleIssueAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
from .inbox import InboxIssueAPIEndpoint

View File

@@ -1,27 +1,26 @@
# Python imports
import zoneinfo
import json
from urllib.parse import urlparse
import zoneinfo
# Django imports
from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
# Third party imports
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
@@ -107,27 +106,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, ValidationError):
return Response(
{
"error": "The provided payload is not valid please try with a valid payload"
},
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": f"The required object does not exist."},
{"error": "The requested resource does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": f" The required key does not exist."},
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -2,7 +2,7 @@
import json
# Django imports
from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func
from django.db.models import Q, Count, Sum, F, OuterRef, Func
from django.utils import timezone
from django.core import serializers
@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
CycleIssue,
IssueLink,
IssueAttachment,
FileAsset,
)
from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
@@ -140,7 +140,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
def get(self, request, slug, project_id, pk=None):
if pk:
queryset = self.get_queryset().get(pk=pk)
queryset = (
self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
)
data = CycleSerializer(
queryset,
fields=self.fields,
@@ -150,7 +152,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
data,
status=status.HTTP_200_OK,
)
queryset = self.get_queryset()
queryset = (
self.get_queryset().filter(archived_at__isnull=True)
)
cycle_view = request.GET.get("cycle_view", "all")
# Current Cycle
@@ -291,6 +295,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if cycle.archived_at:
return Response(
{"error": "Archived cycle cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
request_data = request.data
@@ -321,7 +330,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", cycle.external_source),
external_source=request.data.get(
"external_source", cycle.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
@@ -366,6 +377,139 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Cycle.objects.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,
)
.filter(archived_at__isnull=False)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
def get(self, request, slug, project_id):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
def post(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
@@ -436,8 +580,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))

View File

@@ -119,7 +119,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [
if request.data.get("issue", {}).get("priority", "none") not in [
"low",
"medium",
"high",

View File

@@ -1,53 +1,53 @@
# Python imports
import json
from itertools import chain
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
from django.db.models import (
OuterRef,
Func,
Q,
F,
Case,
When,
Value,
CharField,
Max,
Exists,
F,
Func,
Max,
OuterRef,
Q,
Value,
When,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView, WebhookMixin
from plane.app.permissions import (
ProjectEntityPermission,
ProjectMemberPermission,
ProjectLitePermission,
)
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
Project,
Label,
ProjectMember,
IssueComment,
IssueActivity,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
IssueActivitySerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
ProjectMemberPermission,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueActivity,
IssueComment,
IssueLink,
Label,
Project,
ProjectMember,
)
# Module imports
from .base import BaseAPIView, WebhookMixin
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
@@ -127,8 +127,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -356,6 +357,7 @@ class LabelAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.select_related("parent")
@@ -488,6 +490,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@@ -617,6 +620,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("workspace", "project", "issue", "actor")
.annotate(
is_member=Exists(
@@ -653,7 +657,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
def post(self, request, slug, project_id, issue_id):
# Validation check if the issue already exists
if (
request.data.get("external_id")
@@ -679,7 +682,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -717,7 +719,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
# Validation check if the issue already exists
if (
request.data.get("external_id")
and (issue_comment.external_id != str(request.data.get("external_id")))
and (
issue_comment.external_id
!= str(request.data.get("external_id"))
)
and IssueComment.objects.filter(
project_id=project_id,
workspace__slug=slug,
@@ -735,7 +740,6 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentSerializer(
issue_comment, data=request.data, partial=True
)
@@ -792,6 +796,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))

View File

@@ -19,8 +19,8 @@ from plane.db.models import (
ModuleLink,
Issue,
ModuleIssue,
IssueAttachment,
IssueLink,
FileAsset,
)
from plane.api.serializers import (
ModuleSerializer,
@@ -67,6 +67,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@@ -77,6 +78,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@@ -87,6 +89,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@@ -97,6 +100,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@@ -107,6 +111,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@@ -117,6 +122,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
@@ -165,6 +171,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
if module.archived_at:
return Response(
{"error": "Archived module cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ModuleSerializer(
module,
data=request.data,
@@ -178,7 +189,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
and Module.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", module.external_source),
external_source=request.data.get(
"external_source", module.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
@@ -195,7 +208,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
def get(self, request, slug, project_id, pk=None):
if pk:
queryset = self.get_queryset().get(pk=pk)
queryset = (
self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
)
data = ModuleSerializer(
queryset,
fields=self.fields,
@@ -207,7 +222,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
on_results=lambda modules: ModuleSerializer(
modules,
many=True,
@@ -277,6 +292,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.select_related("module")
@@ -316,8 +332,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -444,3 +461,123 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Module.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda modules: ModuleSerializer(
modules,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
def post(self, request, slug, project_id, pk):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
module.archived_at = timezone.now()
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,4 +1,5 @@
# Django imports
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch
@@ -11,7 +12,6 @@ from rest_framework.serializers import ValidationError
from plane.db.models import (
Workspace,
Project,
ProjectFavorite,
ProjectMember,
ProjectDeployBoard,
State,
@@ -40,7 +40,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
Q(project_projectmember__member=self.request.user)
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
.select_related(
@@ -150,7 +153,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
serializer.save()
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
_ = ProjectMember.objects.create(
project_id=serializer.data["id"],
member=request.user,
role=20,
@@ -245,12 +248,12 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist as e:
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except ValidationError as e:
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -261,6 +264,12 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
if project.archived_at:
return Response(
{"error": "Archived project cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectSerializer(
project,
data={**request.data},
@@ -307,7 +316,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except ValidationError as e:
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
@@ -317,3 +326,22 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None
project.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -28,6 +28,7 @@ class StateAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.filter(~Q(name="Triage"))
.select_related("project")
.select_related("workspace")
@@ -66,8 +67,10 @@ class StateAPIEndpoint(BaseAPIView):
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
@@ -136,7 +139,9 @@ class StateAPIEndpoint(BaseAPIView):
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", state.external_source),
external_source=request.data.get(
"external_source", state.external_source
),
external_id=request.data.get("external_id"),
).exists()
):

View File

@@ -60,7 +60,6 @@ from .issue import (
IssueStateSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
@@ -70,7 +69,6 @@ from .issue import (
IssuePublicSerializer,
IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
)
@@ -86,16 +84,6 @@ from .module import (
from .api import APITokenSerializer, APITokenReadSerializer
from .integration import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
GithubIssueSyncSerializer,
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
SlackProjectSyncSerializer,
)
from .importer import ImporterSerializer
from .page import (
@@ -121,7 +109,10 @@ from .inbox import (
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
from .notification import (
NotificationSerializer,
UserNotificationPreferenceSerializer,
)
from .exporter import ExporterHistorySerializer

View File

@@ -1,8 +1,9 @@
from .base import BaseSerializer
from .base import BaseFileSerializer
from plane.db.models import FileAsset
class FileAssetSerializer(BaseSerializer):
class FileAssetSerializer(BaseFileSerializer):
class Meta:
model = FileAsset
fields = "__all__"

View File

@@ -51,19 +51,18 @@ class DynamicBaseSerializer(BaseSerializer):
for field in allowed:
if field not in self.fields:
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
CycleIssueSerializer,
InboxIssueLiteSerializer,
IssueLinkLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
)
# Expansion mapper
@@ -86,7 +85,6 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
@@ -122,19 +120,18 @@ class DynamicBaseSerializer(BaseSerializer):
if expand in self.fields:
# Import all the expandable serializers
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueLinkLiteSerializer,
IssueLiteSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
)
# Expansion mapper
@@ -157,7 +154,6 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
@@ -179,3 +175,20 @@ class DynamicBaseSerializer(BaseSerializer):
)
return response
class BaseFileSerializer(DynamicBaseSerializer):
class Meta:
abstract = True # Make this serializer abstract
def to_representation(self, instance):
"""
Object instance -> Dict of primitive datatypes.
"""
response = super().to_representation(instance)
response["asset"] = (
instance.asset.name
) # Ensure 'asset' field is consistently serialized
# Apply custom method to get download URL
return response

View File

@@ -11,6 +11,7 @@ from plane.db.models import (
CycleUserProperties,
)
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if (
@@ -30,6 +31,7 @@ class CycleWriteSerializer(BaseSerializer):
"workspace",
"project",
"owned_by",
"archived_at",
]
@@ -47,7 +49,6 @@ class CycleSerializer(BaseSerializer):
# active | draft | upcoming | completed
status = serializers.CharField(read_only=True)
class Meta:
model = Cycle
fields = [

View File

@@ -18,9 +18,4 @@ class WidgetSerializer(BaseSerializer):
class Meta:
model = Widget
fields = [
"id",
"key",
"is_visible",
"widget_filters"
]
fields = ["id", "key", "is_visible", "widget_filters"]

View File

@@ -74,5 +74,3 @@ class WorkspaceEstimateSerializer(BaseSerializer):
"name",
"description",
]

View File

@@ -1,8 +0,0 @@
from .base import IntegrationSerializer, WorkspaceIntegrationSerializer
from .github import (
GithubRepositorySerializer,
GithubRepositorySyncSerializer,
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer

View File

@@ -1,22 +0,0 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import Integration, WorkspaceIntegration
class IntegrationSerializer(BaseSerializer):
class Meta:
model = Integration
fields = "__all__"
read_only_fields = [
"verified",
]
class WorkspaceIntegrationSerializer(BaseSerializer):
integration_detail = IntegrationSerializer(
read_only=True, source="integration"
)
class Meta:
model = WorkspaceIntegration
fields = "__all__"

View File

@@ -1,45 +0,0 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import (
GithubIssueSync,
GithubRepository,
GithubRepositorySync,
GithubCommentSync,
)
class GithubRepositorySerializer(BaseSerializer):
class Meta:
model = GithubRepository
fields = "__all__"
class GithubRepositorySyncSerializer(BaseSerializer):
repo_detail = GithubRepositorySerializer(source="repository")
class Meta:
model = GithubRepositorySync
fields = "__all__"
class GithubIssueSyncSerializer(BaseSerializer):
class Meta:
model = GithubIssueSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
]
class GithubCommentSyncSerializer(BaseSerializer):
class Meta:
model = GithubCommentSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"repository_sync",
"issue_sync",
]

View File

@@ -1,14 +0,0 @@
# Module imports
from plane.app.serializers import BaseSerializer
from plane.db.models import SlackProjectSync
class SlackProjectSyncSerializer(BaseSerializer):
class Meta:
model = SlackProjectSync
fields = "__all__"
read_only_fields = [
"project",
"workspace",
"workspace_integration",
]

View File

@@ -1,39 +1,40 @@
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
CommentReaction,
Cycle,
CycleIssue,
Issue,
IssueActivity,
IssueComment,
IssueProperty,
IssueAssignee,
IssueSubscriber,
IssueComment,
IssueLabel,
IssueLink,
IssueProperty,
IssueReaction,
IssueRelation,
IssueSubscriber,
IssueVote,
Label,
CycleIssue,
Cycle,
Module,
ModuleIssue,
IssueLink,
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
IssueRelation,
State,
Project,
User,
)
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer
class IssueFlatSerializer(BaseSerializer):
## Contain only flat fields
@@ -432,6 +433,20 @@ class IssueLinkSerializer(BaseSerializer):
"issue",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
try:
validate_url(value)
except ValidationError:
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
@@ -443,9 +458,19 @@ class IssueLinkSerializer(BaseSerializer):
)
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return super().update(instance, validated_data)
class IssueLinkLiteSerializer(BaseSerializer):
class Meta:
model = IssueLink
fields = [
@@ -460,36 +485,6 @@ class IssueLinkLiteSerializer(BaseSerializer):
read_only_fields = fields
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueAttachment
fields = [
"id",
"asset",
"attributes",
"issue_id",
"updated_at",
"updated_by_id",
]
read_only_fields = fields
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
@@ -505,13 +500,12 @@ class IssueReactionSerializer(BaseSerializer):
class IssueReactionLiteSerializer(DynamicBaseSerializer):
class Meta:
model = IssueReaction
fields = [
"id",
"actor_id",
"issue_id",
"actor",
"issue",
"reaction",
]
@@ -601,15 +595,18 @@ class IssueSerializer(DynamicBaseSerializer):
# ids
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
child=serializers.UUIDField(),
required=False,
)
# Many to many
label_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
child=serializers.UUIDField(),
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.UUIDField(), required=False,
child=serializers.UUIDField(),
required=False,
)
# Count items
@@ -649,19 +646,7 @@ class IssueSerializer(DynamicBaseSerializer):
read_only_fields = fields
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
]
class IssueLiteSerializer(DynamicBaseSerializer):
class Meta:
model = Issue
fields = [

View File

@@ -3,7 +3,6 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
@@ -40,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"archived_at",
]
def to_representation(self, instance):
@@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
class Meta:
model = ModuleLink
fields = "__all__"
@@ -215,13 +214,12 @@ class ModuleSerializer(DynamicBaseSerializer):
read_only_fields = fields
class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True)
sub_issues = serializers.IntegerField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ['link_module']
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
class ModuleFavoriteSerializer(BaseSerializer):

View File

@@ -15,7 +15,6 @@ class NotificationSerializer(BaseSerializer):
class UserNotificationPreferenceSerializer(BaseSerializer):
class Meta:
model = UserNotificationPreference
fields = "__all__"

View File

@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .issue import LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
@@ -12,8 +12,6 @@ from plane.db.models import (
PageFavorite,
PageLabel,
Label,
Issue,
Module,
)

View File

@@ -95,14 +95,19 @@ class ProjectLiteSerializer(BaseSerializer):
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"logo_props",
"description",
]
read_only_fields = fields
class ProjectListSerializer(DynamicBaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
archived_issues = serializers.IntegerField(read_only=True)
archived_sub_issues = serializers.IntegerField(read_only=True)
draft_issues = serializers.IntegerField(read_only=True)
draft_sub_issues = serializers.IntegerField(read_only=True)
sub_issues = serializers.IntegerField(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)

View File

@@ -4,7 +4,6 @@ from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User, Workspace, WorkspaceMemberInvite
from plane.license.models import InstanceAdmin, Instance
class UserSerializer(BaseSerializer):
@@ -99,13 +98,13 @@ class UserMeSettingsSerializer(BaseSerializer):
).first()
return {
"last_workspace_id": obj.last_workspace_id,
"last_workspace_slug": workspace.slug
if workspace is not None
else "",
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"fallback_workspace_id": obj.last_workspace_id,
"fallback_workspace_slug": workspace.slug
if workspace is not None
else "",
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"invites": workspace_invites,
}
else:
@@ -120,12 +119,16 @@ class UserMeSettingsSerializer(BaseSerializer):
return {
"last_workspace_id": None,
"last_workspace_slug": None,
"fallback_workspace_id": fallback_workspace.id
if fallback_workspace is not None
else None,
"fallback_workspace_slug": fallback_workspace.slug
if fallback_workspace is not None
else None,
"fallback_workspace_id": (
fallback_workspace.id
if fallback_workspace is not None
else None
),
"fallback_workspace_slug": (
fallback_workspace.slug
if fallback_workspace is not None
else None
),
"invites": workspace_invites,
}

View File

@@ -1,5 +1,4 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse

View File

@@ -6,9 +6,7 @@ from .cycle import urlpatterns as cycle_urls
from .dashboard import urlpatterns as dashboard_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
from .importer import urlpatterns as importer_urls
from .inbox import urlpatterns as inbox_urls
from .integration import urlpatterns as integration_urls
from .issue import urlpatterns as issue_urls
from .module import urlpatterns as module_urls
from .notification import urlpatterns as notification_urls
@@ -32,9 +30,7 @@ urlpatterns = [
*dashboard_urls,
*estimate_urls,
*external_urls,
*importer_urls,
*inbox_urls,
*integration_urls,
*issue_urls,
*module_urls,
*notification_urls,

View File

@@ -3,7 +3,6 @@ from django.urls import path
from plane.app.views import (
FileAssetEndpoint,
UserAssetsEndpoint,
FileAssetViewSet,
)
@@ -19,16 +18,6 @@ urlpatterns = [
FileAssetEndpoint.as_view(),
name="file-assets",
),
path(
"users/file-assets/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"users/file-assets/<str:asset_key>/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view(

View File

@@ -8,6 +8,7 @@ from plane.app.views import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
)
@@ -90,4 +91,14 @@ urlpatterns = [
CycleUserPropertiesEndpoint.as_view(),
name="cycle-user-filters",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
]

View File

@@ -2,7 +2,6 @@ from django.urls import path
from plane.app.views import UnsplashEndpoint
from plane.app.views import ReleaseNotesEndpoint
from plane.app.views import GPTIntegrationEndpoint
@@ -12,11 +11,6 @@ urlpatterns = [
UnsplashEndpoint.as_view(),
name="unsplash",
),
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),

View File

@@ -1,37 +0,0 @@
from django.urls import path
from plane.app.views import (
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/importers/<str:service>/",
ServiceIssueImportSummaryEndpoint.as_view(),
name="importer-summary",
),
path(
"workspaces/<str:slug>/projects/importers/<str:service>/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/importers/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/importers/<str:service>/<uuid:pk>/",
ImportServiceEndpoint.as_view(),
name="importer",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/service/<str:service>/importers/<uuid:importer_id>/",
UpdateServiceImportStatusEndpoint.as_view(),
name="importer-status",
),
]

View File

@@ -1,150 +0,0 @@
from django.urls import path
from plane.app.views import (
IntegrationViewSet,
WorkspaceIntegrationViewSet,
GithubRepositoriesEndpoint,
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
GithubCommentSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
SlackProjectSyncViewSet,
)
urlpatterns = [
path(
"integrations/",
IntegrationViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="integrations",
),
path(
"integrations/<uuid:pk>/",
IntegrationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "list",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<str:provider>/",
WorkspaceIntegrationViewSet.as_view(
{
"post": "create",
}
),
name="workspace-integrations",
),
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:pk>/provider/",
WorkspaceIntegrationViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="workspace-integrations",
),
# Github Integrations
path(
"workspaces/<str:slug>/workspace-integrations/<uuid:workspace_integration_id>/github-repositories/",
GithubRepositoriesEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/",
GithubRepositorySyncViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/github-repository-sync/<uuid:pk>/",
GithubRepositorySyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/",
GithubIssueSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/bulk-create-github-issue-sync/",
BulkCreateGithubIssueSyncEndpoint.as_view(),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:pk>/",
GithubIssueSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/",
GithubCommentSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/github-repository-sync/<uuid:repo_sync_id>/github-issue-sync/<uuid:issue_sync_id>/github-comment-sync/<uuid:pk>/",
GithubCommentSyncViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
),
## End Github Integrations
# Slack Integration
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/",
SlackProjectSyncViewSet.as_view(
{
"post": "create",
"get": "list",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/workspace-integrations/<uuid:workspace_integration_id>/project-slack-sync/<uuid:pk>/",
SlackProjectSyncViewSet.as_view(
{
"delete": "destroy",
"get": "retrieve",
}
),
),
## End Slack Integration
]

View File

@@ -1,30 +1,27 @@
from django.urls import path
from plane.app.views import (
IssueListEndpoint,
IssueViewSet,
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
BulkDeleteIssuesEndpoint,
BulkImportIssuesEndpoint,
UserWorkSpaceIssues,
SubIssuesEndpoint,
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentAssetEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
IssueUserDisplayPropertyEndpoint,
IssueArchiveViewSet,
IssueRelationViewSet,
IssueAttachmentEndpoint,
IssueCommentViewSet,
IssueDraftViewSet,
IssueLinkViewSet,
IssueListEndpoint,
IssueReactionViewSet,
IssueRelationViewSet,
IssueSubscriberViewSet,
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
SubIssuesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/list/",
@@ -85,18 +82,7 @@ urlpatterns = [
BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-issues/<str:service>/",
BulkImportIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
# deprecated endpoint TODO: remove once confirmed
path(
"workspaces/<str:slug>/my-issues/",
UserWorkSpaceIssues.as_view(),
name="workspace-issues",
),
##
##
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
SubIssuesEndpoint.as_view(),
@@ -124,16 +110,6 @@ urlpatterns = [
),
name="project-issue-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
@@ -312,5 +288,26 @@ urlpatterns = [
}
),
name="project-issue-draft",
), # Comment Assets
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/",
CommentAssetEndpoint.as_view(),
name="project-comment-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
CommentAssetEndpoint.as_view(),
name="project-comment-attachments",
),
## End Comments
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:workspace_id>/<str:asset_key>/",
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
]

View File

@@ -6,8 +6,8 @@ from plane.app.views import (
ModuleIssueViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
BulkImportModulesEndpoint,
ModuleUserPropertiesEndpoint,
ModuleArchiveUnarchiveEndpoint,
)
@@ -106,14 +106,19 @@ urlpatterns = [
),
name="user-favorite-module",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-import-modules/<str:service>/",
BulkImportModulesEndpoint.as_view(),
name="bulk-modules-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/user-properties/",
ModuleUserPropertiesEndpoint.as_view(),
name="cycle-user-filters",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/archive/",
ModuleArchiveUnarchiveEndpoint.as_view(),
name="module-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
ModuleArchiveUnarchiveEndpoint.as_view(),
name="module-archive-unarchive",
),
]

View File

@@ -6,6 +6,7 @@ from plane.app.views import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PageAssetEndpoint,
)
@@ -130,4 +131,14 @@ urlpatterns = [
SubPagesEndpoint.as_view(),
name="sub-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/attachments/",
PageAssetEndpoint.as_view(),
name="page-assets",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/attachments/<str:workspace_id>/<str:asset_key>/",
PageAssetEndpoint.as_view(),
name="page-assets",
),
]

View File

@@ -1,22 +1,23 @@
from django.urls import path
from plane.app.views import (
ProjectViewSet,
ProjectInvitationsViewset,
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectCoverImageEndpoint,
ProjectDeployBoardViewSet,
ProjectFavoritesViewSet,
ProjectIdentifierEndpoint,
ProjectInvitationsViewset,
ProjectJoinEndpoint,
ProjectMemberUserEndpoint,
ProjectMemberViewSet,
ProjectPublicCoverImagesEndpoint,
ProjectUserViewsEndpoint,
ProjectViewSet,
UserProjectInvitationsViewset,
UserProjectRolesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/",
@@ -175,4 +176,19 @@ urlpatterns = [
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/<str:workspace_id>/<str:cover_image_key>/",
ProjectCoverImageEndpoint.as_view(),
name="project-cover-image",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cover-image/",
ProjectCoverImageEndpoint.as_view(),
name="project-cover-image",
),
]

View File

@@ -15,6 +15,10 @@ from plane.app.views import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
## End Workspaces
# Asset Endpoints ##
UserAvatarEndpoint,
UserCoverImageEndpoint,
## End Asset Endpoint ##
)
urlpatterns = [
@@ -95,5 +99,26 @@ urlpatterns = [
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
# User Assets
path(
"users/avatar/",
UserAvatarEndpoint.as_view(),
name="user-avatar",
),
path(
"users/avatar/<str:avatar_key>/",
UserAvatarEndpoint.as_view(),
name="user-avatar",
),
path(
"users/cover-image/",
UserCoverImageEndpoint.as_view(),
name="user-avatar",
),
path(
"users/cover-image/<str:cover_image_key>/",
UserCoverImageEndpoint.as_view(),
name="user-avatar",
),
## User Assets
]

View File

@@ -1,32 +1,32 @@
from django.urls import path
from plane.app.views import (
UserWorkspaceInvitationsViewSet,
WorkSpaceViewSet,
WorkspaceJoinEndpoint,
WorkSpaceMemberViewSet,
WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
ExportWorkspaceUserActivityEndpoint,
TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
UserWorkspaceInvitationsViewSet,
WorkSpaceAvailabilityCheckEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceInvitationsViewset,
WorkspaceJoinEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceLogoEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceMemberViewSet,
WorkspaceModulesEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceStatesEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkSpaceViewSet,
)
urlpatterns = [
path(
"workspace-slug-check/",
@@ -191,6 +191,11 @@ urlpatterns = [
WorkspaceUserActivityEndpoint.as_view(),
name="workspace-user-activity",
),
path(
"workspaces/<str:slug>/user-activity/<uuid:user_id>/export/",
ExportWorkspaceUserActivityEndpoint.as_view(),
name="export-workspace-user-activity",
),
path(
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
WorkspaceUserProfileEndpoint.as_view(),
@@ -231,4 +236,14 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
path(
"workspaces/<str:slug>/logo/",
WorkspaceLogoEndpoint.as_view(),
name="workspace-logo",
),
path(
"workspaces/<str:slug>/logo/<str:workspace_id>/<str:logo_key>/",
WorkspaceLogoEndpoint.as_view(),
name="workspace-logo",
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +1,156 @@
from .project import (
from .project.base import (
ProjectViewSet,
ProjectMemberViewSet,
UserProjectInvitationsViewset,
ProjectInvitationsViewset,
AddTeamToProjectEndpoint,
ProjectIdentifierEndpoint,
ProjectJoinEndpoint,
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
ProjectCoverImageEndpoint,
)
from .project.invite import (
UserProjectInvitationsViewset,
ProjectInvitationsViewset,
ProjectJoinEndpoint,
)
from .project.member import (
ProjectMemberViewSet,
AddTeamToProjectEndpoint,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
)
from .user import (
from .user.base import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
UserAvatarEndpoint,
UserCoverImageEndpoint,
)
from .oauth import OauthEndpoint
from .base import BaseAPIView, BaseViewSet, WebhookMixin
from .workspace import (
from .workspace.base import (
WorkSpaceViewSet,
UserWorkSpacesEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
WorkspaceJoinEndpoint,
WorkSpaceMemberViewSet,
TeamMemberViewSet,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsViewSet,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
ExportWorkspaceUserActivityEndpoint,
WorkspaceLogoEndpoint,
)
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceMemberUserViewsEndpoint,
)
from .workspace.invite import (
WorkspaceInvitationsViewset,
WorkspaceJoinEndpoint,
UserWorkspaceInvitationsViewSet,
)
from .workspace.label import (
WorkspaceLabelsEndpoint,
)
from .workspace.state import (
WorkspaceStatesEndpoint,
)
from .workspace.user import (
UserLastProjectWithWorkspaceEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileStatsEndpoint,
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
)
from .workspace.estimate import (
WorkspaceEstimatesEndpoint,
)
from .workspace.module import (
WorkspaceModulesEndpoint,
)
from .workspace.cycle import (
WorkspaceCyclesEndpoint,
)
from .state import StateViewSet
from .view import (
from .state.base import StateViewSet
from .view.base import (
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
IssueViewFavoriteViewSet,
)
from .cycle import (
from .cycle.base import (
CycleViewSet,
CycleIssueViewSet,
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleArchiveUnarchiveEndpoint,
CycleUserPropertiesEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
from .cycle.issue import (
CycleIssueViewSet,
)
from .asset.base import FileAssetEndpoint, FileAssetViewSet
from .issue.base import (
IssueListEndpoint,
IssueViewSet,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
IssueUserDisplayPropertyEndpoint,
LabelViewSet,
BulkDeleteIssuesEndpoint,
UserWorkSpaceIssues,
SubIssuesEndpoint,
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
IssueAttachmentEndpoint,
)
from .issue.activity import (
IssueActivityEndpoint,
)
from .issue.archive import (
IssueArchiveViewSet,
IssueSubscriberViewSet,
)
from .issue.attachment import IssueAttachmentEndpoint
from .issue.comment import (
IssueCommentViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
CommentAssetEndpoint,
)
from .issue.draft import IssueDraftViewSet
from .issue.label import (
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
)
from .issue.link import (
IssueLinkViewSet,
)
from .issue.relation import (
IssueRelationViewSet,
IssueDraftViewSet,
)
from .issue.reaction import (
IssueReactionViewSet,
)
from .issue.sub_issue import (
SubIssuesEndpoint,
)
from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .auth_extended import (
@@ -106,59 +169,45 @@ from .authentication import (
MagicSignInEndpoint,
)
from .module import (
from .module.base import (
ModuleViewSet,
ModuleIssueViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleArchiveUnarchiveEndpoint,
ModuleUserPropertiesEndpoint,
)
from .module.issue import (
ModuleIssueViewSet,
)
from .api import ApiTokenEndpoint
from .integration import (
WorkspaceIntegrationViewSet,
IntegrationViewSet,
GithubIssueSyncViewSet,
GithubRepositorySyncViewSet,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
BulkCreateGithubIssueSyncEndpoint,
SlackProjectSyncViewSet,
)
from .importer import (
ServiceIssueImportSummaryEndpoint,
ImportServiceEndpoint,
UpdateServiceImportStatusEndpoint,
BulkImportIssuesEndpoint,
BulkImportModulesEndpoint,
)
from .page import (
from .page.base import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PageAssetEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .external import (
from .external.base import (
GPTIntegrationEndpoint,
ReleaseNotesEndpoint,
UnsplashEndpoint,
)
from .estimate import (
from .estimate.base import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
)
from .inbox import InboxViewSet, InboxIssueViewSet
from .inbox.base import InboxViewSet, InboxIssueViewSet
from .analytic import (
from .analytic.base import (
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
@@ -166,24 +215,23 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import (
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter import ExportIssuesEndpoint
from .exporter.base import ExportIssuesEndpoint
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
from .webhook import (
from .webhook.base import (
WebhookEndpoint,
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view

View File

@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Count, Sum, F, Q
from django.db.models import Count, Sum, F
from django.db.models.functions import ExtractMonth
from django.utils import timezone
@@ -10,7 +10,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.views import BaseAPIView, BaseViewSet
from plane.app.permissions import WorkSpaceAdminPermission
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
from plane.db.models import Issue, AnalyticView, Workspace
from plane.app.serializers import AnalyticViewSerializer
from plane.utils.analytics_plot import build_graph_plot
from plane.bgtasks.analytic_plot_export import analytic_export_task
@@ -51,8 +51,8 @@ class AnalyticsEndpoint(BaseAPIView):
if (
not x_axis
or not y_axis
or not x_axis in valid_xaxis_segment
or not y_axis in valid_yaxis
or x_axis not in valid_xaxis_segment
or y_axis not in valid_yaxis
):
return Response(
{
@@ -266,8 +266,8 @@ class ExportAnalyticsEndpoint(BaseAPIView):
if (
not x_axis
or not y_axis
or not x_axis in valid_xaxis_segment
or not y_axis in valid_yaxis
or x_axis not in valid_xaxis_segment
or y_axis not in valid_yaxis
):
return Response(
{

View File

@@ -43,7 +43,7 @@ class ApiTokenEndpoint(BaseAPIView):
)
def get(self, request, slug, pk=None):
if pk == None:
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug
)

View File

@@ -4,7 +4,7 @@ from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseAPIView, BaseViewSet
from ..base import BaseAPIView, BaseViewSet
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
@@ -61,40 +61,3 @@ class FileAssetViewSet(BaseViewSet):
file_asset.is_deleted = False
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, asset_key):
files = FileAsset.objects.filter(
asset=asset_key, created_by=request.user
)
if files.exists():
serializer = FileAssetSerializer(
files, context={"request": request}
)
return Response(
{"data": serializer.data, "status": True},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(
asset=asset_key, created_by=request.user
)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -16,7 +16,6 @@ from django.contrib.auth.hashers import make_password
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
## Third Party Imports
from rest_framework import status
@@ -172,7 +171,7 @@ class ResetPasswordEndpoint(BaseAPIView):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except DjangoUnicodeDecodeError as indentifier:
except DjangoUnicodeDecodeError:
return Response(
{"error": "token is not valid, please check the new one"},
status=status.HTTP_401_UNAUTHORIZED,

View File

@@ -7,7 +7,6 @@ import json
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports
@@ -65,7 +64,7 @@ class SignUpEndpoint(BaseAPIView):
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
except ValidationError:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
@@ -151,7 +150,7 @@ class SignInEndpoint(BaseAPIView):
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
except ValidationError:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
@@ -238,9 +237,11 @@ class SignInEndpoint(BaseAPIView):
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -254,9 +255,11 @@ class SignInEndpoint(BaseAPIView):
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -392,9 +395,11 @@ class MagicSignInEndpoint(BaseAPIView):
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -408,9 +413,11 @@ class MagicSignInEndpoint(BaseAPIView):
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)

View File

@@ -1,30 +1,27 @@
# Python imports
import zoneinfo
import json
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Django imports
from django.urls import resolve
from django.conf import settings
from django.utils import timezone
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django_filters.rest_framework import DjangoFilterBackend
# Third part imports
from rest_framework import status
from rest_framework import status
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from rest_framework.views import APIView
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
# Module imports
from plane.utils.paginator import BasePaginator
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
class TimezoneMixin:
@@ -90,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
try:
return self.model.objects.all()
except Exception as e:
capture_exception(e)
log_exception(e)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
@@ -119,18 +116,18 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": f"The required object does not exist."},
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
capture_exception(e)
log_exception(e)
return Response(
{"error": f"The required key does not exist."},
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -226,19 +223,17 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": f"The required object does not exist."},
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": f"The required key does not exist."},
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -2,7 +2,6 @@
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.permissions import AllowAny
@@ -12,13 +11,14 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.cache import cache_response
class ConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
# Get all the configuration
(
@@ -136,6 +136,7 @@ class MobileConfigurationEndpoint(BaseAPIView):
AllowAny,
]
@cache_response(60 * 60 * 2, user=False)
def get(self, request):
(
GOOGLE_CLIENT_ID,

View File

@@ -1,63 +1,56 @@
# Python imports
import json
# Django imports
from django.db.models import (
Func,
F,
Q,
Exists,
OuterRef,
Count,
Prefetch,
Sum,
Case,
When,
Value,
CharField,
)
from django.core import serializers
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 import Value, UUIDField
# Django imports
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
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
# Module imports
from . import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.db.models import (
User,
Cycle,
CycleIssue,
Issue,
CycleFavorite,
IssueLink,
IssueAttachment,
Label,
CycleUserProperties,
from plane.app.serializers import (
CycleFavoriteSerializer,
CycleSerializer,
CycleUserPropertiesSerializer,
CycleWriteSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.db.models import (
Cycle,
CycleFavorite,
CycleIssue,
CycleUserProperties,
Issue,
Label,
User,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class CycleViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleSerializer
@@ -89,6 +82,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
@@ -109,7 +103,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
@@ -118,7 +113,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
@@ -128,7 +124,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
@@ -138,7 +135,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
@@ -148,7 +146,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
@@ -158,7 +157,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
@@ -202,7 +202,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all")
# Update the order by
@@ -403,8 +403,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"total_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
@@ -427,11 +427,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def partial_update(self, request, slug, project_id, pk):
queryset = (
self.get_queryset()
.filter(workspace__slug=slug, project_id=project_id, pk=pk)
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
cycle = queryset.first()
if cycle.archived_at:
return Response(
{"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
request_data = request.data
if (
@@ -489,10 +493,22 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
)
data = (
self.get_queryset()
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
# necessary fields
"id",
@@ -509,6 +525,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
"progress_snapshot",
"sub_issues",
# meta fields
"is_favorite",
"total_issues",
@@ -662,273 +679,194 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue_id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project")
.select_related("workspace")
.select_related("cycle")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.filter(project__archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.annotate(
assignee_ids=Coalesce(
ArrayAgg(
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
)
& Q(
issue_cycle__issue__assignees__member_project__is_active=True
),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
@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")
filters = issue_filters(request.query_params, "GET")
def get(self, request, slug, project_id):
queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees",
"labels",
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
self.get_queryset()
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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),
),
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(
.values(
# necessary fields
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"workspace_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"status",
"archived_at",
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle.archived_at = timezone.now()
cycle.save()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
cycle_id=cycle_id,
issue_id=issue,
)
for issue in new_issues
],
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Update the cycle issues
CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("cycle_id")),
"issues": [str(issue_id)],
}
),
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
cycle_issue.delete()
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,311 @@
# Python imports
import json
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core import serializers
# Django imports
from django.db.models import (
F,
Func,
OuterRef,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
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.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
CycleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import Cycle, CycleIssue, FileAsset, Issue, IssueLink
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet, WebhookMixin
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue_id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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,
)
.filter(project__archived_at__isnull=True)
.filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project")
.select_related("workspace")
.select_related("cycle")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.distinct()
)
@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")
filters = issue_filters(request.query_params, "GET")
queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees",
"labels",
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.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",
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
cycle_id=cycle_id,
issue_id=issue,
)
for issue in new_issues
],
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Update the cycle issues
CycleIssue.objects.bulk_update(
updated_records, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("cycle_id")),
"issues": [str(issue_id)],
}
),
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -9,15 +9,15 @@ from django.db.models import (
F,
Exists,
OuterRef,
Max,
Subquery,
JSONField,
Func,
Prefetch,
IntegerField,
)
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 import UUIDField
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -26,7 +26,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseAPIView
from .. import BaseAPIView
from plane.db.models import (
Issue,
IssueActivity,
@@ -36,8 +36,9 @@ from plane.db.models import (
Dashboard,
Project,
IssueLink,
IssueAttachment,
FileAsset,
IssueRelation,
User,
)
from plane.app.serializers import (
IssueActivitySerializer,
@@ -122,8 +123,9 @@ def dashboard_assigned_issues(self, request, slug):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -148,7 +150,8 @@ def dashboard_assigned_issues(self, request, slug):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -212,11 +215,11 @@ def dashboard_assigned_issues(self, request, slug):
if issue_type == "overdue":
overdue_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
target_date__lt=timezone.now(),
).count()
overdue_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
target_date__lt=timezone.now(),
)[:5]
return Response(
{
@@ -231,11 +234,11 @@ def dashboard_assigned_issues(self, request, slug):
if issue_type == "upcoming":
upcoming_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
target_date__gte=timezone.now(),
).count()
upcoming_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
target_date__gte=timezone.now(),
)[:5]
return Response(
{
@@ -276,8 +279,9 @@ def dashboard_created_issues(self, request, slug):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -302,7 +306,8 @@ def dashboard_created_issues(self, request, slug):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -365,11 +370,11 @@ def dashboard_created_issues(self, request, slug):
if issue_type == "overdue":
overdue_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
target_date__lt=timezone.now(),
).count()
overdue_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
target_date__lt=timezone.now(),
)[:5]
return Response(
{
@@ -382,11 +387,11 @@ def dashboard_created_issues(self, request, slug):
if issue_type == "upcoming":
upcoming_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
target_date__gte=timezone.now(),
).count()
upcoming_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
target_date__gte=timezone.now(),
)[:5]
return Response(
{
@@ -470,6 +475,7 @@ def dashboard_recent_activity(self, request, slug):
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
actor=request.user,
).select_related("actor", "workspace", "issue", "project")[:8]
@@ -485,6 +491,7 @@ def dashboard_recent_projects(self, request, slug):
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
actor=request.user,
)
.values_list("project_id", flat=True)
@@ -499,11 +506,14 @@ def dashboard_recent_projects(self, request, slug):
additional_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
archived_at__isnull=True,
workspace__slug=slug,
).exclude(id__in=unique_project_ids)
# Append additional project IDs to the existing list
unique_project_ids.update(additional_projects.values_list("id", flat=True))
unique_project_ids.update(
additional_projects.values_list("id", flat=True)
)
return Response(
list(unique_project_ids)[:4],
@@ -512,90 +522,99 @@ def dashboard_recent_projects(self, request, slug):
def dashboard_recent_collaborators(self, request, slug):
# Fetch all project IDs where the user belongs to
user_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
workspace__slug=slug,
).values_list("id", flat=True)
# Fetch all users who have performed an activity in the projects where the user exists
users_with_activities = (
# Subquery to count activities for each project member
activity_count_subquery = (
IssueActivity.objects.filter(
workspace__slug=slug,
project_id__in=user_projects,
actor=OuterRef("member"),
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.values("actor")
.exclude(actor=request.user)
.annotate(num_activities=Count("actor"))
.order_by("-num_activities")
)[:7]
# Get the count of active issues for each user in users_with_activities
users_with_active_issues = []
for user_activity in users_with_activities:
user_id = user_activity["actor"]
active_issue_count = Issue.objects.filter(
assignees__in=[user_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{"user_id": user_id, "active_issue_count": active_issue_count}
)
# Insert the logged-in user's ID and their active issue count at the beginning
active_issue_count = Issue.objects.filter(
assignees__in=[request.user],
state__group__in=["unstarted", "started"],
).count()
if users_with_activities.count() < 7:
# Calculate the additional collaborators needed
additional_collaborators_needed = 7 - users_with_activities.count()
# Fetch additional collaborators from the project_member table
additional_collaborators = list(
set(
ProjectMember.objects.filter(
~Q(member=request.user),
project_id__in=user_projects,
workspace__slug=slug,
)
.exclude(
member__in=[
user["actor"] for user in users_with_activities
]
)
.values_list("member", flat=True)
)
)
additional_collaborators = additional_collaborators[
:additional_collaborators_needed
]
# Append additional collaborators to the list
for collaborator_id in additional_collaborators:
active_issue_count = Issue.objects.filter(
assignees__in=[collaborator_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{
"user_id": str(collaborator_id),
"active_issue_count": active_issue_count,
}
)
users_with_active_issues.insert(
0,
{"user_id": request.user.id, "active_issue_count": active_issue_count},
.annotate(num_activities=Count("pk"))
.values("num_activities")
)
return Response(users_with_active_issues, status=status.HTTP_200_OK)
# Get all project members and annotate them with activity counts
project_members_with_activities = (
ProjectMember.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.annotate(
num_activities=Coalesce(
Subquery(activity_count_subquery),
Value(0),
output_field=IntegerField(),
),
is_current_user=Case(
When(member=request.user, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
),
)
.values_list("member", flat=True)
.order_by("is_current_user", "-num_activities")
.distinct()
)
search = request.query_params.get("search", None)
if search:
project_members_with_activities = (
project_members_with_activities.filter(
Q(member__display_name__icontains=search)
| Q(member__first_name__icontains=search)
| Q(member__last_name__icontains=search)
)
)
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=self.get_results_controller,
)
class DashboardEndpoint(BaseAPIView):
def get_results_controller(self, project_members_with_activities):
user_active_issue_counts = (
User.objects.filter(id__in=project_members_with_activities)
.annotate(
active_issue_count=Count(
Case(
When(
issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
then=1,
),
output_field=IntegerField(),
)
)
)
.values("active_issue_count", user_id=F("id"))
)
# Create a dictionary to store the active issue counts by user ID
active_issue_counts_dict = {
user["user_id"]: user["active_issue_count"]
for user in user_active_issue_counts
}
# Preserve the sequence of project members with activities
paginated_results = [
{
"user_id": member_id,
"active_issue_count": active_issue_counts_dict.get(
member_id, 0
),
}
for member_id in project_members_with_activities
]
return paginated_results
def create(self, request, slug):
serializer = DashboardSerializer(data=request.data)
if serializer.is_valid():
@@ -622,7 +641,9 @@ class DashboardEndpoint(BaseAPIView):
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = Dashboard.objects.get_or_create(
type_identifier=dashboard_type, owned_by=request.user, is_default=True
type_identifier=dashboard_type,
owned_by=request.user,
is_default=True,
)
if created:
@@ -639,7 +660,9 @@ class DashboardEndpoint(BaseAPIView):
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
widget = Widget.objects.filter(
key=widget_key
).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DashboardWidget(

View File

@@ -0,0 +1,5 @@
# views.py
from django.http import JsonResponse
def custom_404_view(request, exception=None):
return JsonResponse({"error": "Page not found."}, status=404)

View File

@@ -3,7 +3,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .base import BaseViewSet, BaseAPIView
from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint
from plane.app.serializers import (
@@ -11,7 +11,7 @@ from plane.app.serializers import (
EstimatePointSerializer,
EstimateReadSerializer,
)
from plane.utils.cache import invalidate_cache
class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [
@@ -49,6 +49,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
def create(self, request, slug, project_id):
if not request.data.get("estimate", False):
return Response(
@@ -114,6 +115,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_200_OK,
)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
def partial_update(self, request, slug, project_id, estimate_id):
if not request.data.get("estimate", False):
return Response(
@@ -182,6 +184,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_200_OK,
)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
def destroy(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id

View File

@@ -3,7 +3,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseAPIView
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
@@ -29,7 +29,10 @@ class ExportIssuesEndpoint(BaseAPIView):
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
workspace__slug=slug,
project_projectmember__member=request.user,
project_projectmember__is_active=True,
archived_at__isnull=True,
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
@@ -50,7 +53,7 @@ class ExportIssuesEndpoint(BaseAPIView):
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
"message": "Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)

View File

@@ -8,17 +8,15 @@ from rest_framework.response import Response
from rest_framework import status
# Django imports
from django.conf import settings
# Module imports
from .base import BaseAPIView
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import (
ProjectLiteSerializer,
WorkspaceLiteSerializer,
)
from plane.utils.integrations.github import get_release_notes
from plane.license.utils.instance_value import get_configuration_value
@@ -85,12 +83,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
)
class ReleaseNotesEndpoint(BaseAPIView):
def get(self, request):
release_notes = get_release_notes()
return Response(release_notes, status=status.HTTP_200_OK)
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(

View File

@@ -1,558 +0,0 @@
# Python imports
import uuid
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Django imports
from django.db.models import Max, Q
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import (
WorkspaceIntegration,
Importer,
APIToken,
Project,
State,
IssueSequence,
Issue,
IssueActivity,
IssueComment,
IssueLink,
IssueLabel,
Workspace,
IssueAssignee,
Module,
ModuleLink,
ModuleIssue,
Label,
)
from plane.app.serializers import (
ImporterSerializer,
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import (
jira_project_issue_summary,
is_allowed_hostname,
)
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
# Check for all the keys
params = {
"project_key": "Project key is required",
"api_token": "API token is required",
"email": "Email is required",
"cloud_hostname": "Cloud hostname is required",
}
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message},
status=status.HTTP_400_BAD_REQUEST,
)
project_key = request.GET.get("project_key", "")
api_token = request.GET.get("api_token", "")
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
response,
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
if not project_id:
return Response(
{"error": "Project ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
cloud_hostname = metadata.get("cloud_hostname", False)
if not cloud_hostname:
return Response(
{"error": "Cloud hostname is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not is_allowed_hostname(cloud_hostname):
return Response(
{"error": "Hostname is not a valid hostname."},
status=status.HTTP_400_BAD_REQUEST,
)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
imports = (
Importer.objects.filter(workspace__slug=slug)
.order_by("-created_at")
.select_related("initiated_by", "project", "workspace")
)
serializer = ImporterSerializer(imports, many=True)
return Response(serializer.data)
def delete(self, request, slug, service, pk):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
if importer.imported_data is not None:
# Delete all imported Issues
imported_issues = importer.imported_data.get("issues", [])
Issue.issue_objects.filter(id__in=imported_issues).delete()
# Delete all imported Labels
imported_labels = importer.imported_data.get("labels", [])
Label.objects.filter(id__in=imported_labels).delete()
if importer.service == "jira":
imported_modules = importer.imported_data.get("modules", [])
Module.objects.filter(id__in=imported_modules).delete()
importer.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, service, pk):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(
importer, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
importer = Importer.objects.get(
pk=importer_id,
workspace__slug=slug,
project_id=project_id,
service=service,
)
importer.status = request.data.get("status", "processing")
importer.save()
return Response(status.HTTP_200_OK)
class BulkImportIssuesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
# Get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id, default=True
).first()
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project_id=project_id
).aggregate(largest=Max("sequence"))["largest"]
last_id = 1 if last_id is None else last_id + 1
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=project_id, state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
# Get the issues_data
issues_data = request.data.get("issues_data", [])
if not len(issues_data):
return Response(
{"error": "Issue data is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Issues
bulk_issues = []
for issue_data in issues_data:
bulk_issues.append(
Issue(
project_id=project_id,
workspace_id=project.workspace_id,
state_id=issue_data.get("state")
if issue_data.get("state", False)
else default_state.id,
name=issue_data.get("name", "Issue Created through Bulk"),
description_html=issue_data.get(
"description_html", "<p></p>"
),
description_stripped=(
None
if (
issue_data.get("description_html") == ""
or issue_data.get("description_html") is None
)
else strip_tags(issue_data.get("description_html"))
),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", "none"),
created_by=request.user,
)
)
largest_sort_order = largest_sort_order + 10000
last_id = last_id + 1
issues = Issue.objects.bulk_create(
bulk_issues,
batch_size=100,
ignore_conflicts=True,
)
# Sequences
_ = IssueSequence.objects.bulk_create(
[
IssueSequence(
issue=issue,
sequence=issue.sequence_id,
project_id=project_id,
workspace_id=project.workspace_id,
)
for issue in issues
],
batch_size=100,
)
# Attach Labels
bulk_issue_labels = []
for issue, issue_data in zip(issues, issues_data):
labels_list = issue_data.get("labels_list", [])
bulk_issue_labels = bulk_issue_labels + [
IssueLabel(
issue=issue,
label_id=label_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for label_id in labels_list
]
_ = IssueLabel.objects.bulk_create(
bulk_issue_labels, batch_size=100, ignore_conflicts=True
)
# Attach Assignees
bulk_issue_assignees = []
for issue, issue_data in zip(issues, issues_data):
assignees_list = issue_data.get("assignees_list", [])
bulk_issue_assignees = bulk_issue_assignees + [
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for assignee_id in assignees_list
]
_ = IssueAssignee.objects.bulk_create(
bulk_issue_assignees, batch_size=100, ignore_conflicts=True
)
# Track the issue activities
IssueActivity.objects.bulk_create(
[
IssueActivity(
issue=issue,
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
for issue in issues
],
batch_size=100,
)
# Create Comments
bulk_issue_comments = []
for issue, issue_data in zip(issues, issues_data):
comments_list = issue_data.get("comments_list", [])
bulk_issue_comments = bulk_issue_comments + [
IssueComment(
issue=issue,
comment_html=comment.get("comment_html", "<p></p>"),
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for comment in comments_list
]
_ = IssueComment.objects.bulk_create(
bulk_issue_comments, batch_size=100
)
# Attach Links
_ = IssueLink.objects.bulk_create(
[
IssueLink(
issue=issue,
url=issue_data.get("link", {}).get(
"url", "https://github.com"
),
title=issue_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
)
return Response(
{"issues": IssueFlatSerializer(issues, many=True).data},
status=status.HTTP_201_CREATED,
)
class BulkImportModulesEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service):
modules_data = request.data.get("modules_data", [])
project = Project.objects.get(pk=project_id, workspace__slug=slug)
modules = Module.objects.bulk_create(
[
Module(
name=module.get("name", uuid.uuid4().hex),
description=module.get("description", ""),
start_date=module.get("start_date", None),
target_date=module.get("target_date", None),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module in modules_data
],
batch_size=100,
ignore_conflicts=True,
)
modules = Module.objects.filter(
id__in=[module.id for module in modules]
)
if len(modules) == len(modules_data):
_ = ModuleLink.objects.bulk_create(
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get(
"url", "https://plane.so"
),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
batch_size=100,
ignore_conflicts=True,
)
bulk_module_issues = []
for module, module_data in zip(modules, modules_data):
module_issues_list = module_data.get("module_issues_list", [])
bulk_module_issues = bulk_module_issues + [
ModuleIssue(
issue_id=issue,
module=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
)
for issue in module_issues_list
]
_ = ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=100, ignore_conflicts=True
)
serializer = ModuleSerializer(modules, many=True)
return Response(
{"modules": serializer.data}, status=status.HTTP_201_CREATED
)
else:
return Response(
{
"message": "Modules created but issues could not be imported"
},
status=status.HTTP_200_OK,
)

View File

@@ -1,42 +1,53 @@
# Python imports
import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
# Django import
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
from plane.app.serializers import (
InboxIssueSerializer,
InboxSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Inbox,
InboxIssue,
Issue,
State,
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueDetailSerializer,
ProjectMember,
State,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
# Module imports
from ..base import BaseViewSet
class InboxViewSet(BaseViewSet):
@@ -118,8 +129,9 @@ class InboxIssueViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
@@ -133,6 +145,15 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
@@ -146,7 +167,8 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -213,7 +235,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [
if request.data.get("issue", {}).get("priority", "none") not in [
"low",
"medium",
"high",
@@ -404,12 +426,6 @@ class InboxIssueViewSet(BaseViewSet):
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
@@ -428,8 +444,11 @@ class InboxIssueViewSet(BaseViewSet):
)
).first()
if issue is None:
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Requested object was not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -1,9 +0,0 @@
from .base import IntegrationViewSet, WorkspaceIntegrationViewSet
from .github import (
GithubRepositorySyncViewSet,
GithubIssueSyncViewSet,
BulkCreateGithubIssueSyncEndpoint,
GithubCommentSyncViewSet,
GithubRepositoriesEndpoint,
)
from .slack import SlackProjectSyncViewSet

View File

@@ -1,181 +0,0 @@
# Python improts
import uuid
import requests
# Django imports
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from plane.app.views import BaseViewSet
from plane.db.models import (
Integration,
WorkspaceIntegration,
Workspace,
User,
WorkspaceMember,
APIToken,
)
from plane.app.serializers import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
)
from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
)
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
def create(self, request):
serializer = IntegrationSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(
integration, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, pk):
integration = Integration.objects.get(pk=pk)
if integration.verified:
return Response(
{"error": "Verified integrations cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceIntegrationViewSet(BaseViewSet):
serializer_class = WorkspaceIntegrationSerializer
model = WorkspaceIntegration
permission_classes = [
WorkSpaceAdminPermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("integration")
)
def create(self, request, slug, provider):
workspace = Workspace.objects.get(slug=slug)
integration = Integration.objects.get(provider=provider)
config = {}
if provider == "github":
installation_id = request.data.get("installation_id", None)
if not installation_id:
return Response(
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
metadata = slack_response
access_token = metadata.get("access_token", False)
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
return Response(
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}
# Create a bot user
bot_user = User.objects.create(
email=f"{uuid.uuid4().hex}@plane.so",
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=integration.avatar_url
if integration.avatar_url is not None
else "",
)
# Create an API Token for the bot user
api_token = APIToken.objects.create(
user=bot_user,
user_type=1, # bot user
workspace=workspace,
)
workspace_integration = WorkspaceIntegration.objects.create(
workspace=workspace,
integration=integration,
actor=bot_user,
api_token=api_token,
metadata=metadata,
config=config,
)
# Add bot user as a member of workspace
_ = WorkspaceMember.objects.create(
workspace=workspace_integration.workspace,
member=bot_user,
role=20,
)
return Response(
WorkspaceIntegrationSerializer(workspace_integration).data,
status=status.HTTP_201_CREATED,
)
def destroy(self, request, slug, pk):
workspace_integration = WorkspaceIntegration.objects.get(
pk=pk, workspace__slug=slug
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get(
"installation_id", False
)
if installation_id:
delete_github_installation(installation_id=installation_id)
workspace_integration.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,202 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
GithubIssueSync,
GithubRepositorySync,
GithubRepository,
WorkspaceIntegration,
ProjectMember,
Label,
GithubCommentSync,
Project,
)
from plane.app.serializers import (
GithubIssueSyncSerializer,
GithubRepositorySyncSerializer,
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
from plane.app.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
)
class GithubRepositoriesEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def get(self, request, slug, workspace_integration_id):
page = request.GET.get("page", 1)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
class GithubRepositorySyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = GithubRepositorySyncSerializer
model = GithubRepositorySync
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, workspace_integration_id):
name = request.data.get("name", False)
url = request.data.get("url", False)
config = request.data.get("config", {})
repository_id = request.data.get("repository_id", False)
owner = request.data.get("owner", False)
if not name or not url or not repository_id or not owner:
return Response(
{"error": "Name, url, repository_id and owner are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace integration
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id
)
# Delete the old repository object
GithubRepositorySync.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
GithubRepository.objects.filter(
project_id=project_id, workspace__slug=slug
).delete()
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=project_id,
)
# Create a Label for github
label = Label.objects.filter(
name="GitHub",
project_id=project_id,
).first()
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=request.data.get("credentials", {}),
project_id=project_id,
label=label,
)
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
# Return Response
return Response(
GithubRepositorySyncSerializer(repo_sync).data,
status=status.HTTP_201_CREATED,
)
class GithubIssueSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubIssueSyncSerializer
model = GithubIssueSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
repository_sync_id=self.kwargs.get("repo_sync_id"),
)
class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
def post(self, request, slug, project_id, repo_sync_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
github_issue_syncs = request.data.get("github_issue_syncs", [])
github_issue_syncs = GithubIssueSync.objects.bulk_create(
[
GithubIssueSync(
issue_id=github_issue_sync.get("issue"),
repo_issue_id=github_issue_sync.get("repo_issue_id"),
issue_url=github_issue_sync.get("issue_url"),
github_issue_id=github_issue_sync.get("github_issue_id"),
repository_sync_id=repo_sync_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for github_issue_sync in github_issue_syncs
],
batch_size=100,
ignore_conflicts=True,
)
serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class GithubCommentSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = GithubCommentSyncSerializer
model = GithubCommentSync
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_sync_id=self.kwargs.get("issue_sync_id"),
)

View File

@@ -1,96 +0,0 @@
# Django import
from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
SlackProjectSync,
WorkspaceIntegration,
ProjectMember,
)
from plane.app.serializers import SlackProjectSyncSerializer
from plane.app.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
)
from plane.utils.integrations.slack import slack_oauth
class SlackProjectSyncViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id, workspace__slug=slug
)
slack_project_sync = SlackProjectSync.objects.create(
access_token=slack_response.get("access_token"),
scopes=slack_response.get("scope"),
bot_user_id=slack_response.get("bot_user_id"),
webhook_url=slack_response.get("incoming_webhook", {}).get(
"url"
),
data=slack_response,
team_id=slack_response.get("team", {}).get("id"),
team_name=slack_response.get("team", {}).get("name"),
workspace_integration=workspace_integration,
project_id=project_id,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=project_id,
)
serializer = SlackProjectSyncSerializer(slack_project_sync)
return Response(serializer.data, status=status.HTTP_200_OK)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "Slack is already installed for the project"},
status=status.HTTP_410_GONE,
)
capture_exception(e)
return Response(
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
# Python imports
from itertools import chain
# Django imports
from django.db.models import (
Prefetch,
Q,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
IssueActivity,
IssueComment,
CommentReaction,
)
class IssueActivityEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, issue_id):
filters = {}
if request.GET.get("created_at__gt", None) is not None:
filters = {"created_at__gt": request.GET.get("created_at__gt")}
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
)
.filter(**filters)
.select_related("actor", "workspace", "issue", "project")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
)
.filter(**filters)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
.prefetch_related(
Prefetch(
"comment_reactions",
queryset=CommentReaction.objects.select_related("actor"),
)
)
)
issue_activities = IssueActivitySerializer(
issue_activities, many=True
).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
if request.GET.get("activity_type", None) == "issue-property":
return Response(issue_activities, status=status.HTTP_200_OK)
if request.GET.get("activity_type", None) == "issue-comment":
return Response(issue_comments, status=status.HTTP_200_OK)
result_list = sorted(
chain(issue_activities, issue_comments),
key=lambda instance: instance["created_at"],
)
return Response(result_list, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,345 @@
# Python imports
import json
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.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
IssueDetailSerializer,
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
IssueReaction,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def get_queryset(self):
return (
Issue.objects.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.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)
def list(self, request, slug, project_id):
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",
)
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.filter(pk=pk)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
project_id=project_id,
pk=pk,
)
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error": "Can only archive completed or cancelled state group issue"
},
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()
issue.save()
return Response(
{"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK
)
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug,
project_id=project_id,
archived_at__isnull=False,
pk=pk,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": None}),
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 = None
issue.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,111 @@
# Python imports
import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import FileAssetSerializer
from plane.app.views.base import BaseAPIView
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import FileAsset, Workspace
from plane.utils.presigned_url_generator import generate_download_presigned_url
class IssueAttachmentEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, issue_id):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(
workspace=workspace,
project_id=project_id,
entity_identifier=issue_id,
)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(
self, request, slug, project_id, issue_id, workspace_id, asset_key
):
key = f"{workspace_id}/{asset_key}"
asset = FileAsset.objects.get(
asset=key,
entity_identifier=issue_id,
entity_type="issue_attachment",
workspace__slug=slug,
project_id=project_id,
)
asset.is_deleted = True
asset.save()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(
self,
request,
slug,
project_id,
issue_id,
workspace_id=None,
asset_key=None,
):
if workspace_id and asset_key:
key = f"{workspace_id}/{asset_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
# For listing
issue_attachments = FileAsset.objects.filter(
entity_type="issue_attachment",
entity_identifier=issue_id,
workspace__slug=slug,
project_id=project_id,
)
serializer = FileAssetSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,640 @@
# Python imports
import json
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.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
if not issue_ids:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_ids = [
issue_id for issue_id in issue_ids.split(",") if issue_id != ""
]
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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)
# 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)
if self.fields or self.expand:
issues = IssueSerializer(
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 Response(issues, status=status.HTTP_200_OK)
class IssueViewSet(WebhookMixin, BaseViewSet):
def get_serializer_class(self):
return (
IssueCreateSerializer
if self.action in ["create", "update", "partial_update"]
else IssueSerializer
)
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
filterset_fields = [
"state__name",
"assignees__id",
"workspace__id",
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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")
order_by_param = request.GET.get("order_by", "-created_at")
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")
# 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)
# 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 Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save()
# Track the issue
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.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 retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.filter(pk=pk)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None):
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
{"error": "Issue not found"},
status=status.HTTP_404_NOT_FOUND,
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
def patch(self, request, slug, project_id):
issue_property = IssueProperty.objects.get(
user=request.user,
project_id=project_id,
)
issue_property.filters = request.data.get(
"filters", issue_property.filters
)
issue_property.display_filters = request.data.get(
"display_filters", issue_property.display_filters
)
issue_property.display_properties = request.data.get(
"display_properties", issue_property.display_properties
)
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def delete(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.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
total_issues = len(issues)
issues.delete()
return Response(
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
)

View File

@@ -0,0 +1,294 @@
# Python imports
import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists
from django.http import HttpResponseRedirect
from django.utils import timezone
# Third Party imports
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectLitePermission
from plane.app.serializers import (
CommentReactionSerializer,
FileAssetSerializer,
IssueCommentSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
CommentReaction,
FileAsset,
IssueComment,
ProjectMember,
Workspace,
)
from plane.utils.presigned_url_generator import generate_download_presigned_url
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
is_active=True,
)
)
)
.distinct()
)
def create(self, request, slug, project_id, issue_id):
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
IssueCommentSerializer(issue_comment).data,
cls=DjangoJSONEncoder,
)
serializer = IssueCommentSerializer(
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
current_instance = json.dumps(
IssueCommentSerializer(issue_comment).data,
cls=DjangoJSONEncoder,
)
issue_comment.delete()
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
class CommentReactionViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.order_by("-created_at")
.distinct()
)
def create(self, request, slug, project_id, comment_id):
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
actor_id=request.user.id,
comment_id=comment_id,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CommentAssetEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
def post(self, request, slug, project_id, comment_id):
serializer = FileAssetSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
if serializer.is_valid():
serializer.save(
workspace=workspace,
project_id=project_id,
entity_type="comment",
entity_identifier=comment_id,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(
self, request, slug, project_id, comment_id, workspace_id, asset_key
):
key = f"{workspace_id}/{asset_key}"
asset = FileAsset.objects.get(
asset=key,
entity_identifier=comment_id,
entity_type="comment",
workspace__slug=slug,
project_id=project_id,
)
asset.is_deleted = True
asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(
self,
request,
slug,
project_id,
comment_id,
workspace_id=None,
asset_key=None,
):
if workspace_id and asset_key:
key = f"{workspace_id}/{asset_key}"
url = generate_download_presigned_url(
key=key,
host=request.get_host(),
scheme=request.scheme,
)
return HttpResponseRedirect(url)
# For listing
comment_assets = FileAsset.objects.filter(
entity_type="comment",
entity_identifier=comment_id,
workspace__slug=slug,
project_id=project_id,
)
serializer = FileAssetSerializer(comment_assets, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,360 @@
# Python imports
import json
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.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
IssueReaction,
IssueSubscriber,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet
class IssueDraftViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def get_queryset(self):
return (
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.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)
# 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)
# 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",
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save(is_draft=True)
# Track the issue
issue_activity.delay(
type="issue_draft.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
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
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
{"error": "Issue does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.filter(pk=pk)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
issue.delete()
issue_activity.delay(
type="issue_draft.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,105 @@
# Python imports
import random
# Django imports
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import LabelSerializer
from plane.app.permissions import (
ProjectMemberPermission,
)
from plane.db.models import (
Project,
Label,
)
from plane.utils.cache import invalidate_cache
class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
permission_classes = [
ProjectMemberPermission,
]
def get_queryset(self):
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)
.select_related("project")
.select_related("workspace")
.select_related("parent")
.distinct()
.order_by("sort_order")
)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
return Response(
{
"error": "Label with the same name already exists in the project"
},
status=status.HTTP_400_BAD_REQUEST,
)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
def post(self, request, slug, project_id):
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
labels = Label.objects.bulk_create(
[
Label(
name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"),
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for label in label_data
],
batch_size=50,
ignore_conflicts=True,
)
return Response(
{"labels": LabelSerializer(labels, many=True).data},
status=status.HTTP_201_CREATED,
)

View File

@@ -0,0 +1,121 @@
# Python imports
import json
# Django imports
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
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activites_task import issue_activity
class IssueLinkViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = IssueLink
serializer_class = IssueLinkSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.order_by("-created_at")
.distinct()
)
def create(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, issue_id, pk):
issue_link = IssueLink.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
requested_data = json.dumps(request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data,
cls=DjangoJSONEncoder,
)
serializer = IssueLinkSerializer(
issue_link, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, pk):
issue_link = IssueLink.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data,
cls=DjangoJSONEncoder,
)
issue_activity.delay(
type="link.activity.deleted",
requested_data=json.dumps({"link_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,90 @@
# Python imports
import json
# Django imports
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
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activites_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.order_by("-created_at")
.distinct()
)
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
issue_id=issue_id,
project_id=project_id,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,205 @@
# Python imports
import json
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueRelationSerializer,
RelatedIssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
)
from plane.bgtasks.issue_activites_task import issue_activity
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue=issue_id)
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.order_by("-created_at")
.distinct()
)
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
)
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
)
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
)
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
)
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
)
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
)
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
response_data = {
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
}
return Response(response_data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, issue_id):
relation_type = request.data.get("relation_type", None)
issues = request.data.get("issues", [])
project = Project.objects.get(pk=project_id)
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(
issue if relation_type == "blocking" else issue_id
),
related_issue_id=(
issue_id if relation_type == "blocking" else issue
),
relation_type=(
"blocked_by"
if relation_type == "blocking"
else relation_type
),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if relation_type == "blocking":
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
def remove_relation(self, request, slug, project_id, issue_id):
relation_type = request.data.get("relation_type", None)
related_issue = request.data.get("related_issue", None)
if relation_type == "blocking":
issue_relation = IssueRelation.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=related_issue,
related_issue_id=issue_id,
)
else:
issue_relation = IssueRelation.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
related_issue_id=related_issue,
)
current_instance = json.dumps(
IssueRelationSerializer(issue_relation).data,
cls=DjangoJSONEncoder,
)
issue_relation.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,189 @@
# Python imports
import json
from collections import defaultdict
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
F,
Func,
OuterRef,
Q,
UUIDField,
Value,
)
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.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import IssueSerializer
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
)
# Module imports
from .. import BaseAPIView
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, issue_id):
sub_issues = (
Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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())),
),
)
.annotate(state_group=F("state__group"))
)
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id))
sub_issues = sub_issues.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 Response(
{
"sub_issues": sub_issues,
"state_distribution": result,
},
status=status.HTTP_200_OK,
)
# Assign multiple sub issues
def post(self, request, slug, project_id, issue_id):
parent_issue = Issue.issue_objects.get(pk=issue_id)
sub_issue_ids = request.data.get("sub_issue_ids", [])
if not len(sub_issue_ids):
return Response(
{"error": "Sub Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
for sub_issue in sub_issues:
sub_issue.parent = parent_issue
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
updated_sub_issues = Issue.issue_objects.filter(
id__in=sub_issue_ids
).annotate(state_group=F("state__group"))
# Track the issue
_ = [
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"parent": str(issue_id)}),
actor_id=str(request.user.id),
issue_id=str(sub_issue_id),
project_id=str(project_id),
current_instance=json.dumps({"parent": str(sub_issue_id)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for sub_issue_id in sub_issue_ids
]
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in updated_sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id))
serializer = IssueSerializer(
updated_sub_issues,
many=True,
)
return Response(
{
"sub_issues": serializer.data,
"state_distribution": result,
},
status=status.HTTP_200_OK,
)

View File

@@ -0,0 +1,125 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.db.models import (
IssueSubscriber,
ProjectMember,
)
class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer
model = IssueSubscriber
permission_classes = [
ProjectEntityPermission,
]
def get_permissions(self):
if self.action in ["subscribe", "unsubscribe", "subscription_status"]:
self.permission_classes = [
ProjectLitePermission,
]
else:
self.permission_classes = [
ProjectEntityPermission,
]
return super(IssueSubscriberViewSet, self).get_permissions()
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_id=self.kwargs.get("issue_id"),
)
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.order_by("-created_at")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
members = ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
is_active=True,
).select_related("member")
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, issue_id, subscriber_id):
issue_subscriber = IssueSubscriber.objects.get(
project=project_id,
subscriber=subscriber_id,
workspace__slug=slug,
issue=issue_id,
)
issue_subscriber.delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
)
def subscribe(self, request, slug, project_id, issue_id):
if IssueSubscriber.objects.filter(
issue_id=issue_id,
subscriber=request.user,
workspace__slug=slug,
project=project_id,
).exists():
return Response(
{"message": "User already subscribed to the issue."},
status=status.HTTP_400_BAD_REQUEST,
)
subscriber = IssueSubscriber.objects.create(
issue_id=issue_id,
subscriber_id=request.user.id,
project_id=project_id,
)
serializer = IssueSubscriberSerializer(subscriber)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def unsubscribe(self, request, slug, project_id, issue_id):
issue_subscriber = IssueSubscriber.objects.get(
project=project_id,
subscriber=request.user,
workspace__slug=slug,
issue=issue_id,
)
issue_subscriber.delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
)
def subscription_status(self, request, slug, project_id, issue_id):
issue_subscriber = IssueSubscriber.objects.filter(
issue=issue_id,
subscriber=request.user,
workspace__slug=slug,
project=project_id,
).exists()
return Response(
{"subscribed": issue_subscriber}, status=status.HTTP_200_OK
)

View File

@@ -1,51 +1,57 @@
# Python imports
import json
# Django Imports
from django.utils import timezone
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, 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 import (
Count,
Exists,
F,
Func,
IntegerField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
# Django Imports
from django.utils import timezone
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
ModuleWriteSerializer,
ModuleSerializer,
ModuleIssueSerializer,
ModuleLinkSerializer,
ModuleFavoriteSerializer,
IssueSerializer,
ModuleUserPropertiesSerializer,
ModuleDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.db.models import (
Module,
ModuleIssue,
Project,
Issue,
ModuleLink,
ModuleFavorite,
IssueLink,
IssueAttachment,
ModuleUserProperties,
from plane.app.serializers import (
ModuleDetailSerializer,
ModuleFavoriteSerializer,
ModuleLinkSerializer,
ModuleSerializer,
ModuleUserPropertiesSerializer,
ModuleWriteSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.db.models import (
Issue,
Module,
ModuleFavorite,
ModuleIssue,
ModuleLink,
ModuleUserProperties,
Project,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class ModuleViewSet(WebhookMixin, BaseViewSet):
model = Module
@@ -68,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
cancelled_issues = (
Issue.issue_objects.filter(
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_issues = (
Issue.issue_objects.filter(
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
started_issues = (
Issue.issue_objects.filter(
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
unstarted_issues = (
Issue.issue_objects.filter(
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
backlog_issues = (
Issue.issue_objects.filter(
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
total_issues = (
Issue.issue_objects.filter(
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(cnt=Count("pk"))
.values("cnt")
)
return (
super()
.get_queryset()
@@ -87,62 +146,39 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
completed_issues=Coalesce(
Subquery(completed_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
cancelled_issues=Coalesce(
Subquery(cancelled_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
started_issues=Coalesce(
Subquery(started_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
unstarted_issues=Coalesce(
Subquery(unstarted_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
backlog_issues=Coalesce(
Subquery(backlog_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
total_issues=Coalesce(
Subquery(total_issues[:1]),
Value(0, output_field=IntegerField()),
)
)
.annotate(
@@ -190,9 +226,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"total_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
@@ -204,7 +240,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
queryset = self.get_queryset().filter(archived_at__isnull=True)
if self.fields:
modules = ModuleSerializer(
queryset,
@@ -231,8 +267,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
# computed fields
"is_favorite",
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
@@ -244,7 +280,21 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
queryset = (
self.get_queryset()
.filter(archived_at__isnull=True)
.filter(pk=pk)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = (
Issue.objects.filter(
@@ -345,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"completion_chart": {},
}
if queryset.first().start_date and queryset.first().target_date:
# Fetch the modules
modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset.first(),
queryset=modules,
slug=slug,
project_id=project_id,
module_id=pk,
@@ -359,14 +411,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk)
module = self.get_queryset().filter(pk=pk)
if module.first().archived_at:
return Response(
{"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ModuleWriteSerializer(
queryset.first(), data=request.data, partial=True
module.first(), data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
module = queryset.values(
module = module.values(
# Required fields
"id",
"workspace_id",
@@ -387,10 +445,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id",
# computed fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"total_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
@@ -426,232 +484,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
]
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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),
),
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",
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=str(issue),
module_id=module_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in issues
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not modules:
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=issue_id,
module_id=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": module}),
actor_id=str(request.user.id),
issue_id=issue_id,
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
issue_id=issue_id,
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleLinkViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
@@ -676,12 +508,174 @@ class ModuleLinkViewSet(BaseViewSet):
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.order_by("-created_at")
.distinct()
)
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
)
.order_by("-is_favorite", "-created_at")
)
def get(self, request, slug, project_id):
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
"workspace_id",
"project_id",
# Model fields
"name",
"description",
"description_text",
"description_html",
"start_date",
"target_date",
"status",
"lead_id",
"member_ids",
"view_props",
"sort_order",
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
)
return Response(modules, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = timezone.now()
module.save()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id):
module = Module.objects.get(
pk=module_id, project_id=project_id, workspace__slug=slug
)
module.archived_at = None
module.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite

View File

@@ -0,0 +1,262 @@
# Python imports
import json
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import F, Func, OuterRef, Q, UUIDField, Value
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.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueSerializer,
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
FileAsset,
Issue,
IssueLink,
ModuleIssue,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseViewSet, WebhookMixin
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
]
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
entity_identifier=OuterRef("id"),
entity_type="issue_attachment",
)
.order_by()
.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",
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=str(issue),
module_id=module_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in issues
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not modules:
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=issue_id,
module_id=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": module}),
actor_id=str(request.user.id),
issue_id=issue_id,
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
issue_id=issue_id,
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -8,7 +8,7 @@ from rest_framework.response import Response
from plane.utils.paginator import BasePaginator
# Module imports
from .base import BaseViewSet, BaseAPIView
from ..base import BaseViewSet, BaseAPIView
from plane.db.models import (
Notification,
IssueAssignee,
@@ -17,7 +17,10 @@ from plane.db.models import (
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
from plane.app.serializers import (
NotificationSerializer,
UserNotificationPreferenceSerializer,
)
class NotificationViewSet(BaseViewSet, BasePaginator):

View File

@@ -5,7 +5,6 @@ import os
# Django imports
from django.utils import timezone
from django.conf import settings
# Third Party modules
from rest_framework.response import Response
@@ -250,9 +249,11 @@ class OauthEndpoint(BaseAPIView):
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -266,9 +267,11 @@ class OauthEndpoint(BaseAPIView):
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -391,9 +394,11 @@ class OauthEndpoint(BaseAPIView):
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)
@@ -407,9 +412,11 @@ class OauthEndpoint(BaseAPIView):
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
role=(
project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15
),
member=user,
created_by_id=project_member_invite.created_by_id,
)

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