Compare commits

...

297 Commits

Author SHA1 Message Date
Vamsi Kurama
11faf3f810 Merge pull request #1600 from makeplane/stage-release
promote: stage-release to master
2023-07-20 19:36:43 +05:30
Aaryan Khandelwal
30ea1adf61 Merge pull request #1597 from makeplane/develop
promote: develop to stage-release
2023-07-20 19:32:04 +05:30
Dakshesh Jain
546aa40aa3 fix: un-read count not mutating (#1598)
* refactor: height of popover & api fetch call

* fix: notification subscribe endpoint

* fix: un-read count not mutating
2023-07-20 19:15:13 +05:30
Nikhil
78669363b1 fix: restrict notifications for created by when the actor is same (#1596) 2023-07-20 18:53:02 +05:30
guru_sainath
bca749986a Merge pull request #1595 from makeplane/develop
promote: develop to stage-release
2023-07-20 18:11:18 +05:30
gurusainath
229938114d Merge branch 'develop' of gurusainath:makeplane/plane into develop 2023-07-20 17:25:31 +05:30
gurusainath
c5e418ab47 Merge branch 'stage-release' of gurusainath:makeplane/plane into develop 2023-07-20 17:25:00 +05:30
Aaryan Khandelwal
ecdd1f1d03 promote: develop to stage-release (#1594)
* fix: onboarding invitations overflow (#1575)

* fix: onboarding invitations overflow

* fix: user avatar in the notification card

* style: update graph grid color

* fix: no 'Create by me' label coming up (#1573)

* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

* fix: 'read' -> 'unread' heading, my issue wrong filter

* feat: made snooze dropdown & modal

feat: switched to calendar

* fix: minor ui fixes

* feat: snooze modal date/time select

* fix: params for read/un-read notification

* style: snooze notification modal

* fix: no label for 'Create by me'

* fix: no label for 'Create by me'

* fix: removed console log

* fix: tooltip going behind popover

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* style: tooltip on notification header actions (#1577)

* style: tooltip on notification header

* chore: update tooltip content

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: user migrations for back population (#1578)

* fix: total notifications count (#1579)

* fix: notification card (#1583)

* feat: add new icons package (#1586)

* feat: add material icons package

* chore: replace issue view icons

* chore: notification ordering (#1584)

* fix: uuid error when cycle and module updates (#1585)

* refactor: height of popover & api fetch call (#1587)

* fix: snooze dropdown overflow (#1588)

* fix: notification subscribe endpoint (#1593)

* refactor: height of popover & api fetch call

* fix: notification subscribe endpoint

* chore: notification empty state overflow (#1592)

* chore: notification empty state overflow

* fix: white logo

* fix: custom theme default values

* fix: custom theme default values

* fix: issues count to remove archived issues (#1591)

* dev: background migration for user custom themes (#1590)

---------

Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-07-20 17:24:17 +05:30
Nikhil
e687cd6f6a dev: background migration for user custom themes (#1590) 2023-07-20 16:59:09 +05:30
Nikhil
9b6721790f fix: issues count to remove archived issues (#1591) 2023-07-20 16:58:34 +05:30
gurusainath
05df65577a Merge branch 'stage-release' of gurusainath:makeplane/plane into develop 2023-07-20 16:54:40 +05:30
Aaryan Khandelwal
52d21b9dda chore: notification empty state overflow (#1592)
* chore: notification empty state overflow

* fix: white logo

* fix: custom theme default values

* fix: custom theme default values
2023-07-20 16:51:00 +05:30
Dakshesh Jain
51f10d5f36 fix: notification subscribe endpoint (#1593)
* refactor: height of popover & api fetch call

* fix: notification subscribe endpoint
2023-07-20 16:35:18 +05:30
Aaryan Khandelwal
9275e6f373 promote: develop to stage-release (#1589)
* fix: onboarding invitations overflow (#1575)

* fix: onboarding invitations overflow

* fix: user avatar in the notification card

* style: update graph grid color

* fix: no 'Create by me' label coming up (#1573)

* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

* fix: 'read' -> 'unread' heading, my issue wrong filter

* feat: made snooze dropdown & modal

feat: switched to calendar

* fix: minor ui fixes

* feat: snooze modal date/time select

* fix: params for read/un-read notification

* style: snooze notification modal

* fix: no label for 'Create by me'

* fix: no label for 'Create by me'

* fix: removed console log

* fix: tooltip going behind popover

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* style: tooltip on notification header actions (#1577)

* style: tooltip on notification header

* chore: update tooltip content

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: user migrations for back population (#1578)

* fix: total notifications count (#1579)

* fix: notification card (#1583)

* feat: add new icons package (#1586)

* feat: add material icons package

* chore: replace issue view icons

* chore: notification ordering (#1584)

* fix: uuid error when cycle and module updates (#1585)

* refactor: height of popover & api fetch call (#1587)

* fix: snooze dropdown overflow (#1588)

---------

Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
2023-07-20 15:14:57 +05:30
Aaryan Khandelwal
4aef8c2242 fix: snooze dropdown overflow (#1588) 2023-07-20 14:49:30 +05:30
Dakshesh Jain
780573dadd refactor: height of popover & api fetch call (#1587) 2023-07-20 14:33:24 +05:30
Nikhil
5e625ab132 fix: uuid error when cycle and module updates (#1585) 2023-07-20 14:18:28 +05:30
Nikhil
34123681cf chore: notification ordering (#1584) 2023-07-20 14:18:06 +05:30
Aaryan Khandelwal
c72ff782ac feat: add new icons package (#1586)
* feat: add material icons package

* chore: replace issue view icons
2023-07-20 14:17:21 +05:30
Aaryan Khandelwal
6eb72507a5 fix: notification card (#1583) 2023-07-20 10:52:52 +05:30
Aaryan Khandelwal
26b18b431b fix: total notifications count (#1579) 2023-07-19 20:37:40 +05:30
Nikhil
1bae9289f5 fix: user migrations for back population (#1578) 2023-07-19 20:06:12 +05:30
Dakshesh Jain
5c5bcb33e3 style: tooltip on notification header actions (#1577)
* style: tooltip on notification header

* chore: update tooltip content

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-19 18:42:32 +05:30
Dakshesh Jain
bed5f76082 fix: no 'Create by me' label coming up (#1573)
* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

* fix: 'read' -> 'unread' heading, my issue wrong filter

* feat: made snooze dropdown & modal

feat: switched to calendar

* fix: minor ui fixes

* feat: snooze modal date/time select

* fix: params for read/un-read notification

* style: snooze notification modal

* fix: no label for 'Create by me'

* fix: no label for 'Create by me'

* fix: removed console log

* fix: tooltip going behind popover

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-19 17:40:40 +05:30
Aaryan Khandelwal
124c2f772e fix: onboarding invitations overflow (#1575)
* fix: onboarding invitations overflow

* fix: user avatar in the notification card

* style: update graph grid color
2023-07-19 17:09:53 +05:30
Vihar Kurama
b38898753f Merge pull request #1570 from makeplane/develop
promote: develop to stage-release
2023-07-19 16:07:43 +05:30
guru_sainath
86a120f11e Merge branch 'stage-release' into develop 2023-07-19 16:05:13 +05:30
Rhea Jain
da603dc3f8 Update README.md (#1543) 2023-07-19 15:28:01 +05:30
Nikhil
2f3970f641 feat: beat worker for configuration (#1571) 2023-07-19 15:25:33 +05:30
Nikhil
d759438ebd fix: issue automation (#1569) 2023-07-19 15:08:41 +05:30
Nikhil
0102f1d693 fix: psycopg errors (#1568) 2023-07-19 15:07:54 +05:30
Dakshesh Jain
53e443d816 feat: notifications (#1566)
* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

* fix: 'read' -> 'unread' heading, my issue wrong filter

* feat: made snooze dropdown & modal

feat: switched to calendar

* fix: minor ui fixes

* feat: snooze modal date/time select

* fix: params for read/un-read notification

* style: snooze notification modal

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-19 14:44:04 +05:30
Nikhil
98b9957753 fix: issue created notification (#1553)
* fix: issue created notification

* chore: my issues assigned notifications

* dev: fix read at notification
2023-07-19 14:31:45 +05:30
Nikhil
509af4662d dev: migrations (#1561) 2023-07-19 14:31:07 +05:30
Aaryan Khandelwal
d1f2a819f5 fix: update border colors on theme switch (#1565) 2023-07-19 13:53:22 +05:30
Aaryan Khandelwal
a42bff675b fix: archived issue details page overflow, restore button loading (#1564) 2023-07-19 13:29:08 +05:30
Aaryan Khandelwal
c71a2137e6 fix: scope and demand line graph y axis values (#1563) 2023-07-19 13:13:23 +05:30
Rhea Jain
5e1f0a2604 fixes as per consistency (#1562) 2023-07-19 13:00:11 +05:30
Aaryan Khandelwal
ccab382d7d chore: add dashboard navbar (#1560) 2023-07-19 12:16:39 +05:30
Aaryan Khandelwal
a7aa78a349 chore: high contrast theming (#1559)
* fix: archived issues empty state

* chore: update high contrast theme colors
2023-07-19 11:47:41 +05:30
Aaryan Khandelwal
5ae963c451 fix: tour sidebar icons, auth pages scroll (#1555)
* fix: tour sidebar icons, menu button word wrap

* chore: change theme on sign out

* fix: auth pages scroll y-padding
2023-07-18 19:02:33 +05:30
Dakshesh Jain
07c097c9ad style: different style for archive & project issues (#1552)
* fix: same empty state coming for all issue list related UI

* style: different style for archive & project  issues
2023-07-18 18:58:36 +05:30
Aaryan Khandelwal
fc92d7d1a0 chore: hide issue properties if there are none (#1554) 2023-07-18 18:47:28 +05:30
Nikhil
9ba8f5c21f fix: cycle module notifications (#1536)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* dev: improve issue fetch
2023-07-18 15:59:58 +05:30
Aaryan Khandelwal
059b8c793a refactor: parent issue select (#1546)
* refactor: parent issue select

* fix: sibling issues list
2023-07-18 15:36:03 +05:30
Dakshesh Jain
93da220c4a fix: same empty state coming for all issue list related UI (#1547) 2023-07-18 15:28:12 +05:30
Aaryan Khandelwal
0feab162ff style: onboarding screens (#1539)
* fix: onboarding screen styling

* chore: minor styling fixes

* chore: disable buttons if form is invalid
2023-07-18 15:20:05 +05:30
Nikhil
55a1291b1d fix: inbox issue archival (#1538)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* dev: fix inboc issue archival

* dev: update the filter for closing issues
2023-07-18 15:10:20 +05:30
Nikhil
b12a00cf4a chore: default project state colors (#1540)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* chore: project create default state colors
2023-07-18 15:10:01 +05:30
Nikhil
52ee8c5615 chore: remove logger from gpt assistant (#1542)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* chore: remove logger from gpt assistant
2023-07-18 15:09:44 +05:30
Nikhil
68108c9fe9 fix: favorite permissions (#1549)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* dev: fix permissions for cycle and views
2023-07-18 15:09:18 +05:30
Aaryan Khandelwal
88fbe85087 fix: button and input heights (#1548) 2023-07-18 15:00:06 +05:30
Dakshesh Jain
9b423cea4b fix: create workspace valid (#1535)
show alert on-change rather then on-submit
2023-07-18 12:08:22 +05:30
Dakshesh Jain
9d891ecce1 fix: selecting label once create using modal (#1537) 2023-07-18 12:08:09 +05:30
Dakshesh Jain
16a7bd3bda feat: user issue notifications (#1523)
* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-07-18 12:07:55 +05:30
Dakshesh Jain
6e9f3971a5 style: empty state for archive issue (#1541) 2023-07-18 12:07:35 +05:30
Aaryan Khandelwal
dddfeb17b7 refactor: dropdowns (#1532) 2023-07-17 17:35:47 +05:30
Aaryan Khandelwal
538d67dbd9 fix: theming colors (#1533)
* chore: update border colors

* chore: loading screens bg color, custom theming default values

* chore: remove unnecessary images

* chore: update static colors

* chore: update old variable names

* chore: update issue activity icon colors

* chore: update user activity icon colors
2023-07-17 16:28:23 +05:30
Nikhil
090870b03e fix: created_by notifications (#1534)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* dev: remove unused imports and improve the filtering

* dev: fix unread filter

* dev: fix created_by none when updating the field
2023-07-17 16:09:30 +05:30
Aaryan Khandelwal
0a56a30ab2 fix: issue sidebar create label form (#1527)
* fix: issue sidebar create label form

* fix: issue details page overflow
2023-07-17 15:37:50 +05:30
Aaryan Khandelwal
8df1648329 fix: dashboard pie chart legends overflow (#1528) 2023-07-17 14:48:43 +05:30
Nikhil
b69c4b6b30 chore: notifications (#1515)
* chore: add triggered by details for notifications

* dev: update issue activity json to include extra fields

* dev: triggered_by details

* dev: add bot filtering

* dev: unread notification count endpoint

* dev: update endpoint to send count for all notification types
2023-07-17 13:17:48 +05:30
Nikhil
e0181342c0 fix: n+1s (#1514)
* dev: fix workspace n+1

* dev: trim down label details for issue state serializer
2023-07-17 13:17:34 +05:30
Nikhil
f9f8b5c3d9 chore: update bridge id to cycle and module id (#1513)
* chore: update bridge id to cycle and module id

* fix: typo
2023-07-17 13:17:11 +05:30
Aaryan Khandelwal
5fadf53580 style: auth screens (#1531)
* chore: invitations empty state

* style: new auth screens

* chore: update typography

* chore: update border colors

* chore: resend code text
2023-07-17 13:00:44 +05:30
Aaryan Khandelwal
da6ecd439c refactor: favorite projects fetch function, chore: update tooltip design (#1526)
* chore: update tooltip design

* fix: due date popup overflow

* fix: build error

* fix: build error

* chore: add key to map return value

* refactor: favorite projects sidebar list
2023-07-14 15:08:07 +05:30
pablohashescobar
7914bcf486 refactor: serializers to avoid getting child items (#1504)
* refactor: serializers to avoid getting child items

* dev: update issue state serializer to remove n+1 items
2023-07-14 12:01:38 +05:30
Aaryan Khandelwal
c9a5893c3f chore: update favorite projects endpoint (#1522)
* chore: upate favorite projects list endpoint

* chore: add project button on the sidebar

* refactor: sidebar favorite projects workflow
2023-07-13 19:44:53 +05:30
Anmol Singh Bhatia
7361657660 fix: issue delete redirection, chore: code refactor (#1521) 2023-07-13 19:00:43 +05:30
Anmol Singh Bhatia
60e96bcb72 style: app sidebar revamp (#1120)
* style: app sidebar, sidebar workspace dropdown and help section styling

* style: consistent  padding and spacing

* feat: material icon global component

* style: icons updated and tooltip added

* style: project list spacing and project name truncate

* style: sidebar padding and theming

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-13 18:35:43 +05:30
Anmol Singh Bhatia
3f3fb373cc fix: issue archive improvement, chore: code refactor (#1520)
* style: select month modal

* chore: automation setting dropdown updated ,style: month dropdown styling

* chore: restore issue alert message updated

* chore: archive issue fetching updated and code refactor

* fix: build fix
2023-07-13 17:04:24 +05:30
Bavisetti Narayan
a829e6fc40 chore: added is_favorite filter (#1518)
* fix: notification filtering

* dev: reverse logic for archive filtering

* dev: fix watching notification

* dev: read filter

* dev: update automatic issue archival and close to send notifications

* dev: update archival structure for auto close issues

* chore: added is_favorite filter

* fix: removed the unwanted filter

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-07-13 16:51:42 +05:30
Aaryan Khandelwal
864e592bc5 fix: onboarding screen design issues (#1517)
* fix: projects empty state flicker

* fix: tour images padding

* chore: add greeting icon on the dashboard

* chore: update email id font weight

* fix: placeholder color
2023-07-13 16:51:08 +05:30
pablohashescobar
411a661abd fix: magic code casing (#1519) 2023-07-13 16:49:37 +05:30
pablohashescobar
61ad6b9e0e chore: upgrade psycopg to 3 (#1507) 2023-07-13 13:51:02 +05:30
pablohashescobar
2ff49c93bd fix: notifications (#1498)
* fix: notification filtering

* dev: reverse logic for archive filtering

* dev: fix watching notification

* dev: read filter

* dev: update automatic issue archival and close to send notifications

* dev: update archival structure for auto close issues

* Closing of dialog when we click around the dialog (#1364)

* style: new empty states (#1497)

* fix: custom colors opacity

* chore: update text colors for dark mode

* fix: dropdown text colors, datepicker bg color

* chore: update text colors

* chore: updated primary bg color

* style: new empty states added

* refactor: empty state for issues

* style: empty state for estimates

* chore: update labels, estimates and integrations empty states

* fix: custom analytics sidebar

* fix: archival spelling mistake

* fix: updated the default state logic

* dev: fix key

---------

Co-authored-by: Khrystyna Derenivska <56432889+kblueberry@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-07-13 13:47:24 +05:30
Aaryan Khandelwal
120d06983d style: inbox (#1508) 2023-07-13 11:38:45 +05:30
Anmol Singh Bhatia
c9cbca5ec8 feat: auto-archive and auto-close (#1502)
* chore: issue archive services and types added

* chore: project type and constant updated

* feat: auto-close and auto-archive feature added

* feat: implement rendering of archived issues

* feat: implemented rendering of only list view for archived issues , chore: update types and services

* feat: implemented archive issue detail page and unarchive issue functionality , chore: refactor code

* feat: activity for issue archive and issue restore added

* fix: redirection and delete fix

* fix: merge conflict

* fix: restore issue redirection fix

* fix: disable modification of issue properties for archived issues, style: disable properties styling

* fix: hide empty group, switch to list view on redirct to archived issues

* fix: remove unnecessary header buttons for archived issue

* fix: auto-close dropdown fix
2023-07-13 11:34:37 +05:30
pablohashescobar
275942a246 feat: flag for onboarding tour completion (#1499)
* feat: flag for onboarding tour completion

* dev: boolean field

* dev: user tour completed endpoint

* dev: onboarding step json
2023-07-12 22:10:07 +05:30
Aaryan Khandelwal
a1b09fcbc6 style: onboarding screens (#1412)
* style: new onboarding screens

* chore: onboarding tour screens

* fix: build error

* fix: build errors

* style: default layout background

* chor: update user auth hook logic, style: new onboarding screens

* fix: component structure

* chore: tab responsiveness added

* fix: redirection logic

* style: welcome screens responsiveness

* chore: update workspace url input field

* style: mobile responsiveness added

* chore: complete onboarding workflow

* style: create workspace page design update

* style: workspace invitations page design update

* chore: update steps logic

* fix: step change logic

* style: tour steps
2023-07-12 19:55:08 +05:30
Aaryan Khandelwal
26f0e9da00 fix: add missing argument (#1506) 2023-07-12 17:55:58 +05:30
Aaryan Khandelwal
4a2057c0b3 style: new empty states (#1497)
* fix: custom colors opacity

* chore: update text colors for dark mode

* fix: dropdown text colors, datepicker bg color

* chore: update text colors

* chore: updated primary bg color

* style: new empty states added

* refactor: empty state for issues

* style: empty state for estimates

* chore: update labels, estimates and integrations empty states

* fix: custom analytics sidebar
2023-07-12 11:45:45 +05:30
Khrystyna Derenivska
82ff786666 Closing of dialog when we click around the dialog (#1364) 2023-07-12 11:39:40 +05:30
Bavisetti Narayan
5773ff2afd chore: updated old and new value in issue archival (#1500) 2023-07-11 19:15:39 +05:30
Ankur Singh
1403a536c1 feat: openai host (#1447)
* support custom openai api endpoints (for azure and local deployments)

* update openai python package and use new api format

* fix: project member list endpoint n+1 (#1458)

* chore: workspace char name and slug maximum length (#1453)

* fix: user invitation workflow for self hosted version (#1441)

* chore: due date filter (#1460)

* refactor: standardized date format throughout the platform (#1461)

* chore: due date filter (#965)

* chore: due date filter

* fix: deployment error

* chore: optimized code

* chore: created constants for due date

* chore: create seperated css file for react datepicker styling

* fix: due date filter

* chore: highlight selected option

* fix: merge conflicts

* fix: build error

* chore: date range selector validation

* fix: issue views overflow

* refactor: due date filter modal code

* refactor: multi level dropdown

* chore: due date filter select default value

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: layout of tabs on Pages is not adaptable to mobile screens #1380 (#1400)

* fix: layout of tabs on Pages is not adaptable to mobile screens #1380

* fix: scrolling experience on page

* chore: update project members type (#1459)

* fix: state icon color on group titles (#1435)

* fix: workspace invitation delete for self hosted (#1475)

* chore: upgrade backend dependencies (#1479)

* chore: upgrade backend dependencies

* dev: update storage settings for self hosted version

* chore: project members endpoint to support bulk operations (#1464)

* chore: rename workspace company size to organization size (#1463)

* chore: rename workspace company size to organization size

* chore: make workspace organization size as required

* fix: static and media files storages (#1482)

* fix: emoji render function (#1484)

* fix: emoji render function

* fix: emoji render function

* feat: bulk invite for project (#1466)

* feat: bulk invite for project

* feat: members dropdown updated

* fix: error message added ,style: ui improvement

* feat: added add members button for scenarios with multiple members

* chore: updated watch to fields

* feat: created on and updated on column added in spreadsheet view (#1454)

* feat: created on and updated on column added in spreadsheet view

* fix: build fix

* refactor: simplify logic

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: resolved overflow issue with longer state names (#1444)

* chore: update theming structure (#1422)

* chore: store various shades of accent color

* refactor: custom theme selector

* refactor: custom theme selector

* chore: update custom theme input labels

* fix: color generator function logic

* fix: accent color preloaded data

* chore: new theming structure

* chore: update shades calculation logic

* refactor: variable names

* chore: update color scheming

* chore: new color scheming

* refactor: themes folder structure

* chore: update classnames according to new variables

* Revert "chore: update classnames according to new variables"

This reverts commit 60a87453b21768167e37889e709c12287ca07b08.

* chore: remove temp file

* chore: update classnames according to the new theming structure (#1494)

* chore: store various shades of accent color

* refactor: custom theme selector

* refactor: custom theme selector

* chore: update custom theme input labels

* fix: color generator function logic

* fix: accent color preloaded data

* chore: new theming structure

* chore: update shades calculation logic

* refactor: variable names

* chore: update color scheming

* chore: new color scheming

* refactor: themes folder structure

* chore: update classnames to the new ones

* chore: update static colors

* chore: sidebar link colors

* chore: placeholder color

* chore: update border classnames

* chore:  environment variables for worker and api (#1492)

* feat: notifications (#1363)

* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

---------

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

* fix: docker inconsistencies (#1493)

Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>

* feat: issue archival and close (#1474)

* chore: added issue archive using celery beat

* chore: changed the file name

* fix: created API and updated logic for achived-issues

* chore: added issue activity message

* chore: added the beat scheduler command

* feat: added unarchive issue functionality

* feat: auto issue close

* dev: refactor endpoints and issue archive activity

* dev: update manager for global filtering

* fix: added id in issue unarchive url

* dev: update auto close to include default close state

* fix: updated the list and retrive function

* fix: added the prefetch fields

* dev: update unarchive

---------

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

* fix: updated text and background colors (#1496)

* fix: custom colors opacity

* chore: update text colors for dark mode

* fix: dropdown text colors, datepicker bg color

* chore: update text colors

* chore: updated primary bg color

* feat: web waitlist modal integration (#1487)

* dev : Updating the limit of the issues in the sidebar and a weight list modal

* dev: integrated supabase and implemented web waitlist api endpoint

* dev : updated web pro weightlist request

* dev: rename typo

* dev: web waitlist endpoint update

* update: ui fixes

* fix: removed supabase from env.example

* chore: replaced supabase npm package to cdn

* chore: updated supabase req

* fix: Handled error status and error message.

---------

Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>

* add openai host env in all places

---------

Co-authored-by: Ankur Singh <ankur.singh@epfl.ch>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Kunal Vishwakarma <116634168+kunalv17@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Chandan Jal <97095857+ChandanJal@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Quadrubo <71718414+Quadrubo@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: srinivas pendem <65014795+srinivaspendem@users.noreply.github.com>
Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-07-11 18:51:25 +05:30
srinivas pendem
eba2f3820a feat: web waitlist modal integration (#1487)
* dev : Updating the limit of the issues in the sidebar and a weight list modal

* dev: integrated supabase and implemented web waitlist api endpoint

* dev : updated web pro weightlist request

* dev: rename typo

* dev: web waitlist endpoint update

* update: ui fixes

* fix: removed supabase from env.example

* chore: replaced supabase npm package to cdn

* chore: updated supabase req

* fix: Handled error status and error message.

---------

Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-07-11 16:27:29 +05:30
Aaryan Khandelwal
253edebb93 fix: updated text and background colors (#1496)
* fix: custom colors opacity

* chore: update text colors for dark mode

* fix: dropdown text colors, datepicker bg color

* chore: update text colors

* chore: updated primary bg color
2023-07-11 15:18:47 +05:30
Bavisetti Narayan
7554988164 feat: issue archival and close (#1474)
* chore: added issue archive using celery beat

* chore: changed the file name

* fix: created API and updated logic for achived-issues

* chore: added issue activity message

* chore: added the beat scheduler command

* feat: added unarchive issue functionality

* feat: auto issue close

* dev: refactor endpoints and issue archive activity

* dev: update manager for global filtering

* fix: added id in issue unarchive url

* dev: update auto close to include default close state

* fix: updated the list and retrive function

* fix: added the prefetch fields

* dev: update unarchive

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-07-11 14:35:20 +05:30
Quadrubo
7087b1b5f2 fix: docker inconsistencies (#1493)
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
2023-07-11 14:10:57 +05:30
pablohashescobar
abdb4a4778 feat: notifications (#1363)
* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-07-11 13:36:31 +05:30
pablohashescobar
ad1a074292 chore: environment variables for worker and api (#1492) 2023-07-11 13:30:55 +05:30
Aaryan Khandelwal
4c2cb2368a chore: update classnames according to the new theming structure (#1494)
* chore: store various shades of accent color

* refactor: custom theme selector

* refactor: custom theme selector

* chore: update custom theme input labels

* fix: color generator function logic

* fix: accent color preloaded data

* chore: new theming structure

* chore: update shades calculation logic

* refactor: variable names

* chore: update color scheming

* chore: new color scheming

* refactor: themes folder structure

* chore: update classnames to the new ones

* chore: update static colors

* chore: sidebar link colors

* chore: placeholder color

* chore: update border classnames
2023-07-10 12:47:00 +05:30
Aaryan Khandelwal
a14f8c281b chore: update theming structure (#1422)
* chore: store various shades of accent color

* refactor: custom theme selector

* refactor: custom theme selector

* chore: update custom theme input labels

* fix: color generator function logic

* fix: accent color preloaded data

* chore: new theming structure

* chore: update shades calculation logic

* refactor: variable names

* chore: update color scheming

* chore: new color scheming

* refactor: themes folder structure

* chore: update classnames according to new variables

* Revert "chore: update classnames according to new variables"

This reverts commit 60a87453b21768167e37889e709c12287ca07b08.

* chore: remove temp file
2023-07-10 12:25:32 +05:30
Anmol Singh Bhatia
d564ea8898 fix: resolved overflow issue with longer state names (#1444) 2023-07-07 14:11:33 +05:30
Anmol Singh Bhatia
7868bfa2b1 feat: created on and updated on column added in spreadsheet view (#1454)
* feat: created on and updated on column added in spreadsheet view

* fix: build fix

* refactor: simplify logic

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-07 14:11:07 +05:30
Anmol Singh Bhatia
353c85120f feat: bulk invite for project (#1466)
* feat: bulk invite for project

* feat: members dropdown updated

* fix: error message added ,style: ui improvement

* feat: added add members button for scenarios with multiple members

* chore: updated watch to fields
2023-07-07 14:10:36 +05:30
Aaryan Khandelwal
49f37e0346 fix: emoji render function (#1484)
* fix: emoji render function

* fix: emoji render function
2023-07-06 16:08:49 +05:30
pablohashescobar
3a2f4d55d7 fix: static and media files storages (#1482) 2023-07-06 15:30:34 +05:30
pablohashescobar
8443dfc9dd chore: rename workspace company size to organization size (#1463)
* chore: rename workspace company size to organization size

* chore: make workspace organization size as required
2023-07-06 11:58:47 +05:30
pablohashescobar
2f8a1dbe20 chore: project members endpoint to support bulk operations (#1464) 2023-07-06 11:58:35 +05:30
pablohashescobar
6bfeb6af34 chore: upgrade backend dependencies (#1479)
* chore: upgrade backend dependencies

* dev: update storage settings for self hosted version
2023-07-06 11:58:24 +05:30
pablohashescobar
fd3f6ab7a4 Merge pull request #1478 from makeplane/stage-release
promote: stage-release to master
2023-07-05 21:24:39 +05:30
pablohashescobar
d05d814efe chore: workspace invitation for self-hosted versions (#1477) 2023-07-05 21:21:31 +05:30
pablohashescobar
cc0701a823 fix: workspace invitation delete for self hosted (#1475) 2023-07-05 21:00:30 +05:30
Aaryan Khandelwal
3906503c1b fix: state icon color on group titles (#1435) 2023-07-05 00:56:39 +05:30
Aaryan Khandelwal
c3fe221e7a chore: update project members type (#1459) 2023-07-05 00:56:15 +05:30
Chandan Jal
e530533f9e fix: layout of tabs on Pages is not adaptable to mobile screens #1380 (#1400)
* fix: layout of tabs on Pages is not adaptable to mobile screens #1380

* fix: scrolling experience on page
2023-07-04 23:13:31 +05:30
Kunal Vishwakarma
4c3e2c252a chore: due date filter (#965)
* chore: due date filter

* fix: deployment error

* chore: optimized code

* chore: created constants for due date

* chore: create seperated css file for react datepicker styling

* fix: due date filter

* chore: highlight selected option

* fix: merge conflicts

* fix: build error

* chore: date range selector validation

* fix: issue views overflow

* refactor: due date filter modal code

* refactor: multi level dropdown

* chore: due date filter select default value

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-07-04 23:13:07 +05:30
Anmol Singh Bhatia
4ede04d72f refactor: standardized date format throughout the platform (#1461) 2023-07-04 18:19:19 +05:30
pablohashescobar
5a6fd0efdb chore: due date filter (#1460) 2023-07-04 16:48:59 +05:30
pablohashescobar
1a72a0dff4 fix: user invitation workflow for self hosted version (#1441) 2023-07-04 13:56:51 +05:30
pablohashescobar
e4ee6a5bfb chore: workspace char name and slug maximum length (#1453) 2023-07-04 13:54:48 +05:30
pablohashescobar
e23e9ccdbb fix: project member list endpoint n+1 (#1458) 2023-07-04 13:54:37 +05:30
guru_sainath
54a536e268 Merge pull request #1436 from makeplane/fix/bug_fix_and_ui_improvement 2023-07-03 13:57:15 +05:30
pablohashescobar
110eb39a51 dev: update packages to latest version (#1431) 2023-06-30 19:37:26 +05:30
anmolsinghbhatia
fc15da826b style: label dropdown empty state 2023-06-30 18:28:12 +05:30
Mark Percival
7e2caf65bb chore: Dry up docker compose environment variables (#1438)
* chore: Use docker compose extensions to dry up duplicate environment
variables between worker and api services.
See: https://docs.docker.com/compose/compose-file/11-extension/
2023-06-30 10:00:45 +05:30
tajkirkpatrick
d62dc25aa0 Add network timeout option to yarn install (#1382)
Inside of the Dockerfile.web, the yarn install command is susceptible to encountering a ECONNECT timeout error on aarch64 and ARM devices (such as a raspberry pi). This explicit overwrite of the default network timeout extends the window for low power CPU devices.
2023-06-30 09:53:44 +05:30
anmolsinghbhatia
32bf2ed56a fix: display properties fix 2023-06-29 18:37:19 +05:30
anmolsinghbhatia
ebf877b7fa fix: display properties fix 2023-06-29 18:33:44 +05:30
anmolsinghbhatia
d8eeb9e17a fix: cycle list item fix 2023-06-29 18:20:22 +05:30
anmolsinghbhatia
f2a4e026a5 fix: cycle and module sidebar overflow fix 2023-06-29 18:19:38 +05:30
anmolsinghbhatia
8229b12c9c style: recent page overflow fix 2023-06-29 18:17:52 +05:30
anmolsinghbhatia
3c07da433a fix: overflow fix 2023-06-29 18:16:01 +05:30
pablohashescobar
1ed7935bf0 docs: update readme.md for self hosting setup and remove pnpm from contribution guidelines (#1423) 2023-06-29 11:52:44 +05:30
Vamsi Kurama
6394f517a2 Merge pull request #1413 from makeplane/stage-release
promote: stage-release to master
2023-06-28 19:19:59 +05:30
pablohashescobar
379d258375 fix: charts design and mutation (#1426) (#1427)
* fix: pie chart overlap issue

* fix: burndown chart mutation

* fix: burndown chart mutation

Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
2023-06-28 19:09:44 +05:30
Aaryan Khandelwal
c743d43ce2 fix: charts design and mutation (#1426)
* fix: pie chart overlap issue

* fix: burndown chart mutation

* fix: burndown chart mutation
2023-06-28 19:08:33 +05:30
pablohashescobar
6a27827c16 Merge pull request #1425 from makeplane/develop
promote: develop to stage-release
2023-06-28 17:48:53 +05:30
Anmol Singh Bhatia
01f465fdfa style: adjust tooltip position and spacing in workspace id and title in spreadsheet (#1420) 2023-06-28 17:27:43 +05:30
Anmol Singh Bhatia
1a668c19a5 style: issue detail page layout (#1424) 2023-06-28 17:27:19 +05:30
pablohashescobar
206ca98307 Merge pull request #1418 from makeplane/develop
promote: develop to stage-release
2023-06-28 11:14:41 +05:30
pablohashescobar
ed0106200e dev: migrations for removal of timelineissues and shortcut (#1417) 2023-06-28 11:06:52 +05:30
pablohashescobar
473c32d3d4 fix: github importer issue (#1414) (#1415) 2023-06-27 22:56:06 +05:30
pablohashescobar
0bdd26ce12 fix: github importer issue (#1414) 2023-06-27 22:54:52 +05:30
guru_sainath
124d1c30aa Merge pull request #1410 from makeplane/develop
promote: develop to stage-release
2023-06-27 19:01:26 +05:30
Rhea Jain
df0b7d78e6 Update README.md (#1411)
Edited the security email.
2023-06-27 18:57:28 +05:30
Aaryan Khandelwal
7fbf0e6358 fix: progress chart x-axis values (#1409)
* fix: x-axis dates value in the progress chart

* chore: loader for active cycle
2023-06-27 18:08:37 +05:30
Aaryan Khandelwal
7f1def2041 fix: priority none filter (#1407) 2023-06-27 18:01:57 +05:30
Aaryan Khandelwal
b0cdcfd91e fixL inbox status colors (#1408) 2023-06-27 18:01:42 +05:30
Anmol Singh Bhatia
f3be2faa8f fix: resolve z-index issue with quick action menu and disable it for completed cycles (#1405) 2023-06-27 17:12:21 +05:30
guru_sainath
fd7274ba1c Merge pull request #1406 from makeplane/develop
promote: develop to stage-release
2023-06-27 16:48:34 +05:30
Anmol Singh Bhatia
b01afdf300 fix: spreadsheet quick action menu fix (#1404) 2023-06-27 15:49:19 +05:30
M. Palanikannan
5914240290 fix: cannot change the state if it's the only state in group (#1358)
* fixed loohole with groups and added tooltip

* muted text when dropdown disabled
2023-06-26 18:28:12 +05:30
Anmol Singh Bhatia
3f22ccc528 fix: filter none updated (#1399) 2023-06-26 18:08:49 +05:30
Anmol Singh Bhatia
ad3411284b feat: add existing issue option added in spreadsheet view (#1397) 2023-06-26 18:01:28 +05:30
pablohashescobar
ae051b28af fix: priority filtering (#1398) 2023-06-26 17:59:17 +05:30
Aaryan Khandelwal
c89592d587 chore: add tooltip to line graphs (#1392)
* chore: workspace dashboard line graph tooltip

* chore: burndown chart and analytics line graph tooltip added
2023-06-26 14:26:38 +05:30
Aaryan Khandelwal
c70fe91886 fix: create issue modal not working on workspace level (#1393) 2023-06-26 14:25:19 +05:30
Anmol Singh Bhatia
1a48fdd142 fix: bug fixes (#1394)
* fix: multi level dropdown fix

* style: spreadsheet view add issue button

* fix: spreadsheet view sub-issue label mutation
2023-06-26 14:24:52 +05:30
Aaryan Khandelwal
06ce89a03a fix: sidebar toggle button on workspace dashboard (#1389) 2023-06-24 18:12:11 +05:30
Anmol Singh Bhatia
fd30ea9a20 feat: editable label option added in all view , fix: view page list and kanban view mutation fix, chore: code refactor (#1390)
* feat: editable label select component added in spreadsheet view

* feat: editable label select option added in all view, chore: code refactor

* fix: view page list and kanban view mutation fix and sub issue mutation, chore: refactor partial update issue function

* fix: build fix
2023-06-24 18:09:06 +05:30
pablohashescobar
7acd4ef1af fix: issue duplication when ordering by labels and assignee (#1388) 2023-06-24 17:52:12 +05:30
pablohashescobar
46d93525be Merge pull request #1386 from makeplane/develop
promote: develop to stage-release
2023-06-24 01:34:34 +05:30
Anmol Singh Bhatia
56a35e74bb fix: spreadsheet bug fixes (#1385)
* fix: sub issue accordion fix

* chore: assignees sort order updated
2023-06-24 01:25:18 +05:30
Anmol Singh Bhatia
5ada1e5397 fix: spreadsheet view bug fixes (#1383)
* fix: due date sorting fix

* fix: update and delete sub-issue fix
2023-06-24 00:35:43 +05:30
Aaryan Khandelwal
ddaa8df1c5 fix: progress chart to show ideal line only when data is present (#1384) 2023-06-24 00:35:00 +05:30
pablohashescobar
ccbe773ce1 fix: state and priority ordering (#1378) 2023-06-23 22:18:03 +05:30
Anmol Singh Bhatia
160cc014a7 feat: spreadsheet view improvements (#1379)
* feat: quick menu for spreadsheet view added ,style: spreadsheet view column updated ,fix: z-index issue

* feat: sorting indicator, style: spreadsheet column
2023-06-23 21:33:24 +05:30
Aaryan Khandelwal
ca7d3309d3 chore: accept issue confirmation modal (#1377)
* chore: accept issue confirmation modal

* chore: add inbox option to the command menu

* fix: status colors not loading

* chore: show state name on the inbox issue sidebar
2023-06-23 19:30:53 +05:30
Aaryan Khandelwal
b87e2fff4c chore: show error messages from request (#1375) 2023-06-23 19:30:32 +05:30
Aaryan Khandelwal
428d0dbac9 fix: words breaking abruptly (#1371) 2023-06-23 19:30:11 +05:30
Aaryan Khandelwal
ca799a2b02 fix: update task for ai requests (#1368) 2023-06-23 19:29:36 +05:30
Aaryan Khandelwal
ff87137e40 fix: onboarding tracker (#1360) 2023-06-23 19:29:17 +05:30
pablohashescobar
df41daf71b fix: auth error messages (#1376) 2023-06-23 17:28:21 +05:30
pablohashescobar
fef83d3153 fix: inbox issue update (#1373) 2023-06-23 17:28:13 +05:30
pablohashescobar
8e094aa895 chore: triage state filtering (#1372) 2023-06-23 17:28:06 +05:30
Anmol Singh Bhatia
e08fc59114 feat: spreadsheet view (#1369)
* feat: spreadsheet view

* fix: fix scroll and overflow issues, feat: updated issue properties component, style: ui improvements

* feat: sub-issue toggle and sub-issue hook added, chore: code refactor

* fix: only render parent issue

* feat: sub issue fetching hook updated and nested sub issue added, chore: code refactor

* style: title sticky to left on scroll and column styling

* fix: tooltip , filter and view z-index fix

* feat: spreadsheet view column sorting, fix: sticky scroll issue fix

* feat: updated issue view filter for spreadsheet view

* style: spreadsheet view column

* feat: double click to edit title

* fix: estimate sorting fix

* style: spreadsheet view columns

* fix: spreadsheet view mutation, feat: edit , copy and delete option added

* fix: edit sub issue fix
2023-06-23 17:20:05 +05:30
pablohashescobar
0cb856b92f chore: inbox issue ordering (#1367) 2023-06-23 13:33:01 +05:30
pablohashescobar
37303e6cb8 refactor: inbox issues (#1370)
* refactor: inbox issue endpoints

* dev: update inbox issues endpooint
2023-06-23 13:30:52 +05:30
Aaryan Khandelwal
71b2884b57 chore: route to issue after creating it (#1359)
* chore: navigate to newly created inbox issue

* refactor: inbox

* fix: hide ai modal after issue creation

* chore: hide action buttons after acting upon them

* chore: add icon to inbox status

* chore: update inbox status colors
2023-06-23 13:19:26 +05:30
Aaryan Khandelwal
41b7544cfc feat: search endpoint (#1317)
* feat: search endpoint for parent issue selection

* feat: blocker and blocked by search endpoint

* refactor: blocker and blocked by components and types

* refactor: blocker and blocked by components, feeat: cycle and module new search endpoints

* chore: sub-issues param change

* style: show selected issues list
2023-06-23 13:18:38 +05:30
Aaryan Khandelwal
62392be5a3 chore: info icon for activity graph (#1353) 2023-06-23 11:10:19 +05:30
Aaryan Khandelwal
c2caed8c42 fix: bulk delete issues mutation (#1351) 2023-06-23 11:10:09 +05:30
Aaryan Khandelwal
048a01dbf3 fix: description not loading while editing an issue (#1349) 2023-06-23 11:09:57 +05:30
Aaryan Khandelwal
8982452500 fix: issue title breaking in issue card (#1339) 2023-06-23 11:09:47 +05:30
Aaryan Khandelwal
d3c56c1765 fix: cycle stats empty state (#1338)
* chore: active cycle percentage fix

* fix: progress chart x-axis values
2023-06-23 11:09:34 +05:30
Aaryan Khandelwal
9c85704be3 style: profile settings, activity, preference page padding (#1335) 2023-06-23 11:09:03 +05:30
Aaryan Khandelwal
f839150741 fix: create new issue when grouped by label (#1308) 2023-06-23 11:08:53 +05:30
pablohashescobar
e7bb580289 chore: set sentry dsn from environment variable (#1366)
* chore: set sentry dsn from env variable for backend and worker

* dev: sentry dsn for docker compose hub file
2023-06-22 19:48:40 +05:30
pablohashescobar
d1e834eb6f chore: updated user onboarded response (#1365) 2023-06-22 19:48:28 +05:30
pablohashescobar
33cfbbf153 chore: enable print logs for background workers when in DEBUG mode (#1357) 2023-06-22 19:48:17 +05:30
pablohashescobar
bfac39f1bc chore: inbox issue permissions (#1341)
* chore: inbox issue permissions

* dev: update delete endpoint
2023-06-22 19:48:04 +05:30
pablohashescobar
537cd2f5dd chore: link and attachment count in sub issues (#1352) 2023-06-22 19:47:56 +05:30
Aaryan Khandelwal
352c84b026 fix: clear inbox status filter button (#1355) 2023-06-22 16:23:39 +05:30
Aaryan Khandelwal
2cef6e67d4 chore: decline issue mutation (#1354) 2023-06-21 17:56:08 +05:30
Aaryan Khandelwal
a0ae569a68 chore: show inbox applied filters list (#1334) 2023-06-21 17:32:56 +05:30
Aaryan Khandelwal
7d29a89eed fix: inbox mutation fixes (#1324)
* chore: inbox status update mutation

* fix: inbox issue activity mutation

* refactor: code structure

* chore: snoozed status message

* chore: disable older dates for snoozing

* chore: extend snooze time

* chore: hide copy link from inbox
2023-06-21 17:10:52 +05:30
Aaryan Khandelwal
cf8c902473 chore: update cycle and module stats logic (#1323)
* refactor: cycles stats

* chore: show assignee avatar in stats

* chore: cycles and modules sidebar stats refactor

* fix: build errors
2023-06-20 16:32:02 +05:30
pablohashescobar
d7097330ef chore: update issue search for cycle and modules (#1314)
* chore: update issue search for cycle and modules

* dev: return state name, group and color in search

* dev: sub issue search
2023-06-20 10:27:45 +05:30
pablohashescobar
7a991720a8 chore: add assignee, label and burndown plot in module details (#1313)
* chore: add assignee, label and burndown plot in module details

* dev: fix typo and key error

* dev: add avatar in module retrieve
2023-06-20 10:27:34 +05:30
pablohashescobar
ac6fae44e8 chore: toggle sub issue view and sub issue count in sub issues (#1312) 2023-06-20 10:27:21 +05:30
pablohashescobar
8496422d20 fix: inbox issue activity (#1310) 2023-06-20 10:27:01 +05:30
pablohashescobar
d1d8722525 remove: shortcut module (#1315) 2023-06-20 10:26:44 +05:30
pablohashescobar
f797bb20f9 remove: time line issues (#1316) 2023-06-20 10:26:31 +05:30
pablohashescobar
464c0f2308 chore: add assignee avatar and minor refactor on cycles list and retrieve endpoint (#1320) 2023-06-20 10:25:47 +05:30
pablohashescobar
c9ebc20a8e fix: importer delete when imported_data is None (#1328) 2023-06-20 10:24:38 +05:30
Bavisetti Narayan
0c3635cf25 chore: added DEBUG value for docker setup (#1327)
* chore: add DEBUG value for docker setup

* refactor: removed the extra DEFAULT value
2023-06-20 09:36:08 +05:30
Aaryan Khandelwal
f2ebac1bb4 fix: issue details page auth (#1331) 2023-06-19 23:39:47 +05:30
vamsi
a3f6d61347 LICENSE change for Plane 2023-06-19 18:47:39 +05:30
Aaryan Khandelwal
1da86b80b2 chore: change charts library (#1305)
* fix: dashboard charts

* fix: cycles new charts

* chore: sidebar burn down chart and calendar graph

* chore: update dashboard line and pie graph

* chore: update axes width of burndown chart

---------

Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com>
2023-06-19 12:59:57 +05:30
Sai Tharun
59c0de9b57 🔨 updated missing migration file (#1321)
Co-authored-by: saitharunsai <sai.tharun@livehealth.in>
2023-06-19 08:23:44 +05:30
Vamsi Kurama
dbbce439ff Merge pull request #1311 from makeplane/stage-release
promote: staging to master
2023-06-16 21:34:03 +05:30
pablohashescobar
4c0857233e fix: workspace member invite to avoid lower permission user to invite higher permission member (#1309) 2023-06-16 19:52:24 +05:30
Aaryan Khandelwal
0dfa06e55b fix: lower role user cannot invite higher role user (#1302) 2023-06-16 19:06:34 +05:30
Aaryan Khandelwal
81f6562168 feat: sign up page added (#1306) 2023-06-16 19:00:04 +05:30
pablohashescobar
56a4e18a3c chore: link and attachment count for cycles and modules (#1307) 2023-06-16 18:59:13 +05:30
pablohashescobar
bfe581d3bd dev: workspace issue count (#1298) 2023-06-16 18:58:56 +05:30
pablohashescobar
78f9028b2f fix: member invite (#1303)
* fix: member invite

* dev: fix integer errors
2023-06-16 18:58:29 +05:30
pablohashescobar
885f5deebe chore: update docker setup to mount env file (#1270) 2023-06-16 18:58:08 +05:30
pablohashescobar
6b1d20449b chore: add labels data in cycles (#1223)
* dev: add labels data for all cycles

* dev: add assignees and labels percentage

* dev: initial peice on cycle burn down chart

* dev: cycles burn down chat
2023-06-16 18:57:49 +05:30
pablohashescobar
e9a0eb87cc feat: inbox (#1023)
* dev: initialize inbox

* dev: inbox and inbox issues models, views and serializers

* dev: issue object filter for inbox

* dev: filter for search issues

* dev: inbox snooze and duplicates

* dev: set duplicate to null by default

* feat: inbox ui and services

* feat: project detail in inbox

* style: layout, popover, icons, sidebar

* dev: default inbox for project and pending issues count

* dev: fix exception when creating default inbox

* fix: empty state for inbox

* dev: auto issue state updation when rejected or marked duplicate

* fix: inbox update status

* fix: hydrating chose with old values

filters workflow

* feat: inbox issue filtering

* fix: issue inbox filtering

* feat: filter inbox issues

* refactor: analytics, border colors

* dev: filters and views for inbox

* dev: source for inboxissue and update list inbox issue

* dev: update list endpoint to house filters and additional data

* dev: bridge id for list

* dev: remove print logs

* dev: update inbox issue workflow

* dev: add description_html in issue details

* fix: inbox track event auth, chore: inbox issue action authorization

* fix: removed unnecessary api calls

* style: viewed issues

* fix: priority validation

* dev: remove print logs

* dev: update issue inbox update workflow

* chore: added inbox view context

* fix: type errors

* fix: build errors and warnings

* dev: update issue inbox workflow and log all the changes

* fix: filters logic, sidebar fields to show

* dev: update issue filtering status

* chore: update create inbox issue modal, fix: mutation issues

* dev: update issue accept workflow

* chore: add comment to inbox issues

* chore: remove inboxIssueId from url after deleting

* dev: update the issue triage workflow

* fix: mutation after issue status change

* chore: issue details sidebar divider

* fix: issue activity for inbox issues

* dev: update inbox perrmissions

* dev: create new permission layer

* chore: auth layer for inbox

* chore: show accepting status

* chore: show issue status at the top of issue details

---------

Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-06-16 18:57:17 +05:30
pablohashescobar
963ccd808d fix: email ssl setting for docker environment (#1299) 2023-06-16 18:54:01 +05:30
pablohashescobar
1e2c1cac9c chore: project list endpoint to show is_member status (#1166) 2023-06-16 18:52:58 +05:30
pablohashescobar
592fe94cb4 chore: enable/disable signup in self hosted environments (#1271)
* dev: new onboarding workflow for self hosted instance

* dev: additional flag on user creation

* dev: segregate sign up and sign in endpoint

* dev: update sign in endpoint for not existing users
2023-06-16 18:23:39 +05:30
guru_sainath
02111d779b promote: staging to production 2023-06-14 17:47:23 +05:30
guru_sainath
c3aa1cb06d chore: auth workflow in magic-link
chore: auth workflow in magic-link
2023-06-14 17:42:48 +05:30
guru_sainath
e73bd96dbc chore: auth workflow in magic-link (#1292) 2023-06-14 16:56:17 +05:30
guru_sainath
8c9f4b9665 promote: develop to stage release
promote: develop to stage release
2023-06-14 14:57:30 +05:30
guru_sainath
be7706e62e chore: updating last_workspace_id under user (#1289)
* chore: onboarding steps workflow verification

* chore: onboarding onboarding variable update

* chore: role check in onboarding

* chore: updated last_workspace_id under user
2023-06-14 14:04:25 +05:30
guru_sainath
4083f623a0 refactor: onboarding user role validation (#1287) 2023-06-14 13:39:49 +05:30
guru_sainath
6f7b563712 refactor: onboarding workflow (#1286)
* chore: onboarding steps workflow verification

* chore: onboarding variable update
2023-06-14 13:17:35 +05:30
guru_sainath
4b25b7244b promote: develop to staging 2023-06-13 15:40:12 +05:30
guru_sainath
f774603b7f chore: onboarding workflow in authentication (#1281) 2023-06-13 14:37:25 +05:30
pablohashescobar
15b5db0cae dev: upgrade python runtime (#1256) 2023-06-12 10:02:16 +05:30
guru_sainath
66807bef0d fix: social auth authentication workflow (#1264)
* fix: github login mutation

* dev: updated social auth workflow and handled multiple loads on user

* dev: mutaing user and updated analytics logout issue resolved

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-06-10 01:26:38 +05:30
Vamsi Kurama
49f19c2c44 Merge pull request #1257 from makeplane/stage-release
promote: stage release to production
2023-06-09 16:27:25 +05:30
guru_sainath
9529ddb393 Merge pull request #1262 from makeplane/develop
promote: develop to stage-relaease
2023-06-09 16:23:23 +05:30
Aaryan Khandelwal
e7af3da115 chore: update remove file function logic (#1259)
* chore: update remove file function logic

* fix: workspace file delete endpoint
2023-06-09 10:24:57 +05:30
guru_sainath
d09f410f21 Merge pull request #1252 from makeplane/develop
promote: develop to stage release
2023-06-08 00:19:08 +05:30
pablohashescobar
98e6a1366c chore: update workspace invitation email redirection url (#1236)
* chore: update workspace invitation email redirection url

* dev: update workspace invitation mail
2023-06-08 00:14:53 +05:30
pablohashescobar
754142afa2 fix: workspace and project member user deletion (#1241)
* fix: workspace and project member user deletion

* fix: workspace member deletion

* dev: add comments
2023-06-08 00:14:41 +05:30
Aaryan Khandelwal
42fceb4dcd fix: user profile data mutation (#1243) 2023-06-07 19:03:49 +05:30
Aaryan Khandelwal
e949c4e130 chore: fetch only high priority issues for the active cycle (#1228) 2023-06-07 19:03:02 +05:30
Anmol Singh Bhatia
78c1a64690 fix: assignee dropdown, sign in button, and onboarding flicker fix (#1242) 2023-06-07 17:45:57 +05:30
guru_sainath
3d5fcbd4ce dev: route validation on non authenticated pages (#1238) 2023-06-07 13:47:56 +05:30
Anmol Singh Bhatia
f9cd1b1352 fix: last workspace id (#1237) 2023-06-07 13:18:45 +05:30
Peter Dave Hello
18f66805cb Improve apk usages in Dockerfile (#1198) 2023-06-07 12:30:42 +05:30
pablohashescobar
382a1343ea fix: file asset uploads in workspace (#1234) 2023-06-07 12:21:09 +05:30
pablohashescobar
c05eb9e240 fix: forgot password email subject and update template (#1233) 2023-06-07 09:09:59 +05:30
Anmol Singh Bhatia
40d2990565 fix: onboarding ui fix (#1225) 2023-06-07 01:56:58 +05:30
Aaryan Khandelwal
684df96969 chore: replace nextjs Image element (#1227) 2023-06-07 01:56:21 +05:30
Aaryan Khandelwal
1f3fdd5d0a feat: reset password page for self-hosted added (#1221)
* feat: reset password page for self-hosted added

* chore: change reset password workflow

* dev: update email template for reset password

* chore: updated restricted workspace slugs list

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-06-06 21:36:13 +05:30
Anmol Singh Bhatia
6f2a38ad66 fix: bug and auth fixes (#1224)
* fix: sign in and invitation page fixes

* fix: project and workspace services track event fix

* fix: user onboarding complete track event fix

* fix: issue track event fix

* fix: partial property , issue comment and mark as done issue track event fix

* fix: bulk delete , move to cycle or module and issue label track event fix

* fix: state , cycle and module track event fix

* fix: pages and block track event fix

* fix: integration , estimate , importer , analytics and gpt track event fix

* fix: view track event fix

* fix: build fix

* fix: build fix
2023-06-06 21:36:00 +05:30
pablohashescobar
c127353281 chore: workspace invite created detail (#1209)
* chore: workspace invite created detail

* dev: select related workspace member invite list
2023-06-06 19:15:56 +05:30
pablohashescobar
705371eaf3 fix: file upload size limit (#1218) 2023-06-06 19:15:42 +05:30
pablohashescobar
fae9d8cdc1 chore: reset password url (#1220)
* chore: reset password url

* dev: update password reset endpoint

* dev: update reset password url
2023-06-06 19:15:20 +05:30
pablohashescobar
b6c0ddac50 chore: move minio endpoint url to environment configuration (#1210) 2023-06-06 08:21:57 +05:30
pablohashescobar
557e96c68e fix: email tls when selfhosting (#1206) 2023-06-05 21:26:04 +05:30
guru_sainath
7eae6b4c9e fix: user public authentication workflow updates (#1207)
* auth integration fixes

* auth integration fixes

* auth integration fixes

* auth integration fixes

* dev: update user api to return fallback workspace and improve the structure of the response

* dev: fix the issue keyerror and move onboarding logic to serializer method field

* dev: use-user-auth hook imlemented for route access validation and build issues resolved effected by user payload

* fix: global theme color fix

* style: new onboarding ui , fix: use-user-auth hook implemented

* fix: command palette, project invite modal and issue detail page mutation type fix

* fix: onboarding redirection fix

* dev: build isuue resolved

* fix: use user auth hook fix

* fix: sign in toast alert fix, sign out redirection fix and user theme error fix

* fix: user response fix

* fix: unAuthorizedStatus logic updated

* dev: Implemented SEO in app.tsx

* dev: User public auth workflow updates.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: anmolsinghbhatia <anmolsinghbhatia@caravel.tech>
2023-06-05 17:48:29 +05:30
pablohashescobar
77e05a3599 fix: project member role update (#1205) 2023-06-05 17:45:10 +05:30
pablohashescobar
2511a284eb feat: plane proxy setup (#1181)
* feat: plane proxy setup

* dev: remove port mapping from web, api and minio containers
2023-06-05 12:53:04 +05:30
pablohashescobar
e3da80755c fix: minio settings (#1172) 2023-06-05 12:52:25 +05:30
pablohashescobar
58d1d8f132 fix: issue search for blocking and blocked_by condition (#1182)
* fix: issue search for blocking and blocked_by condition

* fix: issue search endpoint blockers

* fix: rectify the filter parameters
2023-06-05 12:51:30 +05:30
pablohashescobar
50060a0bf9 chore: update docker uploads (#1202) 2023-06-05 12:51:12 +05:30
pablohashescobar
bffc6a60e7 fix: workspace member role update (#1203) 2023-06-05 12:50:44 +05:30
pablohashescobar
799cf230b7 Revert "Update README.md (#1189)" (#1193)
This reverts commit 37442f482b.
2023-06-04 15:51:06 +05:30
tarunratan
37442f482b Update README.md (#1189)
change in readme of docker-compose
2023-06-04 09:49:13 +05:30
Nathanael Demacon
c234f2a087 docs: fix docker compose quick start (#1173)
* docs: add missing .env.example fetch in docker compose quick start

* docs: clone the repository instead of fetching particular files
2023-06-02 12:27:15 +05:30
Anmol Singh Bhatia
857879f5ed fix : theme provider fix (#1164) 2023-06-01 14:40:27 +05:30
sriram veeraghanta
44f8ba407d Authentication Workflow fixes. Redirection fixes (#832)
* auth integration fixes

* auth integration fixes

* auth integration fixes

* auth integration fixes

* dev: update user api to return fallback workspace and improve the structure of the response

* dev: fix the issue keyerror and move onboarding logic to serializer method field

* dev: use-user-auth hook imlemented for route access validation and build issues resolved effected by user payload

* fix: global theme color fix

* style: new onboarding ui , fix: use-user-auth hook implemented

* fix: command palette, project invite modal and issue detail page mutation type fix

* fix: onboarding redirection fix

* dev: build isuue resolved

* fix: use user auth hook fix

* fix: sign in toast alert fix, sign out redirection fix and user theme error fix

* fix: user response fix

* fix: unAuthorizedStatus logic updated

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: anmolsinghbhatia <anmolsinghbhatia@caravel.tech>
2023-05-30 19:14:35 +05:30
Anmol Singh Bhatia
33db616767 fix: default status set for module form (#1160) 2023-05-29 22:16:32 +05:30
Anmol Singh Bhatia
c949c4d244 fix: empty module mutation and issue view flicker fix (#1158) 2023-05-29 18:35:43 +05:30
Anmol Singh Bhatia
c8caa925b1 chore: page and cycle refactor (#1159)
* fix: release note modal fix

* chore: cycle and page endpoint refactor
2023-05-29 18:35:16 +05:30
Aaryan Khandelwal
26ba4d71c3 fix: order by last updated, cycles empty state alignment (#1151)
* fix: order by last updated

* fix: cycles empty space alignment, chore: new meta tags

* chore: update meta tags
2023-05-29 15:38:35 +05:30
Aaryan Khandelwal
022960d7e3 fix: pages layout and alignment issues (#1152) 2023-05-29 15:16:49 +05:30
guru_sainath
dce3c3f008 Merge pull request #1153 from makeplane/fix/cycles_layout
refactor: cycles list code and folder structure
2023-05-29 15:16:10 +05:30
pablohashescobar
ffc6077e9b chore: improve docker setup (#1150) 2023-05-29 12:11:16 +05:30
guru_sainath
23d08a2ad1 Merge pull request #1156 from makeplane/hot-fix
promote: hot-fix to develop
2023-05-29 10:46:45 +05:30
Aaryan Khandelwal
1c98f2dca9 chore: update cycle fetch keys names 2023-05-29 02:38:16 +05:30
Aaryan Khandelwal
053ebc031d fix: cycles layout 2023-05-29 02:14:40 +05:30
pablohashescobar
c9dee593eb chore: total members in user workspace invites (#1143) 2023-05-28 18:11:59 +05:30
pablohashescobar
bc8430220b chore: email settings for docker setup (#1148) 2023-05-28 18:11:36 +05:30
Marks
030558c026 Update text during the onboarding process (#1149)
* Update text during the onboarding process

A few of the phrases were worded in ways that took a bit longer to process. These changes should make onboarding more comfortable.

* Update text
2023-05-28 17:42:01 +05:30
pablohashescobar
d8c367c51e docs: update self hosting setup script (#1145) 2023-05-28 14:47:25 +05:30
Eli
1afb3ba4d2 chore: speedup replace-env-vars.sh (#1146) 2023-05-28 10:04:54 +05:30
pablohashescobar
8252b1ccde chore: configuration for nginx port (#1144) 2023-05-28 09:56:15 +05:30
Anmol Singh Bhatia
394f0bf555 chore: cycles endpoint updated and code refactor (#1135)
* chore: cycles endpoint updated and code refactor

* chore: incomplete cycle endpoint updated

* chore: code refactor
2023-05-26 15:38:56 +05:30
Anmol Singh Bhatia
4ce0ac6ea1 chore: pages endpoint updated (#1137) 2023-05-26 15:38:44 +05:30
pablohashescobar
cd821a934d chore: update single click deployments (#1141)
* chore: update single click deployments

* dev: update environment variables
2023-05-26 14:04:15 +05:30
pablohashescobar
f80b3f1eb1 fix: issue ordering for priority and updated_by parameters (#1142) 2023-05-26 13:51:09 +05:30
pablohashescobar
b6321438ce chore: docker setup (#1136)
* chore: update docker environment variables and compose file for better readability

* dev: update single dockerfile

* dev: update WEB_URL configuration

* dev: move database settings to environment variable

* chore: remove port configuration from default compose file

* dev: update example env to add EMAIL_FROM and default values for AWS
2023-05-26 11:09:59 +05:30
pablohashescobar
16604dd31b refactor: page views endpoint (#1130) 2023-05-25 14:13:54 +05:30
Aaryan Khandelwal
11b28048bf chore: analytics tracker events (#1119)
* chore: added export analytics event tracker

* chore: add analytics view events

* chore: workspace analytics view track event added
2023-05-25 12:38:53 +05:30
Dakshesh Jain
7e5b26ea82 fix: project ordering messing up in projects page (#1122) 2023-05-25 12:38:15 +05:30
pablohashescobar
5beb50fa76 fix: role updation (#1110) 2023-05-25 12:27:04 +05:30
pablohashescobar
af2d7d6f75 fix: project member delete when deleting user from workspace (#1123)
* fix: project member delete when deleting user from workspace

* fix: workspace and project member delete
2023-05-25 12:25:15 +05:30
pablohashescobar
e608b58e70 refactor: cycle views endpoint (#1128) 2023-05-25 12:24:39 +05:30
pablohashescobar
a16514ed11 fix: auto generated secret key to only generate hexadecimal characters (#1133) 2023-05-25 12:24:03 +05:30
Anmol Singh Bhatia
74329a49cc fix: send code button behavior on enter key press (#1121) 2023-05-25 12:19:48 +05:30
Anmol Singh Bhatia
def391cb76 feat: storing my issue view display properties (#1124) 2023-05-25 12:19:37 +05:30
Robin
e526a01295 Clean up docker compose file for selfhosting (#1022)
* Clean up docker compose file

Removed nginx container (might want to put it back?)
Changed spacing to tabs for better readability
Changed the order, first the important stuff (plane) and later the database/redis
All containers should be in the same format (first container_name, then image, then restart, etc.).
Removed links because deprecated since compose version 2, all containers are in one docker network
Removed build from plane-api
Removed ports from redis and postgresql
Removed PGDATA directory because that's the default one
Renamed redis and db to plane-redis and plane-db

* Fixed spacing (again)

* Fix spacing (attempt 3)

* Pasting error - should be good now

* New compose download instructions

---------

Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
2023-05-25 10:49:37 +05:30
pablohashescobar
0fb4a87454 fix: docker image uploads (#1108)
* dev: basic initial setup for images

* Update docker-compose.yml

* dev: minio setup

* dev: docker minio setup

* dev: update the asset view

* dev: setup minio with default configuration

* dev: update minio setup for creating buckets

* dev: update the permission sets

* dev: get variables from shell for create bucket

* dev: update image uploading setup for docker

* dev: environment variables update

* dev: web url for images

* dev: update image configuration

* dev: env update for email port

---------

Co-authored-by: Narayana <narayana.vadapalli1996@gmail.com>
2023-05-25 10:24:20 +05:30
Vihar Kurama
0bd6e53b44 docs: updated Readme with new features and screenshots (#1132)
* update readme images with new features

* remove line-break on readme

* update plane analytics image
2023-05-24 21:42:40 +05:30
gurusainath
f095594be9 update: Handled empty state in adding members to project. 2023-05-22 22:38:22 +05:30
guru_sainath
2e638b28b6 dev: added tooltip to title and added info for issues (#1112)
* update: tooltip in the blocks

* dev: added tooltip to title and added info for issues
2023-05-22 22:09:52 +05:30
guru_sainath
aaffe37fbe fix: Showing status for accounts not created in the workspace members (#1111) 2023-05-22 22:09:38 +05:30
617 changed files with 28877 additions and 13980 deletions

View File

@@ -1,20 +1,83 @@
# Replace with your instance Public IP
# Frontend
# Extra image domains that need to be added for Next Image
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
# Error logs
SENTRY_DSN=""
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
OPENAI_API_KEY=""
GPT_ENGINE=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
OPENAI_API_KEY="sk-" # add your openai key here
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh

1
.gitignore vendored
View File

@@ -43,6 +43,7 @@ yarn-error.log*
## Django ##
venv
.venv
*.pyc
staticfiles
mediafiles

View File

@@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- pnpm version 7.22.0
### Setup the project

View File

@@ -1,6 +1,5 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
@@ -13,9 +12,7 @@ RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
@@ -44,10 +41,12 @@ FROM python:3.11.1-alpine3.17 AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV DJANGO_SETTINGS_MODULE plane.settings.production
ENV DOCKERIZED 1
WORKDIR /code
RUN apk --update --no-cache add \
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
@@ -59,8 +58,8 @@ RUN apk --update --no-cache add \
COPY apiserver/requirements.txt ./
COPY apiserver/requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
@@ -81,18 +80,13 @@ COPY apiserver/plane plane/
COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./
RUN apk --update --no-cache add "bash~=5.2"
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
EXPOSE 3000
EXPOSE 80
WORKDIR /app
@@ -126,9 +120,6 @@ COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
EXPOSE 80
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@@ -1,201 +1,661 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
1. Definitions.
Preamble
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
The precise terms and conditions for copying, distribution and
modification follow.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
TERMS AND CONDITIONS
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
0. Definitions.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
"This License" refers to version 3 of the GNU Affero General Public License.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
A "covered work" means either the unmodified Program or a work based
on the Program.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
1. Source Code.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
END OF TERMS AND CONDITIONS
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
APPENDIX: How to apply the Apache License to your work.
The Corresponding Source for a work in source code form is that
same work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
2. Basic Permissions.
Copyright 2022 Plane Software Labs Private Limited
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
http://www.apache.org/licenses/LICENSE-2.0
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -15,11 +15,18 @@
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<br />
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screen.png"
alt="Plane Screens"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screens+Dark+Mode.png"
alt="Plane Screens"
width="100%"
/>
@@ -38,38 +45,26 @@ The easiest way to get started with Plane is by creating a [Plane Cloud](https:/
### Docker Compose Setup
- Clone the Repository
- Clone the repository
```bash
git clone https://github.com/makeplane/plane
```
- Change Directory
```bash
cd plane
chmod +x setup.sh
```
- Run setup.sh
```bash
./setup.sh localhost
./setup.sh http://localhost
```
> If running in a cloud env replace localhost with public facing IP address of the VM
- Export Environment Variables
```bash
set -a
source .env
set +a
```
- Run Docker compose up
```bash
docker-compose -f docker-compose-hub.yml up
docker compose up -d
```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>
@@ -89,41 +84,62 @@ docker-compose -f docker-compose-hub.yml up
## 📸 Screenshots
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Views+Dark+Mode.png"
alt="Plane Views"
width="100%"
/>
</a>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Issue+Detail+Dark+Mode.png"
alt="Plane Issue Details"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Cycles+%26+Modules+Dark+Mode.png"
alt="Plane Cycles and Modules"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
alt="Plane Quick Lists"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Analytics+Dark+Mode.png"
alt="Plane Analytics"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
alt="Plane Command K"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Pages+Dark+Mode.png"
alt="Plane Pages"
width="100%"
/>
</a>
</p>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Commad+K+Dark+Mode.png"
alt="Plane Command Menu"
width="100%"
/>
</a>
</p>
</p>
## 📚Documentation
@@ -141,4 +157,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
## ⛓️ 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 security@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 engineering@plane.so to disclose any security vulnerabilities.

View File

@@ -7,7 +7,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk --update --no-cache add \
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
@@ -15,8 +15,8 @@ RUN apk --update --no-cache add \
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add libffi-dev
RUN apk --update --no-cache --virtual .build-deps add \
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
@@ -46,10 +46,10 @@ COPY templates templates/
COPY gunicorn.config.py ./
USER root
RUN apk --update --no-cache add "bash~=5.2"
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
USER captain

View File

@@ -1,2 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -l info
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO

5
apiserver/bin/beat Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
python manage.py wait_for_db
celery -A plane beat -l info

View File

@@ -6,4 +6,4 @@ python manage.py migrate
# Create a Default User
python bin/user_script.py
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@@ -1,2 +1,2 @@
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission

View File

@@ -89,3 +89,16 @@ class ProjectEntityPermission(BasePermission):
role__in=[Admin, Member],
project_id=view.project_id,
).exists()
class ProjectLitePermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
project_id=view.project_id,
).exists()

View File

@@ -5,7 +5,6 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
from plane.db.models import WorkspaceMember
# Permission Mappings
Owner = 20
Admin = 15
@@ -44,7 +43,6 @@ class WorkSpaceBasePermission(BasePermission):
class WorkSpaceAdminPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
@@ -53,3 +51,13 @@ class WorkSpaceAdminPermission(BasePermission):
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists()
class WorkspaceEntityPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug
).exists()

View File

@@ -21,9 +21,9 @@ from .project import (
ProjectIdentifierSerializer,
ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .shortcut import ShortCutSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
from .asset import FileAssetSerializer
@@ -31,7 +31,6 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
TimeLineIssueSerializer,
IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
@@ -43,6 +42,7 @@ from .issue import (
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
)
from .module import (
@@ -69,6 +69,14 @@ from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
from .estimate import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
)
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer

View File

@@ -1,3 +1,6 @@
# Django imports
from django.db.models.functions import TruncDate
# Third party imports
from rest_framework import serializers
@@ -20,13 +23,13 @@ class CycleSerializer(BaseSerializer):
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField(read_only=True)
labels = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def get_assignees(self, obj):
members = [
{
@@ -44,6 +47,24 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects]
return unique_list
def get_labels(self, obj):
labels = [
{
"name": label.name,
"color": label.color,
"id": label.id,
}
for issue_cycle in obj.issue_cycle.all()
for label in issue_cycle.issue.labels.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in labels}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
return unique_list
class Meta:
model = Cycle

View File

@@ -0,0 +1,58 @@
# Third party frameworks
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .project import ProjectLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Inbox, InboxIssue, Issue
class InboxSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
pending_issue_count = serializers.IntegerField(read_only=True)
class Meta:
model = Inbox
fields = "__all__"
read_only_fields = [
"project",
"workspace",
]
class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = InboxIssue
fields = "__all__"
read_only_fields = [
"project",
"workspace",
]
class InboxIssueLiteSerializer(BaseSerializer):
class Meta:
model = InboxIssue
fields = ["id", "status", "duplicate_to", "snoozed_till", "source"]
read_only_fields = fields
class IssueStateInboxSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
bridge_id = serializers.UUIDField(read_only=True)
issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = "__all__"

View File

@@ -1,3 +1,6 @@
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
@@ -13,10 +16,10 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
TimelineIssue,
IssueProperty,
IssueBlocker,
IssueAssignee,
IssueSubscriber,
IssueLabel,
Label,
IssueBlocker,
@@ -38,6 +41,7 @@ class IssueFlatSerializer(BaseSerializer):
"id",
"name",
"description",
"description_html",
"priority",
"start_date",
"target_date",
@@ -251,6 +255,7 @@ class IssueCreateSerializer(BaseSerializer):
batch_size=10,
)
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
@@ -283,21 +288,6 @@ class IssueCommentSerializer(BaseSerializer):
]
class TimeLineIssueSerializer(BaseSerializer):
class Meta:
model = TimelineIssue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueProperty
@@ -472,12 +462,14 @@ class IssueAttachmentSerializer(BaseSerializer):
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
project_detail = ProjectSerializer(read_only=True, source="project")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
bridge_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
class Meta:
model = Issue
@@ -485,7 +477,7 @@ class IssueStateSerializer(BaseSerializer):
class IssueSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
@@ -539,3 +531,14 @@ class IssueLiteSerializer(BaseSerializer):
"created_at",
"updated_at",
]
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
]

View File

@@ -106,7 +106,7 @@ class ModuleFlatSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer):
module_detail = ModuleFlatSerializer(read_only=True, source="module")
issue_detail = IssueStateSerializer(read_only=True, source="issue")
issue_detail = ProjectLiteSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -151,7 +151,7 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleSerializer(BaseSerializer):
project_detail = ProjectSerializer(read_only=True, source="project")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
link_module = ModuleLinkSerializer(read_only=True, many=True)

View File

@@ -0,0 +1,12 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Notification
class NotificationSerializer(BaseSerializer):
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
class Meta:
model = Notification
fields = "__all__"

View File

@@ -77,6 +77,13 @@ class ProjectSerializer(BaseSerializer):
raise serializers.ValidationError(detail="Project Identifier is already taken")
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = ["id", "identifier", "name"]
read_only_fields = fields
class ProjectDetailSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
default_assignee = UserLiteSerializer(read_only=True)
@@ -85,6 +92,7 @@ class ProjectDetailSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
model = Project
@@ -93,7 +101,7 @@ class ProjectDetailSerializer(BaseSerializer):
class ProjectMemberSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
project = ProjectSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserLiteSerializer(read_only=True)
class Meta:
@@ -102,8 +110,8 @@ class ProjectMemberSerializer(BaseSerializer):
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = ProjectMemberInvite
@@ -117,7 +125,7 @@ class ProjectIdentifierSerializer(BaseSerializer):
class ProjectFavoriteSerializer(BaseSerializer):
project_detail = ProjectSerializer(source="project", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = ProjectFavorite
@@ -128,8 +136,13 @@ class ProjectFavoriteSerializer(BaseSerializer):
]
class ProjectLiteSerializer(BaseSerializer):
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = Project
fields = ["id", "identifier", "name"]
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields

View File

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

View File

@@ -25,6 +25,10 @@ class UserSerializer(BaseSerializer):
]
extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
return bool(obj.first_name) or bool(obj.last_name)
class UserLiteSerializer(BaseSerializer):
class Meta:

View File

@@ -19,6 +19,7 @@ from plane.db.models import (
class WorkSpaceSerializer(BaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Workspace
@@ -44,6 +45,8 @@ class WorkSpaceMemberSerializer(BaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
class Meta:
model = WorkspaceMemberInvite

View File

@@ -5,6 +5,7 @@ from django.urls import path
from plane.api.views import (
# Authentication
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
@@ -21,6 +22,7 @@ from plane.api.views import (
# User
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
## End User
# Workspaces
@@ -69,13 +71,14 @@ from plane.api.views import (
BulkDeleteIssuesEndpoint,
BulkImportIssuesEndpoint,
ProjectUserViewsEndpoint,
TimeLineIssueViewSet,
IssuePropertyViewSet,
LabelViewSet,
SubIssuesEndpoint,
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
## End Issues
# States
StateViewSet,
@@ -84,9 +87,6 @@ from plane.api.views import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
## End Estimates
# Shortcuts
ShortCutViewSet,
## End Shortcuts
# Views
IssueViewViewSet,
ViewIssuesEndpoint,
@@ -96,12 +96,8 @@ from plane.api.views import (
CycleViewSet,
CycleIssueViewSet,
CycleDateCheckEndpoint,
CurrentUpcomingCyclesEndpoint,
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
## End Cycles
# Modules
ModuleViewSet,
@@ -115,10 +111,6 @@ from plane.api.views import (
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
## End Pages
# Api Tokens
ApiTokenEndpoint,
@@ -148,6 +140,10 @@ from plane.api.views import (
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
# Inbox
InboxViewSet,
InboxIssueViewSet,
## End Inbox
# Analytics
AnalyticsEndpoint,
AnalyticViewViewset,
@@ -155,6 +151,10 @@ from plane.api.views import (
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
## End Analytics
# Notification
NotificationViewSet,
UnreadNotificationEndpoint,
## End Notification
)
@@ -162,6 +162,7 @@ urlpatterns = [
# Social Auth
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up
@@ -178,7 +179,7 @@ urlpatterns = [
),
# Password Manipulation
path(
"password-reset/<uidb64>/<token>/",
"reset-password/<uidb64>/<token>/",
ResetPasswordEndpoint.as_view(),
name="password-reset",
),
@@ -203,7 +204,12 @@ urlpatterns = [
path(
"users/me/onboard/",
UpdateUserOnBoardedEndpoint.as_view(),
name="change-password",
name="user-onboard",
),
path(
"users/me/tour-completed/",
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces
@@ -301,7 +307,6 @@ urlpatterns = [
{
"delete": "destroy",
"get": "retrieve",
"get": "retrieve",
}
),
name="workspace",
@@ -474,7 +479,6 @@ urlpatterns = [
"workspaces/<str:slug>/user-favorite-projects/",
ProjectFavoritesViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
@@ -542,30 +546,6 @@ urlpatterns = [
name="bulk-create-estimate-points",
),
# End Estimates ##
# Shortcuts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
ShortCutViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-shortcut",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/<uuid:pk>/",
ShortCutViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-shortcut",
),
## End Shortcuts
# Views
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
@@ -664,21 +644,6 @@ urlpatterns = [
CycleDateCheckEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/current-upcoming-cycles/",
CurrentUpcomingCyclesEndpoint.as_view(),
name="project-cycle-upcoming",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/completed-cycles/",
CompletedCyclesEndpoint.as_view(),
name="project-cycle-completed",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/draft-cycles/",
DraftCyclesEndpoint.as_view(),
name="project-cycle-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-cycles/",
CycleFavoriteViewSet.as_view(
@@ -703,11 +668,6 @@ urlpatterns = [
TransferCycleIssueEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/incomplete-cycles/",
InCompleteCyclesEndpoint.as_view(),
name="transfer-issues",
),
## End Cycles
# Issue
path(
@@ -848,30 +808,34 @@ urlpatterns = [
name="project-issue-comment",
),
## End IssueComments
## Roadmap
# Issue Subscribers
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/",
TimeLineIssueViewSet.as_view(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/",
IssueSubscriberViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
name="project-issue-subscribers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/<uuid:pk>/",
TimeLineIssueViewSet.as_view(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/<uuid:subscriber_id>/",
IssueSubscriberViewSet.as_view({"delete": "destroy"}),
name="project-issue-subscribers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/subscribe/",
IssueSubscriberViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "subscription_status",
"post": "subscribe",
"delete": "unsubscribe",
}
),
name="project-issue-roadmap",
name="project-issue-subscribers",
),
## End Roadmap
## End Issue Subscribers
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
@@ -896,6 +860,36 @@ urlpatterns = [
name="project-issue-roadmap",
),
## IssueProperty Ebd
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
IssueArchiveViewSet.as_view(
{
"get": "list",
}
),
name="project-issue-archive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="project-issue-archive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-issue-archive",
),
## End Issue Archives
## File Assets
path(
"workspaces/<str:slug>/file-assets/",
@@ -1077,26 +1071,6 @@ urlpatterns = [
CreateIssueFromPageBlockEndpoint.as_view(),
name="page-block-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/recent-pages/",
RecentPagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/favorite-pages/",
FavoritePagesEndpoint.as_view(),
name="recent-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/my-pages/",
MyPagesEndpoint.as_view(),
name="user-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/created-by-other-pages/",
CreatedbyOtherPagesEndpoint.as_view(),
name="created-by-other-pages",
),
## End Pages
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
@@ -1290,6 +1264,50 @@ urlpatterns = [
name="release-notes",
),
## End Release Notes
# Inbox
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
InboxViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:pk>/",
InboxViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssueViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssueViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
## End Inbox
# Analytics
path(
"workspaces/<str:slug>/analytics/",
@@ -1324,4 +1342,51 @@ urlpatterns = [
name="default-analytics",
),
## End Analytics
# Notification
path(
"workspaces/<str:slug>/users/notifications/",
NotificationViewSet.as_view(
{
"get": "list",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/",
NotificationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/read/",
NotificationViewSet.as_view(
{
"post": "mark_read",
"delete": "mark_unread",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/archive/",
NotificationViewSet.as_view(
{
"post": "archive",
"delete": "unarchive",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/unread/",
UnreadNotificationEndpoint.as_view(),
name="unread-notifications",
),
## End Notification
]

View File

@@ -16,6 +16,7 @@ from .project import (
from .people import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
)
@@ -43,18 +44,13 @@ from .workspace import (
WorkspaceThemeViewSet,
)
from .state import StateViewSet
from .shortcut import ShortCutViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
CycleDateCheckEndpoint,
CurrentUpcomingCyclesEndpoint,
CompletedCyclesEndpoint,
CycleFavoriteViewSet,
DraftCyclesEndpoint,
TransferCycleIssueEndpoint,
InCompleteCyclesEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
@@ -62,7 +58,6 @@ from .issue import (
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
TimeLineIssueViewSet,
IssuePropertyViewSet,
LabelViewSet,
BulkDeleteIssuesEndpoint,
@@ -71,6 +66,8 @@ from .issue import (
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
)
from .auth_extended import (
@@ -83,6 +80,7 @@ from .auth_extended import (
from .authentication import (
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
@@ -122,10 +120,6 @@ from .page import (
PageBlockViewSet,
PageFavoriteViewSet,
CreateIssueFromPageBlockEndpoint,
RecentPagesEndpoint,
FavoritePagesEndpoint,
MyPagesEndpoint,
CreatedbyOtherPagesEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
@@ -141,6 +135,8 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet
from .analytic import (
AnalyticsEndpoint,
AnalyticViewViewset,
@@ -148,3 +144,5 @@ from .analytic import (
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint

View File

@@ -3,6 +3,7 @@ from django.db.models import (
Count,
Sum,
F,
Q
)
from django.db.models.functions import ExtractMonth
@@ -40,7 +41,7 @@ class AnalyticsEndpoint(BaseAPIView):
segment = request.GET.get("segment", False)
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()
distribution = build_graph_plot(
@@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView):
colors = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug, project_id__in=filters.get("project__in")
).values(key, "color")
if filters.get("project__in", False)
else State.objects.filter(workspace__slug=slug).values(key, "color")
else State.objects.filter(~Q(name="Triage"), workspace__slug=slug).values(key, "color")
)
if x_axis in ["labels__name"] or segment in ["labels__name"]:
@@ -79,7 +81,7 @@ class AnalyticsEndpoint(BaseAPIView):
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
@@ -132,7 +134,7 @@ class SavedAnalyticEndpoint(BaseAPIView):
)
filter = analytic_view.query
queryset = Issue.objects.filter(**filter)
queryset = Issue.issue_objects.filter(**filter)
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
@@ -209,7 +211,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
try:
filters = issue_filters(request.GET, "GET")
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
total_issues = queryset.count()

View File

@@ -3,10 +3,10 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception
from django.conf import settings
# Module imports
from .base import BaseAPIView
from plane.db.models import FileAsset
from plane.db.models import FileAsset, Workspace
from plane.api.serializers import FileAssetSerializer
@@ -27,15 +27,13 @@ class FileAssetEndpoint(BaseAPIView):
try:
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
if request.user.last_workspace_id is None:
return Response(
{"error": "Workspace id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer.save(workspace_id=request.user.last_workspace_id)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Workspace.DoesNotExist:
return Response({"error": "Workspace does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(

View File

@@ -36,6 +36,99 @@ def get_tokens_for_user(user):
)
class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
try:
if not settings.ENABLE_SIGNUP:
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
{"error": "Both email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user already exists
if User.objects.filter(email=email).exists():
return Response(
{"error": "User with this email already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(email=email, username=uuid.uuid4().hex)
user.set_password(password)
# settings last actives for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
serialized_user = UserSerializer(user).data
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
@@ -63,108 +156,69 @@ class SignInEndpoint(BaseAPIView):
user = User.objects.filter(email=email).first()
# Sign up Process
if user is None:
user = User.objects.create(email=email, username=uuid.uuid4().hex)
user.set_password(password)
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last actives for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Sign up Process
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
serialized_user = UserSerializer(user).data
serialized_user = UserSerializer(user).data
access_token, refresh_token = get_tokens_for_user(user)
# settings last active for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
access_token, refresh_token = get_tokens_for_user(user)
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
)
"event_type": "SIGN_IN",
},
)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
return Response(data, status=status.HTTP_200_OK)
# Sign in Process
else:
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
serialized_user = UserSerializer(user).data
# settings last active for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
access_token, refresh_token = get_tokens_for_user(user)
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"user": serialized_user,
}
return Response(data, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -291,7 +345,7 @@ class MagicSignInEndpoint(BaseAPIView):
def post(self, request):
try:
user_token = request.data.get("token", "").strip().lower()
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False)
if not key or user_token == "":

View File

@@ -41,10 +41,12 @@ from plane.db.models import (
CycleFavorite,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
class CycleViewSet(BaseViewSet):
@@ -148,10 +150,149 @@ class CycleViewSet(BaseViewSet):
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only("name", "color", "id").distinct(),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
# All Cycles
if cycle_view == "all":
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
data = CycleSerializer(queryset, many=True).data
if len(data):
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("label_name")
)
data[0]["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
)
return Response(data, status=status.HTTP_200_OK)
# Upcoming Cycles
if cycle_view == "upcoming":
queryset = queryset.filter(start_date__gt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Completed Cycles
if cycle_view == "completed":
queryset = queryset.filter(end_date__lt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Draft Cycles
if cycle_view == "draft":
queryset = queryset.filter(
end_date=None,
start_date=None,
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
return Response(
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id):
try:
if (
@@ -213,6 +354,92 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk):
try:
queryset = self.get_queryset().get(pk=pk)
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("first_name", "last_name")
)
# Label Distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("label_name")
)
data = CycleSerializer(queryset).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.end_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk
)
return Response(
data,
status=status.HTTP_200_OK,
)
except Cycle.DoesNotExist:
return Response(
{"error": "Cycle Does not exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -254,7 +481,7 @@ class CycleIssueViewSet(BaseViewSet):
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id"))
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -278,9 +505,9 @@ class CycleIssueViewSet(BaseViewSet):
group_by = request.GET.get("group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(issue_cycle__cycle_id=cycle_id)
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -478,356 +705,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
)
class CurrentUpcomingCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
current_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__lte=timezone.now(),
end_date__gte=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
upcoming_cycle = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
start_date__gt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
return Response(
{
"current_cycle": CycleSerializer(current_cycle, many=True).data,
"upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).data,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CompletedCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
completed_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date__lt=timezone.now(),
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
return Response(
{
"completed_cycles": CycleSerializer(
completed_cycles, many=True
).data,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class DraftCyclesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
draft_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
end_date=None,
start_date=None,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.annotate(total_issues=Count("issue_cycle"))
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="completed"),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="cancelled"),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="unstarted"),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.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"),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(issue_cycle__issue__state__group="started"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
)
return Response(
{"draft_cycles": CycleSerializer(draft_cycles, many=True).data},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CycleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = CycleFavoriteSerializer
model = CycleFavorite
@@ -948,22 +826,3 @@ class TransferCycleIssueEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class InCompleteCyclesEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
cycles = Cycle.objects.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
workspace__slug=slug,
project_id=project_id,
).select_related("owned_by")
serializer = CycleSerializer(cycles, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -30,31 +30,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
count = 0
# If logger is enabled check for request limit
if settings.LOGGER_BASE_URL:
try:
headers = {
"Content-Type": "application/json",
}
response = requests.post(
settings.LOGGER_BASE_URL,
json={"user_id": str(request.user.id)},
headers=headers,
)
count = response.json().get("count", 0)
if not response.json().get("success", False):
return Response(
{
"error": "You have surpassed the monthly limit for AI assistance"
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except Exception as e:
capture_exception(e)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
@@ -67,7 +42,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
openai.api_key = settings.OPENAI_API_KEY
response = openai.Completion.create(
engine=settings.GPT_ENGINE,
model=settings.GPT_ENGINE,
prompt=final_text,
temperature=0.7,
max_tokens=1024,
@@ -82,7 +57,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
{
"response": text,
"response_html": text_html,
"count": count,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
},

View File

@@ -7,7 +7,7 @@ from rest_framework.response import Response
from sentry_sdk import capture_exception
# Django imports
from django.db.models import Max
from django.db.models import Max, Q
# Module imports
from plane.api.views import BaseAPIView
@@ -42,16 +42,34 @@ from plane.utils.html_processor import strip_tags
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
try:
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["access_tokens_url"]
owner = request.GET.get("owner")
repo = request.GET.get("repo")
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
@@ -239,17 +257,19 @@ class ImportServiceEndpoint(BaseAPIView):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
# Delete all imported Issues
imported_issues = importer.imported_data.get("issues", [])
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.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()
if importer.service == "jira":
imported_modules = importer.imported_data.get("modules", [])
Module.objects.filter(id__in=imported_modules).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)
except Exception as e:
@@ -307,11 +327,13 @@ class BulkImportIssuesEndpoint(BaseAPIView):
# Get the default state
default_state = State.objects.filter(
project_id=project_id, default=True
~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(project_id=project_id).first()
default_state = State.objects.filter(
~Q(name="Triage"), sproject_id=project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(

View File

@@ -0,0 +1,380 @@
# Python imports
import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Project,
Inbox,
InboxIssue,
Issue,
State,
IssueLink,
IssueAttachment,
ProjectMember,
)
from plane.api.serializers import (
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueStateInboxSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
class InboxViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = InboxSerializer
model = Inbox
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.annotate(
pending_issue_count=Count(
"issue_inbox",
filter=Q(issue_inbox__status=-2),
)
)
.select_related("workspace", "project")
)
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def destroy(self, request, slug, project_id, pk):
try:
inbox = Inbox.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Handle default inbox delete
if inbox.is_default:
return Response(
{"error": "You cannot delete the default inbox"},
status=status.HTTP_400_BAD_REQUEST,
)
inbox.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wronf please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class InboxIssueViewSet(BaseViewSet):
permission_classes = [
ProjectLitePermission,
]
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
def list(self, request, slug, project_id, inbox_id):
try:
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id, inbox_id):
try:
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low",
"medium",
"high",
"urgent",
None,
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.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,
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, inbox_id, pk):
try:
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
# Get issue data
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description)
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
)
issue_serializer.save()
else:
return Response(
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 10:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
state = State.objects.filter(
group="cancelled", workspace__slug=slug, project_id=project_id
).first()
if state is not None:
issue.state = state
issue.save()
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
# Update the issue state only if it is in triage state
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
).first()
if state is not None:
issue.state = state
issue.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
try:
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, inbox_id, pk):
try:
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -4,11 +4,25 @@ import random
from itertools import chain
# Django imports
from django.db.models import Prefetch, OuterRef, Func, F, Q, Count
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce
from django.conf import settings
# Third Party imports
from rest_framework.response import Response
@@ -22,7 +36,6 @@ from plane.api.serializers import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
TimeLineIssueSerializer,
IssuePropertySerializer,
LabelSerializer,
IssueSerializer,
@@ -31,23 +44,27 @@ from plane.api.serializers import (
IssueLinkSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
)
from plane.api.permissions import (
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
ProjectLitePermission,
)
from plane.db.models import (
Project,
Issue,
IssueActivity,
IssueComment,
TimelineIssue,
IssueProperty,
Label,
IssueLink,
IssueAttachment,
State,
IssueSubscriber,
ProjectMember,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -120,10 +137,8 @@ class IssueViewSet(BaseViewSet):
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -144,12 +159,17 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.order_by(request.GET.get("order_by", "created_at"))
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__id"))
.annotate(module_id=F("issue_module__id"))
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -166,6 +186,64 @@ class IssueViewSet(BaseViewSet):
)
)
# 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"
@@ -184,7 +262,7 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -219,7 +297,7 @@ class IssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
issue = Issue.issue_objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -234,9 +312,11 @@ class UserWorkSpaceIssues(BaseAPIView):
def get(self, request, slug):
try:
issues = (
Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug)
Issue.issue_objects.filter(
assignees__in=[request.user], workspace__slug=slug
)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -282,7 +362,7 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
def get(self, request, slug):
try:
issues = (
Issue.objects.filter(workspace__slug=slug)
Issue.issue_objects.filter(workspace__slug=slug)
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
)
@@ -416,39 +496,6 @@ class IssueCommentViewSet(BaseViewSet):
)
class TimeLineIssueViewSet(BaseViewSet):
serializer_class = TimeLineIssueSerializer
model = TimelineIssue
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__id",
"workspace__id",
]
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 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)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
class IssuePropertyViewSet(BaseViewSet):
serializer_class = IssuePropertySerializer
model = IssueProperty
@@ -552,7 +599,7 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
@@ -581,7 +628,7 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
Issue.objects.filter(
Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id
)
.select_related("project")
@@ -590,10 +637,32 @@ class SubIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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")
)
)
state_distribution = (
State.objects.filter(workspace__slug=slug, project_id=project_id)
State.objects.filter(
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
)
.annotate(
state_count=Count(
"state_issue",
@@ -627,7 +696,7 @@ class SubIssuesEndpoint(BaseAPIView):
# Assign multiple sub issues
def post(self, request, slug, project_id, issue_id):
try:
parent_issue = Issue.objects.get(pk=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):
@@ -636,14 +705,14 @@ class SubIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
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.objects.filter(id__in=sub_issue_ids)
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
return Response(
IssueFlatSerializer(updated_sub_issues, many=True).data,
@@ -842,3 +911,347 @@ class IssueAttachmentEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
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("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
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)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_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")
)
)
# 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)
)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
workspace__slug=slug,
project_id=project_id,
archived_at__isnull=False,
pk=pk,
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def unarchive(self, request, slug, project_id, pk=None):
try:
issue = Issue.objects.get(
workspace__slug=slug,
project_id=project_id,
archived_at__isnull=False,
pk=pk,
)
issue.archived_at = None
issue.save()
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=None,
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
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)
.order_by("-created_at")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
try:
members = ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
).annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
)
).select_related("member")
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": e},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, subscriber_id):
try:
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,
)
except IssueSubscriber.DoesNotExist:
return Response(
{"error": "User is not subscribed to this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def subscribe(self, request, slug, project_id, issue_id):
try:
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,
)
serilaizer = IssueSubscriberSerializer(subscriber)
return Response(serilaizer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def unsubscribe(self, request, slug, project_id, issue_id):
try:
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,
)
except IssueSubscriber.DoesNotExist:
return Response(
{"error": "User subscribed to this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def subscription_status(self, request, slug, project_id, issue_id):
try:
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)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -37,7 +37,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
class ModuleViewSet(BaseViewSet):
model = Module
@@ -160,6 +160,87 @@ class ModuleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, pk):
try:
queryset = self.get_queryset().get(pk=pk)
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("first_name", "last_name")
)
label_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("label_id"))
.annotate(
completed_issues=Count(
"label_id",
filter=Q(completed_at__isnull=False),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(completed_at__isnull=True),
)
)
.order_by("label_name")
)
data = ModuleSerializer(queryset).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if queryset.start_date and queryset.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset, slug=slug, project_id=project_id, module_id=pk
)
return Response(
data,
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
@@ -201,7 +282,7 @@ class ModuleIssueViewSet(BaseViewSet):
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue"))
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -226,9 +307,9 @@ class ModuleIssueViewSet(BaseViewSet):
group_by = request.GET.get("group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(issue_module__module_id=module_id)
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -399,9 +480,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite

View File

@@ -0,0 +1,258 @@
# Django imports
from django.db.models import Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
from plane.api.serializers import NotificationSerializer
class NotificationViewSet(BaseViewSet):
model = Notification
serializer_class = NotificationSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
receiver_id=self.request.user.id,
)
.select_related("workspace", "project," "triggered_by", "receiver")
)
def list(self, request, slug):
try:
snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false")
read = request.GET.get("read", "true")
# Filter type
type = request.GET.get("type", "all")
notifications = Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
).order_by("snoozed_till", "-created_at")
# Filter for snoozed notifications
if snoozed == "false":
notifications = notifications.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
)
if snoozed == "true":
notifications = notifications.filter(
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
)
if read == "false":
notifications = notifications.filter(read_at__isnull=True)
# Filter for archived or unarchive
if archived == "false":
notifications = notifications.filter(archived_at__isnull=True)
if archived == "true":
notifications = notifications.filter(archived_at__isnull=False)
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Created issues
if type == "created":
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, pk):
try:
notification = Notification.objects.get(
workspace__slug=slug, pk=pk, receiver=request.user
)
# Only read_at and snoozed_till can be updated
notification_data = {
"snoozed_till": request.data.get("snoozed_till", None),
}
serializer = NotificationSerializer(
notification, data=notification_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)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def mark_read(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.read_at = timezone.now()
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def mark_unread(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.read_at = None
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def archive(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.archived_at = timezone.now()
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def unarchive(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.archived_at = None
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug):
try:
# Watching Issues Count
watching_issues_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True),
).count()
# My Issues Count
my_issues_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True),
).count()
# Created Issues Count
created_issues_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True),
).count()
return Response(
{
"watching_issues": watching_issues_count,
"my_issues": my_issues_count,
"created_issues": created_issues_count,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -125,7 +125,57 @@ class PageViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def list(self, request, slug, project_id):
try:
queryset = self.get_queryset()
page_view = request.GET.get("page_view", False)
if not page_view:
return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
# All Pages
if page_view == "all":
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# Recent pages
if page_view == "recent":
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = queryset.filter(updated_at__date=date.today())
yesterdays_pages = queryset.filter(updated_at__date=day_before)
earlier_this_week = queryset.filter( updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
))
return Response(
{
"today": PageSerializer(todays_pages, many=True).data,
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
"earlier_this_week": PageSerializer(earlier_this_week, many=True).data,
},
status=status.HTTP_200_OK,
)
# Favorite Pages
if page_view == "favorite":
queryset = queryset.filter(is_favorite=True)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# My pages
if page_view == "created_by_me":
queryset = queryset.filter(owned_by=request.user)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
# Created by other Pages
if page_view == "created_by_other":
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK)
return Response({"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
class PageBlockViewSet(BaseViewSet):
serializer_class = PageBlockSerializer
@@ -269,249 +319,3 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class RecentPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = (
Page.objects.filter(
updated_at__date=date.today(),
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
yesterdays_pages = (
Page.objects.filter(
updated_at__date=day_before,
workspace__slug=slug,
project_id=project_id,
)
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
earlier_this_week = (
Page.objects.filter(
updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
),
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.annotate(is_favorite=Exists(subquery))
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "-updated_at")
)
todays_pages_serializer = PageSerializer(todays_pages, many=True)
yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True)
earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True)
return Response(
{
"today": todays_pages_serializer.data,
"yesterday": yesterday_pages_serializer.data,
"earlier_this_week": earlier_this_week_serializer.data,
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class FavoritePagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.filter(is_favorite=True)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("name", "-is_favorite")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class MyPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
workspace__slug=slug, project_id=project_id, owned_by=request.user
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.filter(Q(owned_by=self.request.user) | Q(access=0))
.filter(project__project_projectmember__member=request.user)
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CreatedbyOtherPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
subquery = PageFavorite.objects.filter(
user=request.user,
page_id=OuterRef("pk"),
project_id=project_id,
workspace__slug=slug,
)
pages = (
Page.objects.filter(
~Q(owned_by=request.user),
workspace__slug=slug,
project_id=project_id,
access=0,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.prefetch_related("labels")
.annotate(is_favorite=Exists(subquery))
.prefetch_related(
Prefetch(
"blocks",
queryset=PageBlock.objects.select_related(
"page", "issue", "workspace", "project"
),
)
)
.order_by("-is_favorite", "name")
)
serializer = PageSerializer(pages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -31,36 +31,71 @@ class UserEndpoint(BaseViewSet):
def retrieve(self, request):
try:
workspace = Workspace.objects.get(pk=request.user.last_workspace_id)
workspace = Workspace.objects.get(
pk=request.user.last_workspace_id, workspace_member__member=request.user
)
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
assigned_issues = Issue.issue_objects.filter(
assignees__in=[request.user]
).count()
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"last_workspace_id": request.user.last_workspace_id,
"last_workspace_slug": workspace.slug,
"fallback_workspace_id": request.user.last_workspace_id,
"fallback_workspace_slug": workspace.slug,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})[
"assigned_issues"
] = assigned_issues
return Response(
{
"user": UserSerializer(request.user).data,
"slug": workspace.slug,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
serialized_data,
status=status.HTTP_200_OK,
)
except Workspace.DoesNotExist:
# This exception will be hit even when the `last_workspace_id` is None
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=request.user.email
).count()
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
assigned_issues = Issue.issue_objects.filter(
assignees__in=[request.user]
).count()
fallback_workspace = (
Workspace.objects.filter(workspace_member__member=request.user)
.order_by("created_at")
.first()
)
serialized_data = UserSerializer(request.user).data
serialized_data["workspace"] = {
"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,
"invites": workspace_invites,
}
serialized_data.setdefault("issues", {})[
"assigned_issues"
] = assigned_issues
return Response(
{
"user": UserSerializer(request.user).data,
"slug": None,
"workspace_invites": workspace_invites,
"assigned_issues": assigned_issues,
},
serialized_data,
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -73,20 +108,23 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id)
user.is_onboarded = request.data.get("is_onboarded", False)
user.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
if user.last_workspace_id is not None:
user_role = WorkspaceMember.objects.filter(
workspace_id=user.last_workspace_id, member=request.user.id
).first()
return Response(
{
"message": "Updated successfully",
"role": user_role.company_role
if user_role is not None
else None,
},
status=status.HTTP_200_OK,
)
class UpdateUserTourCompletedEndpoint(BaseAPIView):
def patch(self, request):
try:
user = User.objects.get(pk=request.user.id)
user.is_tour_completed = request.data.get("is_tour_completed", False)
user.save()
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)

View File

@@ -37,18 +37,19 @@ from plane.db.models import (
State,
TeamMember,
ProjectFavorite,
ProjectIdentifier,
Module,
Cycle,
CycleFavorite,
ModuleFavorite,
PageFavorite,
IssueViewFavorite,
Page,
IssueAssignee,
ModuleMember,
Inbox,
)
from plane.db.models import (
Project,
ProjectMember,
Workspace,
ProjectMemberInvite,
User,
ProjectIdentifier,
Cycle,
Module,
)
from plane.bgtasks.project_invitation_task import project_invitation
@@ -71,6 +72,7 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
@@ -80,11 +82,21 @@ class ProjectViewSet(BaseViewSet):
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.annotate(is_favorite=Exists(subquery))
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct()
)
def list(self, request, slug):
try:
is_favorite = request.GET.get("is_favorite", "all")
subquery = ProjectFavorite.objects.filter(
user=self.request.user,
project_id=OuterRef("pk"),
@@ -115,6 +127,12 @@ class ProjectViewSet(BaseViewSet):
.values("count")
)
)
if is_favorite == "true":
projects = projects.filter(is_favorite=True)
if is_favorite == "false":
projects = projects.filter(is_favorite=False)
return Response(ProjectDetailSerializer(projects, many=True).data)
except Exception as e:
capture_exception(e)
@@ -133,41 +151,41 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
## Add the user as Administrator to the project
# Add the user as Administrator to the project
ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
## Default states
# Default states
states = [
{
"name": "Backlog",
"color": "#5e6ad2",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#eb5757",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#26b5ce",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#f2c94c",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#4cb782",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",
},
@@ -237,6 +255,20 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox", project=project, is_default=True
)
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=pk,
color="#ff7700",
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -373,7 +405,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
]
)
## Delete joined project invites
# Delete joined project invites
project_invitations.delete()
return Response(status=status.HTTP_200_OK)
@@ -411,14 +443,23 @@ class ProjectMemberViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
try:
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
if request.user.id == project_member.member_id:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
if request.data.get("role", 10) > project_member.role:
# Check while updating user roles
requested_project_member = ProjectMember.objects.get(
project_id=project_id, workspace__slug=slug, member=request.user
)
if (
"role" in request.data
and int(request.data.get("role", project_member.role))
> requested_project_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
@@ -441,8 +482,72 @@ class ProjectMemberViewSet(BaseViewSet):
)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, pk):
try:
project_member = ProjectMember.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# check requesting user role
requesting_project_member = ProjectMember.objects.get(
workspace__slug=slug, member=request.user, project_id=project_id
)
if requesting_project_member.role < project_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than yourself"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Remove all favorites
ProjectFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
CycleFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
ModuleFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
PageFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
IssueViewFavorite.objects.filter(
workspace__slug=slug, project_id=project_id, user=project_member.member
).delete()
# Also remove issue from issue assigned
IssueAssignee.objects.filter(
workspace__slug=slug,
project_id=project_id,
assignee=project_member.member,
).delete()
# Remove if module member
ModuleMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=project_member.member,
).delete()
# Delete owned Pages
Page.objects.filter(
workspace__slug=slug,
project_id=project_id,
owned_by=project_member.member,
).delete()
project_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except ProjectMember.DoesNotExist:
return Response(
{"error": "Project Member does not exist"}, status=status.HTTP_400
)
except Exception as e:
capture_exception(e)
return Response({"error": "Something went wrong please try again later"})
class AddMemberToProjectEndpoint(BaseAPIView):
@@ -452,45 +557,47 @@ class AddMemberToProjectEndpoint(BaseAPIView):
def post(self, request, slug, project_id):
try:
member_id = request.data.get("member_id", False)
role = request.data.get("role", False)
members = request.data.get("members", [])
if not member_id or not role:
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Member ID and role is required"},
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user is a member in the workspace
if not WorkspaceMember.objects.filter(
workspace__slug=slug, member_id=member_id
).exists():
# TODO: Update this error message - nk
return Response(
{
"error": "User is not a member of the workspace. Invite the user to the workspace to add him to project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user is already member of project
if ProjectMember.objects.filter(
project=project_id, member_id=member_id
).exists():
return Response(
{"error": "User is already a member of the project"},
status=status.HTTP_400_BAD_REQUEST,
)
# Add the user to project
project_member = ProjectMember.objects.create(
project_id=project_id, member_id=member_id, role=role
project_members = ProjectMember.objects.bulk_create(
[
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
)
for member in members
],
batch_size=10,
ignore_conflicts=True,
)
serializer = ProjectMemberSerializer(project_member)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except KeyError:
return Response(
{"error": "Incorrect data sent"}, status=status.HTTP_400_BAD_REQUEST
)
except Project.DoesNotExist:
return Response(
{"error": "Project does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
return Response(
{"error": "User not member of the workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(

View File

@@ -57,7 +57,7 @@ class GlobalSearchEndpoint(BaseAPIView):
else:
q |= Q(**{f"{field}__icontains": query})
return (
Issue.objects.filter(
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
@@ -206,28 +206,51 @@ class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
query = request.query_params.get("search", False)
parent = request.query_params.get("parent", False)
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
sub_issue = request.query_params.get("sub_issue", "false")
issue_id = request.query_params.get("issue_id", False)
issues = search_issues(query)
issues = issues.filter(
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
project__project_projectmember__member=self.request.user,
)
if query:
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issue = Issue.objects.get(pk=issue_id)
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.objects.filter(parent__isnull=False).values_list(
pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list(
"parent_id", flat=True
)
)
if blocker_blocked_by == "true" and issue_id:
issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(blocked_issues__block=issue),
~Q(blocker_issues__blocked_by=issue),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
return Response(
issues.values(
@@ -237,6 +260,9 @@ class IssueSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
"state__name",
"state__group",
"state__color",
),
status=status.HTTP_200_OK,
)
@@ -245,7 +271,7 @@ class IssueSearchEndpoint(BaseAPIView):
{"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,

View File

@@ -1,29 +0,0 @@
# Module imports
from . import BaseViewSet
from plane.api.serializers import ShortCutSerializer
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Shortcut
class ShortCutViewSet(BaseViewSet):
serializer_class = ShortCutSerializer
model = Shortcut
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_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(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.distinct()
)

View File

@@ -3,13 +3,13 @@ from itertools import groupby
# Django imports
from django.db import IntegrityError
from django.db.models import Q
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import StateSerializer
@@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
.select_related("project")
.select_related("workspace")
.distinct()
@@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
try:
state = State.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
~Q(name="Triage"),
pk=pk, project_id=project_id, workspace__slug=slug,
)
if state.default:
@@ -89,7 +91,7 @@ class StateViewSet(BaseViewSet):
)
# Check for any issues in the state
issue_exist = Issue.objects.filter(state=pk).exists()
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(

View File

@@ -67,7 +67,7 @@ class ViewIssuesEndpoint(BaseAPIView):
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
Issue.issue_objects.filter(
**queries, project_id=project_id, workspace__slug=slug
)
.filter(**filters)

View File

@@ -2,6 +2,7 @@
import jwt
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from uuid import uuid4
# Django imports
from django.db import IntegrityError
@@ -21,6 +22,7 @@ from django.db.models import (
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
from django.contrib.auth.hashers import make_password
# Third party modules
from rest_framework import status
@@ -50,6 +52,14 @@ from plane.db.models import (
IssueActivity,
Issue,
WorkspaceTheme,
IssueAssignee,
ProjectFavorite,
CycleFavorite,
ModuleMember,
ModuleFavorite,
PageFavorite,
Page,
IssueViewFavorite,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
@@ -72,14 +82,47 @@ class WorkSpaceViewSet(BaseViewSet):
lookup_field = "slug"
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().select_related("owner")
).order_by("name")
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
return (
self.filter_queryset(super().get_queryset().select_related("owner"))
.order_by("name")
.filter(workspace_member__member=self.request.user)
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.select_related("owner")
)
def create(self, request):
try:
serializer = WorkSpaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
if not name or not slug:
return Response(
{"error": "Both name and slug are required"},
status=status.HTTP_400_BAD_REQUEST,
)
if len(name) > 80 or len(slug) > 48:
return Response(
{"error": "The maximum length for name is 80 and for slug is 48"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid():
serializer.save(owner=request.user)
# Create Workspace member
@@ -131,15 +174,28 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.values("count")
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspace = (
Workspace.objects.prefetch_related(
Prefetch("workspace_member", queryset=WorkspaceMember.objects.all())
(
Workspace.objects.prefetch_related(
Prefetch(
"workspace_member", queryset=WorkspaceMember.objects.all()
)
)
.filter(
workspace_member__member=request.user,
)
.select_related("owner")
)
.filter(
workspace_member__member=request.user,
)
.select_related("owner")
).annotate(total_members=member_count)
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
)
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -187,6 +243,22 @@ class InviteWorkspaceEndpoint(BaseAPIView):
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST
)
# check for role level
requesting_user = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
if len(
[
email
for email in emails
if int(email.get("role", 10)) > requesting_user.role
]
):
return Response(
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
# Check if user is already a member of workspace
@@ -241,6 +313,21 @@ class InviteWorkspaceEndpoint(BaseAPIView):
email__in=[email.get("email") for email in emails]
).select_related("workspace")
# create the user if signup is disabled
if settings.DOCKERIZED and not settings.ENABLE_SIGNUP:
_ = User.objects.bulk_create(
[
User(
username=str(uuid4().hex),
email=invitation.email,
password=make_password(uuid4().hex),
is_password_autoset=True,
)
for invitation in workspace_invitations
],
batch_size=100,
)
for invitation in workspace_invitations:
workspace_invitation.delay(
invitation.email,
@@ -353,9 +440,33 @@ class WorkspaceInvitationsViewset(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "workspace__owner")
.select_related("workspace", "workspace__owner", "created_by")
)
def destroy(self, request, slug, pk):
try:
workspace_member_invite = WorkspaceMemberInvite.objects.get(
pk=pk, workspace__slug=slug
)
# delete the user if signup is disabled
if settings.DOCKERIZED and not settings.ENABLE_SIGNUP:
user = User.objects.filter(email=workspace_member_invite.email).first()
if user is not None:
user.delete()
workspace_member_invite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMemberInvite.DoesNotExist:
return Response(
{"error": "Workspace member invite does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UserWorkspaceInvitationsEndpoint(BaseViewSet):
serializer_class = WorkSpaceMemberInviteSerializer
@@ -366,7 +477,8 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace", "workspace__owner")
.select_related("workspace", "workspace__owner", "created_by")
.annotate(total_members=Count("workspace__workspace_member"))
)
def create(self, request):
@@ -432,7 +544,17 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if request.data.get("role", 10) > workspace_member.role:
# Get the requested user role
requested_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
# Check if role is being updated
# One cannot update role higher than his own role
if (
"role" in request.data
and int(request.data.get("role", workspace_member.role))
> requested_workspace_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
@@ -460,6 +582,69 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, pk):
try:
# Check the user role who is deleting the user
workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk)
# check requesting user role
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
if requesting_workspace_member.role < workspace_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the user also from all the projects
ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
).delete()
# Remove all favorites
ProjectFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
CycleFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
ModuleFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
PageFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
IssueViewFavorite.objects.filter(
workspace__slug=slug, user=workspace_member.member
).delete()
# Also remove issue from issue assigned
IssueAssignee.objects.filter(
workspace__slug=slug, assignee=workspace_member.member
).delete()
# Remove if module member
ModuleMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
).delete()
# Delete owned Pages
Page.objects.filter(
workspace__slug=slug, owned_by=workspace_member.member
).delete()
workspace_member.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace Member does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
@@ -662,7 +847,7 @@ class UserIssueCompletedGraphEndpoint(BaseAPIView):
month = request.GET.get("month", 1)
issues = (
Issue.objects.filter(
Issue.issue_objects.filter(
assignees__in=[request.user],
workspace__slug=slug,
completed_at__month=month,
@@ -707,7 +892,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
month = request.GET.get("month", 1)
completed_issues = (
Issue.objects.filter(
Issue.issue_objects.filter(
assignees__in=[request.user],
workspace__slug=slug,
completed_at__month=month,
@@ -720,24 +905,24 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
.order_by("week_in_month")
)
assigned_issues = Issue.objects.filter(
assigned_issues = Issue.issue_objects.filter(
workspace__slug=slug, assignees__in=[request.user]
).count()
pending_issues_count = Issue.objects.filter(
pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[request.user],
).count()
completed_issues_count = Issue.objects.filter(
completed_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[request.user],
state__group="completed",
).count()
issues_due_week = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[request.user],
)
@@ -747,14 +932,16 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
)
state_distribution = (
Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user])
Issue.issue_objects.filter(
workspace__slug=slug, assignees__in=[request.user]
)
.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
overdue_issues = Issue.objects.filter(
overdue_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[request.user],
@@ -762,7 +949,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
upcoming_issues = Issue.objects.filter(
upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(),
workspace__slug=slug,

View File

@@ -36,7 +36,7 @@ row_mapping = {
def analytic_export_task(email, data, slug):
try:
filters = issue_filters(data, "POST")
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug)
x_axis = data.get("x_axis", False)
y_axis = data.get("y_axis", False)
@@ -53,7 +53,7 @@ def analytic_export_task(email, data, slug):
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
@@ -169,6 +169,8 @@ def analytic_export_task(email, data, slug):
msg.send(fail_silently=False)
except Exception as e:
print(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -19,7 +19,7 @@ def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
@@ -39,5 +39,8 @@ def email_verification(first_name, email, token, current_site):
msg.send()
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -16,12 +16,12 @@ from plane.db.models import User
def forgot_password(first_name, email, uidb64, token, current_site):
try:
realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/"
abs_url = "http://" + current_site + realtivelink
realtivelink = f"/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!"
subject = f"Reset Your Password - Plane"
context = {
"first_name": first_name,
@@ -37,5 +37,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
msg.send()
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -175,5 +175,8 @@ def service_importer(service, importer_id):
importer = Importer.objects.get(pk=importer_id)
importer.status = "failed"
importer.save()
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -5,6 +5,7 @@ import requests
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
# Third Party imports
from celery import shared_task
@@ -20,6 +21,9 @@ from plane.db.models import (
State,
Cycle,
Module,
IssueSubscriber,
Notification,
IssueAssignee,
)
from plane.api.serializers import IssueActivitySerializer
@@ -44,7 +48,7 @@ def track_name(
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
)
)
@@ -554,6 +558,64 @@ def track_estimate_points(
)
def track_archive_at(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
if requested_data.get("archived_at") is None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} has restored the issue",
verb="updated",
actor=actor,
field="archived_at",
old_value="archive",
new_value="restore",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"Plane has archived the issue",
verb="updated",
actor=actor,
field="archived_at",
old_value=None,
new_value="archive",
)
)
def track_closed_to(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
if requested_data.get("closed_to") is not None:
updated_state = State.objects.get(
pk=requested_data.get("closed_to"), project=project
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=None,
new_value=updated_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"Plane updated the state to {updated_state.name}",
old_identifier=None,
new_identifier=updated_state.id,
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@@ -570,6 +632,8 @@ def update_issue_activity(
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
@@ -950,7 +1014,13 @@ def delete_attachment_activity(
# Receive message from room group
@shared_task
def issue_activity(
type, requested_data, current_instance, issue_id, actor_id, project_id
type,
requested_data,
current_instance,
issue_id,
actor_id,
project_id,
subscriber=True,
):
try:
issue_activities = []
@@ -958,6 +1028,27 @@ def issue_activity(
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
]:
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
if issue is not None:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber=actor
)
except Exception as e:
pass
ACTIVITY_MAPPER = {
"issue.activity.created": create_issue_activity,
"issue.activity.updated": update_issue_activity,
@@ -992,19 +1083,101 @@ def issue_activity(
# Post the updates to segway for integrations and webhooks
if len(issue_activities_created):
# Don't send activities if the actor is a bot
if settings.PROXY_BASE_URL:
try:
if settings.PROXY_BASE_URL:
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
json=issue_activity_json,
headers=headers,
)
except Exception as e:
capture_exception(e)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
issue_subscribers = list(
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
# Add bot filtering
if (
issue is not None
and issue.created_by_id is not None
and not issue.created_by.is_bot
and str(issue.created_by_id) != str(actor_id)
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
for subscriber in issue_subscribers:
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
json=issue_activity_json,
headers=headers,
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.id),
"verb": str(issue_activity.verb),
"field": str(issue_activity.field),
"actor": str(issue_activity.actor_id),
"new_value": str(issue_activity.new_value),
"old_value": str(issue_activity.old_value),
"issue_comment": str(
issue_activity.issue_comment.comment_stripped
if issue_activity.issue_comment is not None
else ""
),
},
},
)
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -0,0 +1,157 @@
# Python imports
import json
from datetime import timedelta
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
@shared_task
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
def archive_old_issues():
try:
# Get all the projects whose archive_in is greater than 0
projects = Project.objects.filter(archive_in__gt=0)
for project in projects:
project_id = project.id
archive_in = project.archive_in
# Get all the issues whose updated_at in less that the archive_in month
issues = Issue.objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)),
state__group__in=["completed", "cancelled"],
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(issue_module__module__target_date__lt=timezone.now().date())
& Q(issue_module__isnull=False)
),
).filter(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True)
)
# Check if Issues
if issues:
issues_to_update = []
for issue in issues:
issue.archived_at = timezone.now()
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
]
return
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
return
def close_old_issues():
try:
# Get all the projects whose close_in is greater than 0
projects = Project.objects.filter(close_in__gt=0).select_related(
"default_state"
)
for project in projects:
project_id = project.id
close_in = project.close_in
# Get all the issues whose updated_at in less that the close_in month
issues = Issue.objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)),
state__group__in=["backlog", "unstarted", "started"],
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(issue_module__module__target_date__lt=timezone.now().date())
& Q(issue_module__isnull=False)
),
).filter(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True)
)
# Check if Issues
if issues:
if project.default_state is None:
close_state = State.objects.filter(group="cancelled").first()
else:
close_state = project.default_state
issues_to_update = []
for issue in issues:
issue.state = close_state
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
]
return
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -13,7 +13,7 @@ from sentry_sdk import capture_exception
def magic_link(email, key, token, current_site):
try:
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
@@ -31,4 +31,7 @@ def magic_link(email, key, token, current_site):
return
except Exception as e:
capture_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
return

View File

@@ -21,7 +21,7 @@ def project_invitation(email, project_id, token, current_site):
)
relativelink = f"/project-member-invitation/{project_member_invite.id}"
abs_url = "http://" + current_site + relativelink
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM
@@ -50,5 +50,8 @@ def project_invitation(email, project_id, token, current_site):
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e:
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -29,5 +29,8 @@ def send_welcome_slack(user_id, created, message):
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -23,9 +23,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
realtivelink = (
f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}"
f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}"
)
abs_url = "http://" + current_site + realtivelink
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
@@ -66,5 +66,8 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@@ -1,6 +1,7 @@
import os
from celery import Celery
from plane.settings.redis import redis_instance
from celery.schedules import crontab
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
@@ -13,5 +14,15 @@ app = Celery("plane")
# pickle the object when using Windows.
app.config_from_object("django.conf:settings", namespace="CELERY")
app.conf.beat_schedule = {
# Executes every day at 12 AM
"check-every-day-to-archive-and-close": {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'

View File

@@ -0,0 +1,83 @@
# Generated by Django 3.2.19 on 2023-06-18 15:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0032_auto_20230520_2015'),
]
operations = [
migrations.CreateModel(
name='Inbox',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, verbose_name='Inbox Description')),
('is_default', models.BooleanField(default=False)),
('view_props', models.JSONField(default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
],
options={
'verbose_name': 'Inbox',
'verbose_name_plural': 'Inboxes',
'db_table': 'inboxes',
'ordering': ('name',),
},
),
migrations.AddField(
model_name='project',
name='inbox_view',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='InboxIssue',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('status', models.IntegerField(choices=[(-2, 'Pending'), (-1, 'Rejected'), (0, 'Snoozed'), (1, 'Accepted'), (2, 'Duplicate')], default=-2)),
('snoozed_till', models.DateTimeField(null=True)),
('source', models.TextField(blank=True, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('duplicate_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_duplicate', to='db.issue')),
('inbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.inbox')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inboxissue', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inboxissue', to='db.workspace')),
],
options={
'verbose_name': 'InboxIssue',
'verbose_name_plural': 'InboxIssues',
'db_table': 'inbox_issues',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='inbox',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inbox', to='db.project'),
),
migrations.AddField(
model_name='inbox',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AddField(
model_name='inbox',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inbox', to='db.workspace'),
),
migrations.AlterUniqueTogether(
name='inbox',
unique_together={('name', 'project')},
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 3.2.19 on 2023-06-28 05:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0033_auto_20230618_2125'),
]
operations = [
migrations.RemoveField(
model_name='timelineissue',
name='created_by',
),
migrations.RemoveField(
model_name='timelineissue',
name='issue',
),
migrations.RemoveField(
model_name='timelineissue',
name='project',
),
migrations.RemoveField(
model_name='timelineissue',
name='updated_by',
),
migrations.RemoveField(
model_name='timelineissue',
name='workspace',
),
migrations.DeleteModel(
name='Shortcut',
),
migrations.DeleteModel(
name='TimelineIssue',
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 3.2.19 on 2023-07-04 16:55
from django.db import migrations, models
def update_company_organization_size(apps, schema_editor):
Model = apps.get_model("db", "Workspace")
updated_size = []
for obj in Model.objects.all():
obj.organization_size = str(obj.company_size)
updated_size.append(obj)
Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0034_auto_20230628_1046"),
]
operations = [
migrations.AddField(
model_name="workspace",
name="organization_size",
field=models.CharField(default="2-10", max_length=20),
),
migrations.RunPython(update_company_organization_size),
migrations.AlterField(
model_name="workspace",
name="name",
field=models.CharField(max_length=80, verbose_name="Workspace Name"),
),
migrations.AlterField(
model_name="workspace",
name="slug",
field=models.SlugField(max_length=48, unique=True),
),
migrations.RemoveField(
model_name="workspace",
name="company_size",
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.19 on 2023-07-05 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0035_auto_20230704_2225'),
]
operations = [
migrations.AlterField(
model_name='workspace',
name='organization_size',
field=models.CharField(max_length=20),
),
]

View File

@@ -0,0 +1,266 @@
# Generated by Django 4.2.3 on 2023-07-19 06:52
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.user
import uuid
def onboarding_default_steps(apps, schema_editor):
default_onboarding_schema = {
"workspace_join": True,
"profile_complete": True,
"workspace_create": True,
"workspace_invite": True,
}
Model = apps.get_model("db", "User")
updated_user = []
for obj in Model.objects.filter(is_onboarded=True):
obj.onboarding_step = default_onboarding_schema
obj.is_tour_completed = True
updated_user.append(obj)
Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0036_alter_workspace_organization_size"),
]
operations = [
migrations.AddField(
model_name="issue",
name="archived_at",
field=models.DateField(null=True),
),
migrations.AddField(
model_name="project",
name="archive_in",
field=models.IntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(12),
],
),
),
migrations.AddField(
model_name="project",
name="close_in",
field=models.IntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(12),
],
),
),
migrations.AddField(
model_name="project",
name="default_state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="default_state",
to="db.state",
),
),
migrations.AddField(
model_name="user",
name="is_tour_completed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="onboarding_step",
field=models.JSONField(default=plane.db.models.user.get_default_onboarding),
),
migrations.RunPython(onboarding_default_steps),
migrations.CreateModel(
name="Notification",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("data", models.JSONField(null=True)),
("entity_identifier", models.UUIDField(null=True)),
("entity_name", models.CharField(max_length=255)),
("title", models.TextField()),
("message", models.JSONField(null=True)),
("message_html", models.TextField(blank=True, default="<p></p>")),
("message_stripped", models.TextField(blank=True, null=True)),
("sender", models.CharField(max_length=255)),
("read_at", models.DateTimeField(null=True)),
("snoozed_till", models.DateTimeField(null=True)),
("archived_at", models.DateTimeField(null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="db.project",
),
),
(
"receiver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="received_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"triggered_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="triggered_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="db.workspace",
),
),
],
options={
"verbose_name": "Notification",
"verbose_name_plural": "Notifications",
"db_table": "notifications",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="IssueSubscriber",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_subscribers",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"subscriber",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_subscribers",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Issue Subscriber",
"verbose_name_plural": "Issue Subscribers",
"db_table": "issue_subscribers",
"ordering": ("-created_at",),
"unique_together": {("issue", "subscriber")},
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 4.2.3 on 2023-07-20 09:35
from django.db import migrations, models
def restructure_theming(apps, schema_editor):
Model = apps.get_model("db", "User")
updated_user = []
for obj in Model.objects.exclude(theme={}).all():
current_theme = obj.theme
updated_theme = {
"primary": current_theme.get("accent", ""),
"background": current_theme.get("bgBase", ""),
"sidebarBackground": current_theme.get("sidebar", ""),
"text": current_theme.get("textBase", ""),
"sidebarText": current_theme.get("textBase", ""),
"palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""",
"darkPalette": current_theme.get("darkPalette", "")
}
obj.theme = updated_theme
updated_user.append(obj)
Model.objects.bulk_update(
updated_user, ["theme"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
("db", "0037_issue_archived_at_project_archive_in_and_more"),
]
operations = [
migrations.RunPython(restructure_theming)
]

View File

@@ -23,7 +23,6 @@ from .project import (
from .issue import (
Issue,
IssueActivity,
TimelineIssue,
IssueProperty,
IssueComment,
IssueBlocker,
@@ -34,6 +33,7 @@ from .issue import (
IssueLink,
IssueSequence,
IssueAttachment,
IssueSubscriber,
)
from .asset import FileAsset
@@ -44,8 +44,6 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite
from .shortcut import Shortcut
from .view import IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite
@@ -68,4 +66,8 @@ from .page import Page, PageBlock, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint
from .analytic import AnalyticView
from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification

View File

@@ -4,6 +4,7 @@ from uuid import uuid4
# Django import
from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
# Module import
from . import BaseModel
@@ -16,8 +17,7 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")

View File

@@ -0,0 +1,51 @@
# Django imports
from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
class Inbox(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(verbose_name="Inbox Description", blank=True)
is_default = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the Inbox"""
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
verbose_name = "Inbox"
verbose_name_plural = "Inboxes"
db_table = "inboxes"
ordering = ("name",)
class InboxIssue(ProjectBaseModel):
inbox = models.ForeignKey(
"db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE
)
issue = models.ForeignKey(
"db.Issue", related_name="issue_inbox", on_delete=models.CASCADE
)
status = models.IntegerField(
choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")),
default=-2,
)
snoozed_till = models.DateTimeField(null=True)
duplicate_to = models.ForeignKey(
"db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True
)
source = models.TextField(blank=True, null=True)
class Meta:
verbose_name = "InboxIssue"
verbose_name_plural = "InboxIssues"
db_table = "inbox_issues"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the Issue"""
return f"{self.issue.name} <{self.inbox.name}>"

View File

@@ -17,6 +17,21 @@ from plane.utils.html_processor import strip_tags
# TODO: Handle identifiers for Bulk Inserts - nk
class IssueManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
models.Q(issue_inbox__status=1)
| models.Q(issue_inbox__status=-1)
| models.Q(issue_inbox__status=2)
| models.Q(issue_inbox__isnull=True)
)
.exclude(archived_at__isnull=False)
)
class Issue(ProjectBaseModel):
PRIORITY_CHOICES = (
("urgent", "Urgent"),
@@ -67,6 +82,10 @@ class Issue(ProjectBaseModel):
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
objects = models.Manager()
issue_objects = IssueManager()
class Meta:
verbose_name = "Issue"
@@ -81,11 +100,13 @@ class Issue(ProjectBaseModel):
from plane.db.models import State
default_state = State.objects.filter(
project=self.project, default=True
~models.Q(name="Triage"), project=self.project, default=True
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(project=self.project).first()
random_state = State.objects.filter(
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
@@ -210,8 +231,8 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
# File limit check is only for cloud hosted
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")
@@ -276,24 +297,6 @@ class IssueActivity(ProjectBaseModel):
return str(self.issue)
class TimelineIssue(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_timeline"
)
sequence_id = models.FloatField(default=1.0)
links = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Timeline Issue"
verbose_name_plural = "Timeline Issues"
db_table = "issue_timelines"
ordering = ("-created_at",)
def __str__(self):
"""Return project of the project member"""
return str(self.issue)
class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, default=dict)
@@ -400,6 +403,27 @@ class IssueSequence(ProjectBaseModel):
ordering = ("-created_at",)
class IssueSubscriber(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_subscribers"
)
subscriber = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_subscribers",
)
class Meta:
unique_together = ["issue", "subscriber"]
verbose_name = "Issue Subscriber"
verbose_name_plural = "Issue Subscribers"
db_table = "issue_subscribers"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.subscriber.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):

View File

@@ -0,0 +1,37 @@
# Django imports
from django.db import models
# Third party imports
from .base import BaseModel
class Notification(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="notifications", on_delete=models.CASCADE
)
project = models.ForeignKey(
"db.Project", related_name="notifications", on_delete=models.CASCADE, null=True
)
data = models.JSONField(null=True)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=255)
title = models.TextField()
message = models.JSONField(null=True)
message_html = models.TextField(blank=True, default="<p></p>")
message_stripped = models.TextField(blank=True, null=True)
sender = models.CharField(max_length=255)
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
read_at = models.DateTimeField(null=True)
snoozed_till = models.DateTimeField(null=True)
archived_at = models.DateTimeField(null=True)
class Meta:
verbose_name = "Notification"
verbose_name_plural = "Notifications"
db_table = "notifications"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>"

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.template.defaultfilters import slugify
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
# Modeule imports
from plane.db.mixins import AuditModel
@@ -69,10 +70,20 @@ class Project(BaseModel):
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
)
archive_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
close_in = models.IntegerField(
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
default_state = models.ForeignKey(
"db.State", on_delete=models.SET_NULL, null=True, related_name="default_state"
)
def __str__(self):
"""Return name of the project"""

View File

@@ -1,26 +0,0 @@
# Django imports
from django.db import models
# Module imports
from . import ProjectBaseModel
class Shortcut(ProjectBaseModel):
TYPE_CHOICES = (("repo", "Repo"), ("direct", "Direct"))
name = models.CharField(max_length=255, verbose_name="Cycle Name")
description = models.TextField(verbose_name="Cycle Description", blank=True)
type = models.CharField(
max_length=255, verbose_name="Shortcut Type", choices=TYPE_CHOICES
)
url = models.URLField(verbose_name="URL", blank=True, null=True)
class Meta:
verbose_name = "Shortcut"
verbose_name_plural = "Shortcuts"
db_table = "shortcuts"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the shortcut"""
return f"{self.name} <{self.project.name}>"

View File

@@ -18,6 +18,13 @@ from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
return {
"profile_complete": False,
"workspace_create": False,
"workspace_invite": False,
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
@@ -73,6 +80,8 @@ class User(AbstractBaseUser, PermissionsMixin):
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
USERNAME_FIELD = "email"

View File

@@ -15,15 +15,15 @@ ROLE_CHOICES = (
class Workspace(BaseModel):
name = models.CharField(max_length=255, verbose_name="Workspace Name")
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="owner_workspace",
)
slug = models.SlugField(max_length=100, db_index=True, unique=True)
company_size = models.PositiveIntegerField(default=10)
slug = models.SlugField(max_length=48, db_index=True, unique=True)
organization_size = models.CharField(max_length=20)
def __str__(self):
"""Return name of the Workspace"""

View File

@@ -35,6 +35,7 @@ INSTALLED_APPS = [
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"taggit",
"django_celery_beat",
]
MIDDLEWARE = [
@@ -179,6 +180,7 @@ EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1"
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1"
EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>")
@@ -212,3 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)

View File

@@ -10,22 +10,26 @@ from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
DEBUG = True
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": "plane",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("PGUSER", "plane"),
"USER": "",
"PASSWORD": "",
"HOST": "",
"HOST": os.environ.get("PGHOST", "localhost"),
}
}
DOCKERIZED = os.environ.get("DOCKERIZED", False)
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
if DOCKERIZED:
DATABASES["default"] = dj_database_url.config()
@@ -55,7 +59,29 @@ if os.environ.get("SENTRY_DSN", False):
send_default_pii=True,
environment="local",
traces_sample_rate=0.7,
profiles_sample_rate=1.0,
)
else:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
"loggers": {
"*": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
},
}
REDIS_HOST = "localhost"
REDIS_PORT = 6379
@@ -68,14 +94,15 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
if DOCKERIZED:
REDIS_URL = os.environ.get("REDIS_URL")
WEB_URL = os.environ.get("WEB_URL", "localhost:3000")
WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
@@ -84,5 +111,6 @@ LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"

View File

@@ -13,10 +13,11 @@ from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
# Database
DEBUG = False
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
@@ -29,9 +30,12 @@ DATABASES = {
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
# Set the variable true if running in docker environment
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@@ -66,10 +70,14 @@ CORS_ALLOW_HEADERS = [
]
CORS_ALLOW_CREDENTIALS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
if os.environ.get("SENTRY_DSN", False):
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
if bool(os.environ.get("SENTRY_DSN", False)):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[DjangoIntegration(), RedisIntegration()],
@@ -78,14 +86,32 @@ if os.environ.get("SENTRY_DSN", False):
traces_sample_rate=1,
send_default_pii=True,
environment="production",
profiles_sample_rate=1.0,
)
if (
os.environ.get("AWS_REGION", False)
and os.environ.get("AWS_ACCESS_KEY_ID", False)
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
and os.environ.get("AWS_S3_BUCKET_NAME", False)
):
if DOCKERIZED and USE_MINIO:
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
else:
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
@@ -99,7 +125,7 @@ if (
# AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
@@ -166,14 +192,11 @@ if (
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@@ -187,9 +210,6 @@ ALLOWED_HOSTS = [
]
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
@@ -218,22 +238,17 @@ else:
}
}
RQ_QUEUES = {
"default": {
"USE_REDIS_CACHE": "default",
}
}
WEB_URL = os.environ.get("WEB_URL")
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
@@ -252,3 +267,6 @@ else:
CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"

View File

@@ -11,15 +11,16 @@ from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
# Database
DEBUG = True
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": "plane",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("PGUSER", "plane"),
"USER": "",
"PASSWORD": "",
"HOST": "",
"HOST": os.environ.get("PGHOST", "localhost"),
}
}
@@ -46,10 +47,18 @@ ALLOWED_HOSTS = ["*"]
# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD.
CORS_ALLOW_ALL_ORIGINS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Make true if running in a docker environment
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[DjangoIntegration(), RedisIntegration()],
@@ -58,6 +67,7 @@ sentry_sdk.init(
traces_sample_rate=1,
send_default_pii=True,
environment="staging",
profiles_sample_rate=1.0,
)
# The AWS region to connect to.
@@ -142,7 +152,9 @@ AWS_S3_SIGNATURE_VERSION = None
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@@ -155,17 +167,11 @@ ALLOWED_HOSTS = [
"*",
]
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL")
DOCKERIZED = os.environ.get("DOCKERIZED", False)
CACHES = {
"default": {
@@ -192,17 +198,23 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
broker_url = (
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
)
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"

View File

@@ -3,11 +3,10 @@
"""
# from django.contrib import admin
from django.urls import path
from django.urls import path, include, re_path
from django.views.generic import TemplateView
from django.conf import settings
from django.conf.urls import include, url
# from django.conf.urls.static import static
@@ -17,13 +16,11 @@ urlpatterns = [
path("api/", include("plane.api.urls")),
path("", include("plane.web.urls")),
]
# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
url(r"^__debug__/", include(debug_toolbar.urls)),
re_path(r"^__debug__/", include(debug_toolbar.urls)),
] + urlpatterns

View File

@@ -1,11 +1,16 @@
# Python imports
from itertools import groupby
from datetime import timedelta
# Django import
from django.db import models
from django.db.models.functions import TruncDate
from django.db.models import Count, F, Sum, Value, Case, When, CharField
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
# Module imports
from plane.db.models import Issue
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
@@ -74,3 +79,69 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
else:
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
return sorted_data
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
# Total Issues in Cycle or Module
total_issues = queryset.total_issues
if cycle_id:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range((queryset.end_date - queryset.start_date).days + 1)
]
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
if module_id:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range((queryset.target_date - queryset.start_date).days + 1)
]
chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = (
Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
for date in date_range:
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
[
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
]
)
cumulative_pending_issues -= total_completed
chart_data[str(date)] = cumulative_pending_issues
return chart_data

View File

@@ -113,7 +113,7 @@ def get_github_repo_details(access_tokens_url, owner, repo):
last_url = labels_response.links.get("last").get("url")
parsed_url = urlparse(last_url)
last_page_value = parse_qs(parsed_url.query)["page"][0]
total_labels = total_labels + 100 * (last_page_value - 1)
total_labels = total_labels + 100 * (int(last_page_value) - 1)
# Get labels in last page
last_page_labels = requests.get(last_url, headers=headers).json()

View File

@@ -1,7 +1,6 @@
from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
@@ -26,12 +25,27 @@ def filter_estimate_point(params, filter, method):
def filter_priority(params, filter, method):
if method == "GET":
priorties = params.get("priority").split(",")
if len(priorties) and "" not in priorties:
filter["priority__in"] = priorties
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority")
priorities = params.get("priority")
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
return filter
@@ -152,16 +166,16 @@ def filter_target_date(params, filter, method):
for query in target_dates:
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gte"] = target_date_query[0]
filter["target_date__gt"] = target_date_query[0]
else:
filter["target_date__lte"] = target_date_query[0]
filter["target_date__lt"] = target_date_query[0]
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
if query.get("timeline", "after") == "after":
filter["target_date__gte"] = query.get("datetime")
filter["target_date__gt"] = query.get("datetime")
else:
filter["target_date__lte"] = query.get("datetime")
filter["target_date__lt"] = query.get("datetime")
return filter
@@ -231,6 +245,29 @@ def filter_module(params, filter, method):
return filter
def filter_inbox_status(params, filter, method):
if method == "GET":
status = params.get("inbox_status").split(",")
if len(status) and "" not in status:
filter["issue_inbox__status__in"] = status
else:
if params.get("inbox_status", None) and len(params.get("inbox_status")):
filter["issue_inbox__status__in"] = params.get("inbox_status")
return filter
def filter_sub_issue_toggle(params, filter, method):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter["parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter["parent__isnull"] = True
return filter
def issue_filters(query_params, method):
filter = dict()
@@ -252,6 +289,8 @@ def issue_filters(query_params, method):
"project": filter_project,
"cycle": filter_cycle,
"module": filter_module,
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
}
for key, value in ISSUE_FILTER.items():

View File

@@ -8,7 +8,7 @@ from django.db.models import Q
from plane.db.models import Issue
def search_issues(query):
def search_issues(query, queryset):
fields = ["name", "sequence_id"]
q = Q()
for field in fields:
@@ -18,6 +18,6 @@ def search_issues(query):
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
return Issue.objects.filter(
return queryset.filter(
q,
).distinct()

View File

@@ -1,31 +1,34 @@
# base requirements
Django==3.2.19
Django==4.2.3
django-braces==1.15.0
django-taggit==3.1.0
psycopg2==2.9.5
django-oauth-toolkit==2.2.0
mistune==2.0.4
django-taggit==4.0.0
psycopg==3.1.9
django-oauth-toolkit==2.3.0
mistune==3.0.1
djangorestframework==3.14.0
redis==4.5.4
redis==4.6.0
django-nested-admin==4.0.2
django-cors-headers==3.13.0
whitenoise==6.3.0
django-allauth==0.52.0
faker==13.4.0
django-filter==22.1
django-cors-headers==4.1.0
whitenoise==6.5.0
django-allauth==0.54.0
faker==18.11.2
django-filter==23.2
jsonmodels==2.6.0
djangorestframework-simplejwt==5.2.2
sentry-sdk==1.14.0
django-s3-storage==0.13.11
sentry-sdk==1.27.0
django-s3-storage==0.14.0
django-crum==0.7.9
django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.16.0
google-api-python-client==2.75.0
django-redis==5.2.0
uvicorn==0.20.0
google-auth==2.21.0
google-api-python-client==2.92.0
django-redis==5.3.0
uvicorn==0.22.0
channels==4.0.0
openai==0.27.2
slack-sdk==3.20.2
celery==5.2.7
openai==0.27.8
slack-sdk==3.21.3
celery==5.3.1
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9

View File

@@ -1,3 +1,3 @@
-r base.txt
django-debug-toolbar==3.8.1
django-debug-toolbar==4.1.0

View File

@@ -1,12 +1,11 @@
-r base.txt
dj-database-url==1.2.0
dj-database-url==2.0.0
gunicorn==20.1.0
whitenoise==6.3.0
whitenoise==6.5.0
django-storages==1.13.2
boto==2.49.0
django-anymail==9.0
twilio==7.16.2
django-debug-toolbar==3.8.1
gevent==22.10.2
boto3==1.27.0
django-anymail==10.0
django-debug-toolbar==4.1.0
gevent==23.7.0
psycogreen==1.0.2

View File

@@ -1 +1 @@
python-3.11.3
python-3.11.4

View File

@@ -1,11 +1,21 @@
<!DOCTYPE html>
<html>
<p>
<body>
<p>
Dear {{first_name}},<br /><br />
Welcome! Your account has been created.
Verify your email by clicking on the link below <br />
{{forgot_password_url}}
successfully.<br /><br />
</p>
We received a request to reset your password for your Plane account.
<br /><br />
To proceed with resetting your password, please click on the link below:
<br />
<a href="{{forgot_password_url}}">{{forgot_password_url}}</a>
<br /><br />
If you didn't request to reset your password, please ignore this email. Your account will remain secure.
<br /><br />
If you have any questions or need further assistance, please contact our support team.
<br /><br />
Thank you for using Plane.
</p>
</body>
</html>

View File

@@ -37,6 +37,14 @@
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_FROM": {
"description": "Email Sender",
"value": ""
},
"EMAIL_PORT": {
"description": "The default Email PORT to use",
"value": "587"
},
"AWS_REGION": {
"description": "AWS Region to use for S3",
"value": "false"
@@ -49,30 +57,22 @@
"description": "AWS Secret Access Key to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"AWS_S3_BUCKET_NAME": {
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane",
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_GITHUB_ID": {
"description": "Next Public Github ID",
"value": ""
},
"NEXT_PUBLIC_GOOGLE_CLIENTID": {
"description": "Next Public Google Client ID",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
"description": "Next Public API Base URL",
"value": ""

View File

@@ -1,4 +1,7 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
};

View File

@@ -1,6 +1,5 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app

View File

@@ -1,6 +1,5 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
@@ -14,7 +13,6 @@ RUN turbo prune --scope=app --docker
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
@@ -22,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
RUN yarn install --network-timeout 500000
# Build the project
COPY --from=builder /app/out/full/ .

View File

@@ -16,11 +16,12 @@ type EmailCodeFormValues = {
token?: string;
};
export const EmailCodeForm = ({ onSuccess }: any) => {
export const EmailCodeForm = ({ handleSignIn }: any) => {
const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
const [errorResendingCode, setErrorResendingCode] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { setToastAlert } = useToast();
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
@@ -31,6 +32,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
setError,
setValue,
getValues,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({
defaultValues: {
@@ -64,12 +66,14 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
};
const handleSignin = async (formData: EmailCodeFormValues) => {
setIsLoading(true);
await authenticationService
.magicSignIn(formData)
.then((response) => {
onSuccess(response);
handleSignIn(response);
})
.catch((error) => {
setIsLoading(false);
setToastAlert({
title: "Oops!",
type: "error",
@@ -77,7 +81,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error.error,
message: error?.error,
});
});
};
@@ -88,45 +92,56 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
setErrorResendingCode(false);
}, [emailOld]);
useEffect(() => {
const submitForm = (e: KeyboardEvent) => {
if (!codeSent && e.key === "Enter") {
e.preventDefault();
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}
};
if (!codeSent) {
window.addEventListener("keydown", submitForm);
}
return () => {
window.removeEventListener("keydown", submitForm);
};
}, [handleSubmit, codeSent]);
return (
<>
<form className="space-y-5 py-5 px-5">
{(codeSent || codeResent) && (
<div className="rounded-md bg-green-500/20 p-4">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-5 w-5 text-green-500" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-green-500">
{codeResent
? "Please check your mail for new code."
: "Please check your mail for code."}
</p>
</div>
</div>
</div>
)}
<div>
{(codeSent || codeResent) && (
<p className="text-center mt-4">
We have sent the sign in code.
<br />
Please check your inbox at <span className="font-medium">{watch("email")}</span>
</p>
)}
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
{codeSent && (
<div>
<>
<Input
id="token"
type="token"
@@ -136,14 +151,15 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
required: "Code is required",
}}
error={errors.token}
placeholder="Enter code"
placeholder="Enter code..."
className="border-custom-border-300 h-[46px]"
/>
<button
type="button"
className={`mt-5 flex w-full justify-end text-xs outline-none ${
className={`flex w-full justify-end text-xs outline-none ${
isResendDisabled
? "cursor-default text-brand-secondary"
: "cursor-pointer text-brand-accent"
? "cursor-default text-custom-text-200"
: "cursor-pointer text-custom-primary-100"
} `}
onClick={() => {
setIsCodeResending(true);
@@ -156,46 +172,43 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
disabled={isResendDisabled}
>
{resendCodeTimer > 0 ? (
<p className="text-right">
Didn{"'"}t receive code? Get new code in {resendCodeTimer} seconds.
</p>
<span className="text-right">Request new code in {resendCodeTimer} seconds</span>
) : isCodeResending ? (
"Sending code..."
"Sending new code..."
) : errorResendingCode ? (
"Please try again later"
) : (
"Resend code"
<span className="font-medium">Resend code</span>
)}
</button>
</div>
</>
)}
{codeSent ? (
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
loading={isLoading}
>
{isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton>
) : (
<PrimaryButton
className="w-full text-center h-[46px]"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Sending code..." : "Send sign in code"}
</PrimaryButton>
)}
<div>
{codeSent ? (
<PrimaryButton
type="submit"
className="w-full text-center"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
) : (
<PrimaryButton
className="w-full text-center"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
});
}}
loading={isSubmitting || (!isValid && isDirty)}
>
{isSubmitting ? "Sending code..." : "Send code"}
</PrimaryButton>
)}
</div>
</form>
</>
);

View File

@@ -1,15 +1,14 @@
import React from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react hook form
import { useForm } from "react-hook-form";
// services
import authenticationService from "services/authentication.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmailResetPasswordForm } from "components/account";
// ui
import { Input, SecondaryButton } from "components/ui";
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
email: string;
@@ -17,12 +16,19 @@ type EmailPasswordFormValues = {
medium?: string;
};
export const EmailPasswordForm = ({ onSuccess }: any) => {
const { setToastAlert } = useToast();
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
};
export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
const [isResettingPassword, setIsResettingPassword] = useState(false);
const router = useRouter();
const isSignUpPage = router.pathname === "/sign-up";
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
@@ -34,83 +40,96 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
reValidateMode: "onChange",
});
const onSubmit = (formData: EmailPasswordFormValues) => {
authenticationService
.emailLogin(formData)
.then((response) => {
onSuccess(response);
})
.catch((error) => {
console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct email address and password to sign in",
});
if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key];
console.log(err);
setError(key as keyof EmailPasswordFormValues, {
type: "manual",
message: Array.isArray(err) ? err.join(", ") : err,
});
});
});
};
return (
<>
<form className="mt-5 py-5 px-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email ID is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email ID is not valid",
}}
error={errors.email}
placeholder="Enter your Email ID"
/>
</div>
<div className="mt-5">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password"
/>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<Link href={"/forgot-password"}>
<a className="font-medium text-brand-accent hover:text-brand-accent">
Forgot your password?
</a>
</Link>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
{isResettingPassword
? "Reset your password"
: isSignUpPage
? "Sign up on Plane"
: "Sign in to Plane"}
</h1>
{isResettingPassword ? (
<EmailResetPasswordForm setIsResettingPassword={setIsResettingPassword} />
) : (
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
</div>
</div>
<div className="mt-5">
<SecondaryButton
type="submit"
className="w-full text-center"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign In"}
</SecondaryButton>
</div>
</form>
<div className="space-y-1">
<Input
id="password"
type="password"
name="password"
register={register}
validations={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="text-right text-xs">
{isSignUpPage ? (
<Link href="/">
<a className="text-custom-text-200 hover:text-custom-primary-100">
Already have an account? Sign in.
</a>
</Link>
) : (
<button
type="button"
onClick={() => setIsResettingPassword(true)}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
)}
</div>
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSignUpPage
? isSubmitting
? "Signing up..."
: "Sign up"
: isSubmitting
? "Signing in..."
: "Sign in"}
</PrimaryButton>
{!isSignUpPage && (
<Link href="/sign-up">
<a className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">
Don{"'"}t have an account? Sign up.
</a>
</Link>
)}
</div>
</form>
)}
</>
);
};

View File

@@ -0,0 +1,97 @@
import React from "react";
// react hook form
import { useForm } from "react-hook-form";
// services
import userService from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
};
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const forgotPassword = async (formData: any) => {
const payload = {
email: formData.email,
};
await userService
.forgotPassword(payload)
.then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Password reset link has been sent to your email address.",
})
)
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Please check the Email ID entered.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
return (
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(forgotPassword)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter registered email address.."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<SecondaryButton
className="w-full text-center h-[46px]"
onClick={() => setIsResettingPassword(false)}
>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</div>
</form>
);
};

View File

@@ -1,24 +0,0 @@
import { useState, FC } from "react";
import { KeyIcon } from "@heroicons/react/24/outline";
// components
import { EmailCodeForm, EmailPasswordForm } from "components/account";
export interface EmailSignInFormProps {
handleSuccess: () => void;
}
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
const { handleSuccess } = props;
// states
const [useCode, setUseCode] = useState(true);
return (
<>
{useCode ? (
<EmailCodeForm onSuccess={handleSuccess} />
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
</>
);
};

View File

@@ -1,9 +1,14 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// next-themes
import { useTheme } from "next-themes";
// images
import githubImage from "/public/logos/github-black.png";
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
@@ -11,35 +16,42 @@ export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn } = props;
// router
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null);
const {
query: { code },
} = useRouter();
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const { theme } = useTheme();
useEffect(() => {
if (code) {
if (code && !gitCode) {
setGitCode(code.toString());
handleSignIn(code.toString());
}
}, [code, handleSignIn]);
}, [code, gitCode, handleSignIn]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any);
setLoginCallBackURL(`${origin}/` as any);
}, []);
return (
<div className="w-full px-1">
<div className="w-full flex justify-center items-center">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-3 rounded-md border border-brand-base p-2 text-sm font-medium text-brand-secondary duration-300 hover:bg-brand-surface-2">
<Image src={githubImage} height={22} width={22} color="#000" alt="GitHub Logo" />
<span>Sign In with Github</span>
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<Image
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
height={20}
width={20}
alt="GitHub Logo"
/>
<span>Sign in with GitHub</span>
</button>
</Link>
</div>

View File

@@ -1,5 +1,5 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
// next
import Script from "next/script";
export interface IGoogleLoginButton {
@@ -8,18 +8,18 @@ export interface IGoogleLoginButton {
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn } = props;
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: handleSignIn,
});
window?.google?.accounts.id.renderButton(
googleSignInButton.current,
{
@@ -27,11 +27,13 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
theme: "outline",
size: "large",
logo_alignment: "center",
width: "410",
text: "continue_with",
width: "360",
text: "signin_with",
} as GsiButtonConfiguration // customization attributes
);
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
@@ -47,7 +49,11 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="overflow-hidden rounded" id="googleSignInButton" ref={googleSignInButton} />
<div
className="overflow-hidden rounded w-full flex justify-center items-center !text-sm !font-medium !text-custom-text-100"
id="googleSignInButton"
ref={googleSignInButton}
/>
</>
);
};

View File

@@ -1,5 +1,5 @@
export * from "./google-login";
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./github-login-button";
export * from "./email-signin-form";
export * from "./google-login";

View File

@@ -97,7 +97,7 @@ export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClos
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
@@ -111,10 +111,13 @@ export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClos
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Save Analytics
</Dialog.Title>
<div className="mt-5">

View File

@@ -18,7 +18,7 @@ import { Loader, PrimaryButton } from "components/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
@@ -29,6 +29,7 @@ type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
fullScreen: boolean;
user: ICurrentUserResponse | undefined;
};
export const CustomAnalytics: React.FC<Props> = ({
@@ -38,6 +39,7 @@ export const CustomAnalytics: React.FC<Props> = ({
control,
setValue,
fullScreen,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@@ -59,7 +61,7 @@ export const CustomAnalytics: React.FC<Props> = ({
<AnalyticsSelectBar
control={control}
setValue={setValue}
projects={projects}
projects={projects ?? []}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
@@ -84,7 +86,7 @@ export const CustomAnalytics: React.FC<Props> = ({
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</div>
</div>
@@ -102,7 +104,7 @@ export const CustomAnalytics: React.FC<Props> = ({
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-brand-secondary">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton
@@ -124,6 +126,7 @@ export const CustomAnalytics: React.FC<Props> = ({
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
user={user}
/>
</div>
);

View File

@@ -31,7 +31,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
}
return (
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
@@ -39,7 +39,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
}}
/>
<span
className={`font-medium text-brand-secondary ${
className={`font-medium text-custom-text-200 ${
params.segment
? params.segment === "priority" || params.segment === "state__group"
? "capitalize"

View File

@@ -111,7 +111,6 @@ export const AnalyticsGraph: React.FC<Props> = ({
: undefined,
}}
theme={{
background: "rgb(var(--color-bg-base))",
axis: {},
}}
/>

View File

@@ -29,7 +29,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-brand-secondary">Project</h6>
<h6 className="text-xs text-custom-text-200">Project</h6>
<Controller
name="project"
control={control}
@@ -40,7 +40,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
</div>
)}
<div>
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
<h6 className="text-xs text-custom-text-200">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
@@ -50,7 +50,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
<h6 className="text-xs text-custom-text-200">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
@@ -67,7 +67,7 @@ export const AnalyticsSelectBar: React.FC<Props> = ({
/>
</div>
<div>
<h6 className="text-xs text-brand-secondary">Group</h6>
<h6 className="text-xs text-custom-text-200">Group</h6>
<Controller
name="segment"
control={control}

View File

@@ -7,6 +7,7 @@ import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
@@ -22,8 +23,16 @@ import {
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
import {
IAnalyticsParams,
IAnalyticsResponse,
ICurrentUserResponse,
IExportAnalyticsFormData,
IWorkspace,
} from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants
@@ -34,6 +43,7 @@ type Props = {
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
user: ICurrentUserResponse | undefined;
};
export const AnalyticsSidebar: React.FC<Props> = ({
@@ -41,6 +51,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
params,
fullScreen,
isProjectLevel = false,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@@ -82,6 +93,60 @@ export const AnalyticsSidebar: React.FC<Props> = ({
: null
);
const trackExportAnalytics = () => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
@@ -95,13 +160,15 @@ export const AnalyticsSidebar: React.FC<Props> = ({
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) =>
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
})
)
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",
@@ -112,23 +179,23 @@ export const AnalyticsSidebar: React.FC<Props> = ({
};
const selectedProjects =
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
@@ -140,7 +207,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div>
)}
</div>
<div className="h-full overflow-hidden">
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
@@ -148,61 +215,62 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{selectedProjects.map((projectId) => {
const project: IProject = projects.find((p) => p.id === projectId);
const project = projects?.find((p) => p.id === projectId);
return (
<div key={project.id}>
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(project.emoji))}
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
if (project)
return (
<div key={project.id} className="w-full">
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{renderEmoji(project.emoji)}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="break-all">
{project.name}
<span className="text-brand-secondary text-xs ml-1">
({project.identifier})
</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total members</h6>
</div>
<span className="text-brand-secondary">{project.total_members}</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="text-custom-text-200 text-xs ml-1">
({project.identifier})
</span>
</h5>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<ContrastIcon height={16} width={16} />
<h6>Total cycles</h6>
<div className="mt-4 space-y-3 pl-2 w-full">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{project.total_members}</span>
</div>
<span className="text-brand-secondary">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
<h6>Total modules</h6>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<ContrastIcon height={16} width={16} />
<h6>Total cycles</h6>
</div>
<span className="text-custom-text-200">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{project.total_modules}</span>
</div>
<span className="text-brand-secondary">{project.total_modules}</span>
</div>
</div>
</div>
);
);
})}
</div>
</div>
@@ -210,16 +278,16 @@ export const AnalyticsSidebar: React.FC<Props> = ({
{projectId ? (
cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<h6 className="text-custom-text-200">Lead</h6>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderShortDate(cycleDetails.start_date)
@@ -227,7 +295,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
@@ -238,17 +306,17 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div>
) : moduleId && moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
<h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6>
<h6 className="text-custom-text-200">Lead</h6>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Start Date</h6>
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderShortDate(moduleDetails.start_date)
@@ -256,7 +324,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Target Date</h6>
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderShortDate(moduleDetails.target_date)
@@ -270,7 +338,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
{String.fromCodePoint(parseInt(projectDetails.emoji))}
{renderEmoji(projectDetails.emoji)}
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
@@ -286,11 +354,11 @@ export const AnalyticsSidebar: React.FC<Props> = ({
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Network</h6>
<h6 className="text-custom-text-200">Network</h6>
<span>
{
NETWORK_CHOICES[

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