Compare commits

...
Author SHA1 Message Date
NarayanBavisetti 168823e5ba dev: serializer updates for workspace, project, inbox etc. 2023-08-21 14:12:48 +05:30
NarayanBavisetti ea3b9456f9 Merge branch 'develop' of github.com:makeplane/plane into chore/serializers 2023-08-18 16:05:42 +05:30
NarayanBavisetti 2058334559 fix: created dynamic serializer 2023-08-18 16:04:28 +05:30
Bavisetti NarayanandGitHub 125e9090ea chore: module link model (#1905)
* chore: module link model

* chore: added migration
2023-08-18 15:50:48 +05:30
Bavisetti NarayanandGitHub 02ac4cee22 chore: renamed target date to start date (#1902) 2023-08-18 15:25:42 +05:30
Anmol Singh BhatiaandGitHub 93164755e2 style: analytics stats empty state (#1903) 2023-08-18 15:15:33 +05:30
Aaryan KhandelwalandGitHub 6344f6f562 chore: set order by to manual on gantt chart (#1886) 2023-08-18 15:12:12 +05:30
Aaryan KhandelwalandGitHub b67e30fd9c chore: analytics start date property for x-axis and group (#1888) 2023-08-18 15:11:25 +05:30
Anmol Singh BhatiaandGitHub 93fec2c678 fix: completed cycle validation , style: assignee count alignment fix (#1901)
* style: assignee count alignment fix

* fix: completed cycle validation
2023-08-18 14:23:13 +05:30
Anmol Singh BhatiaandGitHub c3c6ba9e34 chore: link edit functionality (#1895) 2023-08-18 12:03:31 +05:30
Anmol Singh BhatiaandGitHub d74ec7bda9 fix: ui improvement and bug fixes (#1883) 2023-08-18 12:01:51 +05:30
Henit ChobisaandGitHub e593a8d4bd chore: Edited Setup Script to take TipTap Auth Token and Generate .npmrc (#1897) 2023-08-18 11:47:58 +05:30
guru_sainathandGitHub abb8782c44 fix: handled default view on plane deploy (#1893)
* fix: handled default view on plane deploy

* fix: handled default view on refresh
2023-08-17 14:24:33 +05:30
guru_sainathandGitHub 0afd72db95 fix: updated theming in workspace preferences (#1890) 2023-08-16 20:35:17 +05:30
guru_sainathandGitHub 65295f6c6f chore: updating the theme using MobX from command k (#1879)
* chore: updating the theme using mobx from command k

* feat: Showing the project published status in the app header

* dev: updated validation and redirection the project publish modal and added redirection on the app header
2023-08-16 18:26:36 +05:30
Aaryan KhandelwalandGitHub 5b6b43fb83 fix: quick action buttons: (#1884) 2023-08-16 18:25:11 +05:30
Dakshesh JainandGitHub f8497125db style: fixed display name coming twice on profile page (#1889) 2023-08-16 18:18:57 +05:30
NarayanBavisetti e1b77d400a Merge branch 'develop' of github.com:makeplane/plane into chore/serializers 2023-08-16 17:40:52 +05:30
Bavisetti NarayanandGitHub b24622e5ef fix: validation for issue activity description (#1887) 2023-08-16 17:12:09 +05:30
guru_sainathandGitHub 10dface85d chore: updated error pages 404 and project-not-found in plane deploy (#1885)
* dev: custom error messages.

* dev: updated next version in yarn.lock

* dev: updated project-not-published icon
2023-08-16 17:05:40 +05:30
Aditi PatelandGitHub 2b6debaa3e Updated setup document to include tiptap pro install (#1871) 2023-08-16 14:42:37 +05:30
1750ba344b fix: my issue duplication (#1882)
Co-authored-by: Plane Team <planeteam@srirams-Mac-mini.local>
2023-08-16 14:40:10 +05:30
NikhilandGitHub 550473bb02 Merge pull request #1876 from makeplane/stage/merge-fixes
Promote: Develop to Stage Release
2023-08-16 13:18:26 +05:30
sriramveeraghanta fde978861c Merge branch 'develop' of github.com:makeplane/plane into stage/merge-fixes 2023-08-16 13:17:21 +05:30
guru_sainathandGitHub f44d142f2c chore: tip-tap editor update in workspace user activity (#1877) 2023-08-16 13:16:20 +05:30
guru_sainathandGitHub 1ded8f486f chore: updated meta tags for project issues (#1875)
* dev: updated meta tags for project issues

* dev: updated project description in meta tags in plane deploy.
2023-08-16 13:15:57 +05:30
2c43a15515 chore: added new filed in serializer (#1874)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 13:10:03 +05:30
Dakshesh JainandGitHub 0979acc1a4 fix: notification card not redirecting to archive issue detail for archived issue (#1861) 2023-08-16 13:09:56 +05:30
sriramveeraghanta 9003c58d89 merge conflicts resolved 2023-08-16 13:00:58 +05:30
NarayanBavisetti 77977d7d32 fix: changed the serializers 2023-08-16 12:42:34 +05:30
NikhilandGitHub 55e2f00ffe fix: members list filtering for workspace and projects (#1872) 2023-08-16 12:21:56 +05:30
NikhilandGitHub 08382f88b4 chore: updated migration files for 0.11 (#1851) 2023-08-16 10:37:22 +05:30
72419447ec fix: added slug in filename for export issues (#1870)
* chore: file name changed for exported issues

* fix: added slug in filename for export issues

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 09:39:17 +05:30
NikhilandGitHub b554087b1f chore: deploy board status for project (#1866) 2023-08-16 01:00:22 +05:30
07717e9a93 chore: file name changed for exported issues (#1865)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-15 19:49:17 +05:30
NikhilandGitHub df46a45afc fix: analytics export (#1862)
* fix: analytics export

* dev: export analytics assignee indexing

* dev: total counts
2023-08-15 15:48:22 +05:30
e1ae0d3b56 feat : Tiptap integration (#1832)
* remirror instances commented out to avoid prosemirror conflicts

* styles migrated for remirror to tiptap transition

* added bubblemenu support with extensions

* fixed css for task lists and code with syntax highlighting

* added support for slash command

* fixed bubble menu to match styles and added better seperation in UI

* saving with debounce logic added and it's stored in backend

* added migration support by updating to html

* Image uploads done

* improved file structure and delete image function implemented

* Integrated tiptap with Issue Modal

* added additional props and Tiptap Integration with Comments

* added tiptap integration with user activity feeds

* added ref control support and bubble menu support for readonly editor

* added tiptap support for plane pages

* added tiptap support to gpt assistant modal (yet to be tested)

* removed remirror instances and cleaned up code

* improved code structure for extracting props in Tiptap

* fixing ts errors for next build

* fixing node ts error for Horizontal Rule

* added ts fix for node types

* temp fix

* temp fix

* added min height for issue description in modal

* added resolutions to prosemirror-model version

* trying pnpm overrides

* explicitly added prosemirror deps

* bugfixes

* removed extra gap at the top and moved saved indicator to the bottom

* fix: slash command scroll position

* chore: update custom css variables

* matched theme colours

* fixed gpt-assistant modal

* updated yarn lock

* added debounced updates for the title and removed saved state after timeout

* added css animations for saved state

* build fixes and remove remirror instances

* minor commenting fixes

---------

Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-08-15 15:04:46 +05:30
guru_sainathandGitHub daa8f7d79b feat / public deploy settings workflow (#1863)
* Feat: Implemented project publish settings

* dev: updated the env dependancy in turbo and enabling the publish access to admin
2023-08-14 19:18:38 +05:30
NikhilandGitHub d2cdaaccb9 fix: slack queryset (#1860) 2023-08-14 18:53:09 +05:30
Anmol Singh BhatiaandGitHub 6774eddb66 style: sidebar select date width (#1859) 2023-08-14 18:10:21 +05:30
Dakshesh JainandGitHub 8ccc1b3fcc fix: gray background on png image (#1856) 2023-08-14 16:23:10 +05:30
Anmol Singh BhatiaandGitHub 5e76e03a55 style: profile activity loader (#1858) 2023-08-14 16:20:53 +05:30
srinivas pendemandGitHub 77fb50faa4 fix: route fix in imports and exports (#1857) 2023-08-14 16:19:13 +05:30
5ddfee12bc fix: changed the display of date format (#1855)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-14 16:10:57 +05:30
Aaryan KhandelwalandGitHub f7a596c113 fix: gantt chart block left drag flicker (#1854)
* fix: left drag flicker

* fix: opposite side manual scroll
2023-08-14 14:27:02 +05:30
Dakshesh JainandGitHub f73239be92 refactor: using webp image format instead of svg (#1852) 2023-08-14 13:26:16 +05:30
Anmol Singh BhatiaandGitHub dc2438b2d3 style: image upload modal aspect ratio (#1853) 2023-08-14 13:06:00 +05:30
srinivas pendemGitHubsrinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
ddd3301d17 feat: csv, json and, xlsx exporter (#1840)
* feat : csv, jason and, xlxs exporter

* handeling the export fail

* adding expired state to exports

* typo update

* header change

* improvement: added validation for the expired date

---------

Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
2023-08-14 11:44:17 +05:30
Vamsi KuramaandGitHub 816f00d956 Merge pull request #1848 from ryota-murakami/fix/license 2023-08-12 17:12:14 +05:30
ryota-murakami 70e2509d52 package.json; fix license 2023-08-12 20:30:01 +09:00
1a9faa025a fix: export issues in CSV, JSON and XLSX (#1794)
* fix: file name change

* feat: added xml json and csv export

* chore: added openpyxl package

* fix: added initiated_by field

* fix: added initiated by details

* dev: refactoring

* fix: rendering assignee name and labels in sheet

* fix: handeled exception in label

* feat: implemented link expiration scheduler(8 days)

* fix: removed the expired field

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-08-11 20:09:52 +05:30
NikhilandGitHub feba1cc4d0 feat: project public boards (#1772)
* feat: project public boards

* dev: public issue and comment reactions

* dev: public issue comments

* dev: public comments

* dev: inbox for public boards

* dev: inbox issues for public board

* dev: public inbox issue

* dev: migrations

* dev: update api endpoints

* dev: project boards and views

* dev: state and label details

* dev: public issue voting

* dev: issue voting

* dev: workspace details

* dev: project icon and emoji
2023-08-11 19:27:44 +05:30
Anmol Singh BhatiaandGitHub 079a5b28d8 style: custom theming color picker position (#1846) 2023-08-11 19:17:59 +05:30
Aaryan KhandelwalandGitHub c6eea9c7a9 fix: empty groups not appearing in the kanban view (#1843) 2023-08-11 19:17:02 +05:30
Anmol Singh BhatiaandGitHub 5f5790ebf9 chore: start date option added in spreadsheet view (#1824) 2023-08-11 19:14:54 +05:30
Anmol Singh BhatiaandGitHub a3d99100ee fix: sub issue progress indicator fix (#1847) 2023-08-11 19:05:13 +05:30
Anmol Singh BhatiaandGitHub ac6d2b0139 chore: updated user permission for project and workspace settings (#1807)
* fix: updated user permission for project and workspace settings

* chore: workspace delete modal fix
2023-08-11 18:29:24 +05:30
Dakshesh JainandGitHub 7becec4ee9 feat: randomize color on label create (#1839)
fix: create label state being persisted after edit label
2023-08-11 17:42:47 +05:30
guru_sainathandGitHub cd5e5b96da feat: Mobx integration, List and Kanban boards implementation in plane space (#1844)
* feat: init mobx and issue filter

* feat: Implemented list and kanban views in plane space and integrated mobx.

* feat: updated store type check
2023-08-11 17:18:33 +05:30
Dakshesh JainandGitHub ad4cdcc512 fix: cmdk modal not closing when choosing an option (#1833) 2023-08-11 17:16:37 +05:30
NikhilandGitHub 6617049983 fix: sub issue endpoint for state distribution (#1845) 2023-08-11 16:38:03 +05:30
Dakshesh JainandGitHub abec46a725 fix: cmdk not changing theme if active theme is custom-theme (#1842) 2023-08-11 16:16:20 +05:30
NikhilandGitHub 88e987b902 feat: cycle and module sort order (#1841)
* dev: cycle and module ordering

* dev: sort order for smallest
2023-08-11 16:03:15 +05:30
Aaryan KhandelwalandGitHub 785a6e8871 chore: gantt chart resizable blocks, y-axis drag and drop (#1810)
* chore: gantt chart resizable blocks

* chore: right scroll added

* chore: left scroll added

* fix: build errors

* chore: remove unnecessary console logs

* chore: add block type and remove info toggle

* feat: gantt chart blocks y-axis drag and drop

* chore: disable drag flag

* fix: y-axis drag mutation

* fix: scroll container undefined error

* fix: negative scroll

* fix: negative scroll

* style: blocks tooltip consistency
2023-08-11 15:59:13 +05:30
Anmol Singh BhatiaandGitHub 762ef422ca chore: project search added in project list page (#1837) 2023-08-11 15:57:11 +05:30
Anmol Singh BhatiaandGitHub e2b5657c3e chore: profile cover photo (#1836) 2023-08-11 15:56:26 +05:30
Anmol Singh BhatiaandGitHub dbb53a663e style: sidebar ui improvement (#1816)
* style: sidebar ui improvement

* style: sidebar menu spacing

* style: sidebar consistent spacing

* style: notification improvement

* style: remove from favorite filled icon added

* chore: settings option added in sidebar context menu

* chore: update delete project option visibility for sidebar

* style: sidebar project list display border top on scroll
2023-08-11 15:53:10 +05:30
Anmol Singh BhatiaandGitHub 5c964d144a style: ui improvement (#1806)
* style: kanban board theming

* style: assignee ui revamp

* style: kanban board header, label , priority , state and avatar ui revamp

* style: kanban card state dropdown

* style: sidebar profile dropdown

* style: sidebar dropdown icon

* style: sidebar workspace dropdown

* style: assignee select component

* fix: state select

* style: consistent app header button
2023-08-11 15:50:05 +05:30
Anmol Singh BhatiaandGitHub 289e81d6eb chore: issue activity user redirection added (#1805)
* feat: issue activity user redirection added

* feat: analytics page user redirection
2023-08-11 15:48:52 +05:30
NikhilandGitHub def10af1e2 fix: issue date filtering (#1834) 2023-08-11 12:48:30 +05:30
NikhilandGitHub edaeae1b69 chore: sort order for cycle and modules (#1835) 2023-08-11 12:48:02 +05:30
NikhilandGitHub e06ee25800 fix: project states create (#1830) 2023-08-11 12:46:50 +05:30
NikhilandGitHub be86a7d38e feat: project member role (#1828) 2023-08-11 12:46:29 +05:30
NikhilandGitHub 0a1483c482 chore: issue list date filters and properties (#1820)
* dev: start date and target date validation and filter for null dated issues

* dev: remove print logs

* dev: issue property dates
2023-08-11 12:45:51 +05:30
NikhilandGitHub 11abd3cadf fix: user id for default analytics (#1808) 2023-08-11 12:45:04 +05:30
NikhilandGitHub 085cd1960e fix: project members endpoint email (#1804)
* fix: project members endpoint email

* dev: cycle and module assignee display name
2023-08-11 12:44:20 +05:30
NikhilandGitHub 2769a73898 remove: auto start date configuration (#1799) 2023-08-11 12:43:43 +05:30
NikhilandGitHub 8373f20944 fix: issues n plus 1 (#1785) 2023-08-11 12:43:00 +05:30
Dakshesh JainandGitHub f562fcd466 refactor: both delete or backspace can be used to open modal (#1829)
* refactor: both delete or backspace can be used to open modal

* fix: 'Bulk Issue Delete' modal will open on single click
2023-08-10 16:02:10 +05:30
Dakshesh JainandGitHub 8c8668a3e6 refactor: not making calls to member's endpoint to get membership status (#1831)
* refactor: not making call to members endpoint to get membership status

* refactor: type for IProject
2023-08-10 15:39:32 +05:30
Dakshesh JainandGitHub 9ce85cdf21 refactor: if/else conditions (#1822)
* refactor: shortcuts if/else

* fix: merge conflict
2023-08-10 13:12:17 +05:30
Dakshesh JainandGitHub 1c6cdb8328 refactor: added validation for display_name (#1823) 2023-08-10 13:05:03 +05:30
guru_sainathandGitHub 005b42cb8d dev: Updating themening worfkflow (#1827) 2023-08-10 13:03:42 +05:30
Dakshesh JainandGitHub be062ccd34 fix: import user using endpoint with email (#1819) 2023-08-09 17:49:25 +05:30
Dakshesh JainandGitHub c9d0c5353d style: displaying email in members page depending on user role (#1817)
* style: displaying email in members page depending on user role

* refactor: added null safety
2023-08-09 17:23:57 +05:30
Dakshesh JainandGitHub 2a6eb5fe23 style: option to edit display name in profile page (#1818) 2023-08-09 16:44:35 +05:30
Aaryan KhandelwalandGitHub d9ccce41bc chore: profile and my issues page start date option (#1815)
* chore: profile and my issues page start date option

* fix: properties order in my profile view options
2023-08-09 15:46:47 +05:30
Anmol Singh BhatiaandGitHub 3db69a3a71 style: profile empty state (#1811)
* style: profile empty state

* style: priority empty state padding
2023-08-09 15:45:20 +05:30
Dakshesh JainandGitHub faa50b0bbb fix: analytics showing user id instead of display name (#1812)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist

* fix: analytics showing user id instead of display name
2023-08-09 15:20:29 +05:30
Dakshesh JainandGitHub 1991e09035 refactor: removed escape keydown listener (#1814) 2023-08-09 15:18:26 +05:30
Aaryan KhandelwalandGitHub 4fcd081d27 chore: select start date option for issue (#1813) 2023-08-09 15:17:32 +05:30
5f1209f1db chore: empty state for multi-level dropdown (#1802)
* fix :label filter should show something if there is no label #1779 (#1795)

* style: children empty state

---------

Co-authored-by: Pankaj Chotaliya <34762752+pankajvc@users.noreply.github.com>
2023-08-08 14:13:26 +05:30
Aaryan KhandelwalandGitHub 88e5a05253 chore: subscribed by me tab on my issues page (#1800)
* chore: add subscribed by me tab in my issues

* chore: update tab titles

* fix: build error
2023-08-08 13:15:25 +05:30
Dakshesh JainandGitHub 981acc81c1 fix: replaced first name, last name or email to display name (#1796)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist
2023-08-08 13:01:43 +05:30
NikhilandGitHub cf306ee605 feat: user display name (#1179)
* feat: user display name for the entire system

* feat: update issue activity to remove emails

* dev: update to display name wherever assignees__email and member__email

* dev: update display names on issue activity and the user script

* dev: update display_name function to generate display_name from email

* dev: add email for test purpose

* dev: set default display name for the user

* dev: add migration script and default value

* dev: annotate with assignees_id

* dev: return assignees id

* dev: display name for the profile

* dev: project members endpoint

* dev: url update

* dev: trailing /

* dev: update workspace member serializer

* fix: activity for assignees
2023-08-08 12:59:04 +05:30
guru_sainathandGitHub 9df0ba6e3a feat: initiated plane space (#1801) 2023-08-08 12:55:42 +05:30
guru_sainathandGitHub b6744dcd29 Chore: mobx setup and app sidebar and theme management (#1798)
* dev: Mobx integration for app sidebar and custom theme

* dev: Handled edge case and conditional rendering for mobx store
2023-08-08 12:50:27 +05:30
DevMinerandGitHub a164dfd532 chore(frontend): add sharp (#1451) 2023-08-07 15:52:08 +05:30
2b46e5f977 feat: issue export csv (#1781)
* feat: created issue export csv

* fix: optimized the queries

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-07 11:59:04 +05:30
Aaryan KhandelwalandGitHub 97c3fb40e7 fix: custom theme persisting after signing out (#1780)
* fix: custom theme persistence

* chore: remove console logs

* fix: build error

* fix: change theme from command k
2023-08-03 15:01:31 +05:30
Dakshesh JainandGitHub 5aad6c71da fix: notification read status being toggled when click on link (#1769) 2023-08-02 17:23:55 +05:30
Aaryan KhandelwalandGitHub a1ae338c37 chore: add non existing states for project entities (#1770) 2023-08-02 16:47:36 +05:30
Anmol Singh BhatiaandGitHub c16b0daa22 style: profile activity (#1771)
* style: profile activity comment log styling

* chore: profile feed activity refactor

* style: sidebar project list
2023-08-02 16:45:34 +05:30
NikhilandGitHub 9a29896291 fix: bulk issue import (#1773) 2023-08-02 16:42:47 +05:30
NikhilandGitHub a66dcb9419 fix: user profiles n plus 1 (#1765) 2023-08-02 16:42:24 +05:30
Dakshesh JainandGitHub 87a920174e fix: comment reaction mutation (#1768) 2023-08-02 14:21:48 +05:30
Anmol Singh BhatiaandGitHub 584192faba style: sidebar project list improvement (#1767) 2023-08-02 14:21:26 +05:30
Dakshesh JainandGitHub b61adbed4b fix: invalid project selection in create issue modal (#1766) 2023-08-02 13:38:45 +05:30
Aaryan KhandelwalandGitHub 7434800999 chore: show message if dragging unjoined project (#1763) 2023-08-02 12:09:53 +05:30
387 changed files with 16147 additions and 7720 deletions
+2
View File
@@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
+3 -1
View File
@@ -70,4 +70,6 @@ package-lock.json
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
pnpm-workspace.yaml
.npmrc
+10
View File
@@ -61,6 +61,16 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
+4 -4
View File
@@ -1,4 +1,4 @@
import os, sys
import os, sys, random, string
import uuid
sys.path.append("/code")
@@ -19,9 +19,9 @@ def populate():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password)
user.save()
print("User created")
print("Success")
print(f"User created with an email: {default_email}")
else:
print(f"User already exists with the default email: {default_email}")
if __name__ == "__main__":
+8 -11
View File
@@ -1,16 +1,10 @@
from .base import BaseSerializer
from .people import (
ChangePasswordSerializer,
ResetPasswordSerializer,
TokenSerializer,
)
from .user import UserSerializer, UserLiteSerializer
from .user import UserSerializer, ChangePasswordSerializer, ResetPasswordSerializer
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
)
from .project import (
@@ -20,10 +14,11 @@ from .project import (
ProjectMemberInviteSerializer,
ProjectIdentifierSerializer,
ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .state import StateSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer
@@ -34,7 +29,7 @@ from .issue import (
IssuePropertySerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer,
# IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
IssueFlatSerializer,
@@ -45,6 +40,7 @@ from .issue import (
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from .module import (
@@ -74,7 +70,6 @@ from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
from .estimate import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
)
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
@@ -82,3 +77,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer
+28
View File
@@ -1,5 +1,33 @@
from rest_framework import serializers
def filterFields(self, fields):
for field_name in fields:
if isinstance(field_name, dict):
for key, value in field_name.items():
if isinstance(value, list):
filterFields(self.fields[key], value)
allowed = []
for item in fields:
if isinstance(item, str):
allowed.append(item)
elif isinstance(item, dict):
allowed.append(list(item.keys())[0])
existing = set(self.fields)
allowed = set(allowed)
for field_name in existing - allowed:
self.fields.pop(field_name)
return self.fields
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
self.fields = filterFields(self, fields)
+9 -8
View File
@@ -6,10 +6,10 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserSerializer
from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkSpaceSerializer
from .project import ProjectSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleWriteSerializer(BaseSerializer):
@@ -20,7 +20,7 @@ class CycleWriteSerializer(BaseSerializer):
class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True)
owned_by = UserSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
@@ -33,14 +33,15 @@ class CycleSerializer(BaseSerializer):
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")
workspace_detail = WorkSpaceSerializer(source="workspace",read_only=True)
project_detail = ProjectSerializer(read_only=True, source="project")
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
@@ -53,7 +54,7 @@ class CycleSerializer(BaseSerializer):
unique_list = [dict(item) for item in unique_objects]
return unique_list
def get_labels(self, obj):
labels = [
{
+3 -18
View File
@@ -2,12 +2,12 @@
from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
from plane.api.serializers import WorkSpaceSerializer, ProjectSerializer
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkSpaceSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(read_only=True, source="project")
class Meta:
model = Estimate
@@ -27,18 +27,3 @@ class EstimatePointSerializer(BaseSerializer):
"workspace",
"project",
]
class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = [
"points",
"name",
"description",
]
@@ -0,0 +1,26 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import ExporterHistory
from .user import UserSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserSerializer(source="initiated_by",read_only=True)
class Meta:
model = ExporterHistory
fields = [
"id",
"created_at",
"updated_at",
"project",
"provider",
"status",
"url",
"initiated_by",
"initiated_by_detail",
"token",
"created_by",
"updated_by",
]
read_only_fields = fields
+6 -6
View File
@@ -1,15 +1,15 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .user import UserSerializer
from .project import ProjectSerializer
from .workspace import WorkSpaceSerializer
from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
initiated_by_detail = UserSerializer(source="initiated_by", read_only=True)
project_detail = ProjectSerializer(source="project", read_only=True)
workspace_detail = WorkSpaceSerializer(source="workspace", read_only=True)
class Meta:
model = Importer
+6 -7
View File
@@ -4,15 +4,14 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .project import ProjectLiteSerializer
from .project import ProjectSerializer
from .state import StateLiteSerializer
from .project import ProjectLiteSerializer
from .user import UserLiteSerializer
from .user import UserSerializer
from plane.db.models import Inbox, InboxIssue, Issue
class InboxSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id","name","cover_image","icon_prop","emoji","description"), read_only=True)
pending_issue_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -26,7 +25,7 @@ class InboxSerializer(BaseSerializer):
class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id","name","cover_image","icon_prop","emoji","description"), read_only=True)
class Meta:
model = InboxIssue
@@ -46,9 +45,9 @@ class InboxIssueLiteSerializer(BaseSerializer):
class IssueStateInboxSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
assignee_details = UserSerializer(source="assignees", read_only=True, 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)
+124 -52
View File
@@ -6,11 +6,10 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserSerializer
from .state import StateSerializer, StateLiteSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectSerializer
from .workspace import WorkSpaceSerializer
from plane.db.models import (
User,
Issue,
@@ -31,6 +30,7 @@ from plane.db.models import (
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
)
@@ -53,7 +53,11 @@ class IssueFlatSerializer(BaseSerializer):
class IssueProjectLiteSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
class Meta:
model = Issue
@@ -70,9 +74,12 @@ class IssueProjectLiteSerializer(BaseSerializer):
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
created_by_detail = UserSerializer(
source="created_by",
read_only=True,
)
project_detail = ProjectSerializer(source="project", read_only=True)
workspace_detail = WorkSpaceSerializer(source="workspace", read_only=True)
assignees_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
@@ -111,6 +118,15 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
@@ -290,35 +306,19 @@ class IssueCreateSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
actor_detail = UserSerializer(source="actor", read_only=True)
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
class Meta:
model = IssueActivity
fields = "__all__"
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueProperty
@@ -331,8 +331,14 @@ class IssuePropertySerializer(BaseSerializer):
class LabelSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace", fields=("id", "name", "slug"), read_only=True
)
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
class Meta:
model = Label
@@ -393,12 +399,16 @@ class BlockerIssueSerializer(BaseSerializer):
read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer):
assignee_details = UserLiteSerializer(read_only=True, source="assignee")
# class IssueAssigneeSerializer(BaseSerializer):
# assignee_details = UserSerializer(
# source="assignee",
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
# read_only=True,
# )
class Meta:
model = IssueAssignee
fields = "__all__"
# class Meta:
# model = IssueAssignee
# fields = "__all__"
class CycleBaseSerializer(BaseSerializer):
@@ -462,7 +472,11 @@ class IssueModuleDetailSerializer(BaseSerializer):
class IssueLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
created_by_detail = UserSerializer(
source="created_by",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = IssueLink
@@ -516,7 +530,11 @@ class IssueReactionSerializer(BaseSerializer):
class IssueReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
actor_detail = UserSerializer(
source="actor",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = IssueReaction
@@ -529,7 +547,11 @@ class IssueReactionLiteSerializer(BaseSerializer):
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
actor_detail = UserSerializer(
source="actor",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = CommentReaction
@@ -548,14 +570,28 @@ class CommentReactionSerializer(BaseSerializer):
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
read_only_fields = fields
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
actor_detail = UserSerializer(source="actor", read_only=True)
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
workspace_details = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = IssueComment
@@ -568,12 +604,17 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
class IssueStateFlatSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
class Meta:
model = Issue
@@ -590,8 +631,17 @@ class IssueStateFlatSerializer(BaseSerializer):
class IssueStateSerializer(BaseSerializer):
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)
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
assignee_details = UserSerializer(
source="assignees",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
many=True,
)
sub_issues_count = serializers.IntegerField(read_only=True)
bridge_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
@@ -603,11 +653,20 @@ class IssueStateSerializer(BaseSerializer):
class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
assignee_details = UserSerializer(
source="assignees",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
many=True,
)
# List of issues blocked by this issue
blocked_issues = BlockedIssueSerializer(read_only=True, many=True)
# List of issues that block this issue
@@ -633,11 +692,24 @@ class IssueSerializer(BaseSerializer):
class IssueLiteSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
project_detail = ProjectSerializer(
source="project",
fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"),
read_only=True,
)
state_detail = StateLiteSerializer(read_only=True, source="state")
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
assignee_details = UserSerializer(
source="assignees",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
many=True,
)
sub_issues_count = serializers.IntegerField(read_only=True)
cycle_id = serializers.UUIDField(read_only=True)
module_id = serializers.UUIDField(read_only=True)
+27 -10
View File
@@ -3,9 +3,9 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .user import UserSerializer
from .project import ProjectSerializer
from .workspace import WorkSpaceSerializer
from .issue import IssueStateSerializer
from plane.db.models import (
@@ -25,8 +25,12 @@ class ModuleWriteSerializer(BaseSerializer):
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = Module
@@ -106,7 +110,7 @@ class ModuleFlatSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer):
module_detail = ModuleFlatSerializer(read_only=True, source="module")
issue_detail = ProjectLiteSerializer(read_only=True, source="issue")
issue_detail = ProjectSerializer(source="issue", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -124,7 +128,11 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
created_by_detail = UserSerializer(
source="created_by",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = ModuleLink
@@ -151,9 +159,18 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleSerializer(BaseSerializer):
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")
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
lead_detail = UserSerializer(
source="lead",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
members_detail = UserSerializer(
source="members",
fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
many=True,
)
link_module = ModuleLinkSerializer(read_only=True, many=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
@@ -1,12 +1,16 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserSerializer
from plane.db.models import Notification
class NotificationSerializer(BaseSerializer):
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
triggered_by_details = UserSerializer(
source="triggered_by",
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = Notification
fields = "__all__"
+14 -6
View File
@@ -4,15 +4,19 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkSpaceSerializer
from .project import ProjectSerializer
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
class PageBlockSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = PageBlock
@@ -39,8 +43,12 @@ class PageSerializer(BaseSerializer):
required=False,
)
blocks = PageBlockLiteSerializer(read_only=True, many=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = Page
-57
View File
@@ -1,57 +0,0 @@
from rest_framework.serializers import (
ModelSerializer,
Serializer,
CharField,
SerializerMethodField,
)
from rest_framework.authtoken.models import Token
from rest_framework_simplejwt.tokens import RefreshToken
from plane.db.models import User
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
class ChangePasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = CharField(required=True)
new_password = CharField(required=True)
class ResetPasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = CharField(required=True)
confirm_password = CharField(required=True)
class TokenSerializer(ModelSerializer):
user = UserSerializer()
access_token = SerializerMethodField()
refresh_token = SerializerMethodField()
def get_access_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token.access_token)
def get_refresh_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token)
class Meta:
model = Token
fields = "__all__"
+92 -20
View File
@@ -6,19 +6,41 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
from plane.api.serializers.user import UserLiteSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer
from plane.api.serializers.user import UserSerializer
from plane.db.models import (
Project,
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
class ProjectSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
# workspace = WorkSpaceSerializer(read_only=True)
default_assignee = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
project_lead = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
is_favorite = serializers.BooleanField(read_only=True)
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)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
# fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = Project
@@ -77,23 +99,24 @@ 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)
project_lead = UserLiteSerializer(read_only=True)
default_assignee = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
project_lead = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
is_favorite = serializers.BooleanField(read_only=True)
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)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta:
model = Project
@@ -102,8 +125,36 @@ class ProjectDetailSerializer(BaseSerializer):
class ProjectMemberSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserLiteSerializer(read_only=True)
project = ProjectSerializer(fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
member = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = ProjectMember
fields = "__all__"
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
project = ProjectSerializer(fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
member = UserSerializer(
# fields=(
# "id",
# "first_name",
# "last_name",
# "avatar",
# "is_bot",
# "display_name",
# "email",
# ),
read_only=True,
)
class Meta:
model = ProjectMember
@@ -111,8 +162,11 @@ class ProjectMemberSerializer(BaseSerializer):
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectSerializer(fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace = WorkSpaceSerializer(
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = ProjectMemberInvite
@@ -126,7 +180,7 @@ class ProjectIdentifierSerializer(BaseSerializer):
class ProjectFavoriteSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
class Meta:
model = ProjectFavorite
@@ -137,13 +191,31 @@ class ProjectFavoriteSerializer(BaseSerializer):
]
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
member = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = ProjectDeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
]
+4 -4
View File
@@ -1,14 +1,14 @@
# Module imports
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkSpaceSerializer
from .project import ProjectSerializer
from plane.db.models import State
class StateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkSpaceSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(source="project", read_only=True)
class Meta:
model = State
+21 -15
View File
@@ -1,3 +1,6 @@
# Third party imports
from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User
@@ -30,18 +33,21 @@ class UserSerializer(BaseSerializer):
return bool(obj.first_name) or bool(obj.last_name)
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"first_name",
"last_name",
"email",
"avatar",
"is_bot",
]
read_only_fields = [
"id",
"is_bot",
]
class ChangePasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
+8 -4
View File
@@ -3,16 +3,20 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkSpaceSerializer
from .project import ProjectSerializer
from plane.db.models import IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectSerializer(source="project", fields=("id", "name", "cover_image", "icon_prop", "emoji", "description"), read_only=True)
workspace_detail = WorkSpaceSerializer(
source="workspace",
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = IssueView
+24 -17
View File
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserSerializer
from plane.db.models import (
User,
@@ -17,7 +17,10 @@ from plane.db.models import (
class WorkSpaceSerializer(BaseSerializer):
owner = UserLiteSerializer(read_only=True)
owner = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
@@ -35,8 +38,14 @@ class WorkSpaceSerializer(BaseSerializer):
class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
member = UserSerializer(
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name" ,"email"),
read_only=True,
)
workspace = WorkSpaceSerializer(
fields=("id", "name", "slug"),
read_only=True,
)
class Meta:
model = WorkspaceMember
@@ -46,7 +55,11 @@ 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")
created_by_detail = UserSerializer(
source="created_by",
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
)
class Meta:
model = WorkspaceMemberInvite
@@ -54,7 +67,12 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
members_detail = UserSerializer(
source="members",
# fields=("id", "first_name", "last_name", "avatar", "is_bot", "display_name"),
read_only=True,
many=True,
)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
@@ -101,17 +119,6 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data)
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
+152
View File
@@ -32,6 +32,7 @@ from plane.api.views import (
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint,
@@ -59,6 +60,7 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
@@ -84,8 +86,10 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
## End Issues
# States
StateViewSet,
@@ -162,6 +166,15 @@ from plane.api.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
## End Public Boards
)
@@ -334,6 +347,11 @@ urlpatterns = [
),
name="workspace",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
@@ -467,6 +485,11 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
@@ -808,6 +831,11 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
@@ -1463,4 +1491,128 @@ urlpatterns = [
name="unread-notifications",
),
## End Notification
# Public Boards
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
## End Public Boards
]
+16 -3
View File
@@ -12,8 +12,12 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
)
from .people import (
from .user import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
@@ -47,6 +51,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@@ -73,8 +78,12 @@ from .issue import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
from .auth_extended import (
@@ -142,7 +151,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
AnalyticsEndpoint,
@@ -152,4 +161,8 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)
+5 -5
View File
@@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
)
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
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")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)
+1 -1
View File
@@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.api.serializers.people import (
from plane.api.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
)
+37 -15
View File
@@ -56,6 +56,10 @@ class CycleViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
# def get_serializer_class(self):
# return (
# CycleSerializer(nested_fields={"owned_by":("id", "first_name", "last_name", "avatar", "is_bot", "display_name")})
# )
def perform_create(self, serializer):
serializer.save(
@@ -148,13 +152,15 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
queryset=User.objects.only(
"avatar", "first_name", "id").distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only("name", "color", "id").distinct(),
queryset=Label.objects.only(
"name", "color", "id").distinct(),
)
)
.order_by("-is_favorite", "name")
@@ -165,11 +171,15 @@ class CycleViewSet(BaseViewSet):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
# All Cycles
if cycle_view == "all":
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], many=True).data, status=status.HTTP_200_OK
)
# Current Cycle
@@ -179,7 +189,8 @@ class CycleViewSet(BaseViewSet):
end_date__gte=timezone.now(),
)
data = CycleSerializer(queryset, many=True).data
data = CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], many=True).data
if len(data):
assignee_distribution = (
@@ -253,14 +264,16 @@ class CycleViewSet(BaseViewSet):
if cycle_view == "upcoming":
queryset = queryset.filter(start_date__gt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], 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
CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], many=True).data, status=status.HTTP_200_OK
)
# Draft Cycles
@@ -271,16 +284,19 @@ class CycleViewSet(BaseViewSet):
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], 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),
Q(end_date__gte=timezone.now().date()) | Q(
end_date__isnull=True),
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}], many=True).data, status=status.HTTP_200_OK
)
return Response(
@@ -303,7 +319,8 @@ class CycleViewSet(BaseViewSet):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleSerializer(data=request.data)
serializer = CycleSerializer(data=request.data, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}])
if serializer.is_valid():
serializer.save(
project_id=project_id,
@@ -339,7 +356,8 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -370,7 +388,8 @@ class CycleViewSet(BaseViewSet):
.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(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
@@ -414,7 +433,8 @@ class CycleViewSet(BaseViewSet):
.order_by("label_name")
)
data = CycleSerializer(queryset).data
data = CycleSerializer(queryset, fields=["id", "created_by", "created_at", "updated_at", "updated_by", "project", "workspace", "name", "description", "start_date", "end_date", "owned_by", "view_props", "sort_order", "is_favorite", "total_issues", "cancelled_issues", "completed_issues", "started_issues", "unstarted_issues"
, "backlog_issues", "assignees", "labels", "total_estimates", "completed_estimates", "started_estimates",{"workspace_detail": ["id", "name","slug"]},{ "project_detail": ["id", "name", "cover_image", "icon_prop", "emoji", "description"]}, {"owned_by": ["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}]).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
@@ -482,7 +502,8 @@ class CycleIssueViewSet(BaseViewSet):
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.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")
@@ -508,7 +529,8 @@ class CycleIssueViewSet(BaseViewSet):
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.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")
+114 -10
View File
@@ -13,7 +13,6 @@ from plane.db.models import Project, Estimate, EstimatePoint
from plane.api.serializers import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
)
@@ -51,10 +50,38 @@ class BulkEstimatePointEndpoint(BaseViewSet):
def list(self, request, slug, project_id):
try:
estimates = Estimate.objects.filter(
workspace__slug=slug, project_id=project_id
).prefetch_related("points").select_related("workspace", "project")
serializer = EstimateReadSerializer(estimates, many=True)
estimates = (
Estimate.objects.filter(workspace__slug=slug, project_id=project_id)
.prefetch_related("points")
.select_related("workspace", "project")
)
serializer = EstimateSerializer(
estimates,
fields=[
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
"name",
"points",
"description",
],
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -79,7 +106,31 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
estimate_serializer = EstimateSerializer(
data=request.data.get("estimate"),
fields=[
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
"name",
"description",
],
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -137,7 +188,32 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id
)
serializer = EstimateReadSerializer(estimate)
serializer = EstimateSerializer(
estimate,
fields=[
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
"name",
"points",
"description",
],
)
return Response(
serializer.data,
status=status.HTTP_200_OK,
@@ -170,7 +246,31 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(pk=estimate_id)
estimate_serializer = EstimateSerializer(
estimate, data=request.data.get("estimate"), partial=True
estimate,
data=request.data.get("estimate"),
fields=[
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
"name",
"description",
],
partial=True,
)
if not estimate_serializer.is_valid():
return Response(
@@ -211,7 +311,9 @@ class BulkEstimatePointEndpoint(BaseViewSet):
try:
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10,
updated_estimate_points,
["value"],
batch_size=10,
)
except IntegrityError as e:
return Response(
@@ -219,7 +321,9 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
estimate_point_serializer = EstimatePointSerializer(
estimate_points, many=True
)
return Response(
{
"estimate": estimate_serializer.data,
+124
View File
@@ -0,0 +1,124 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseAPIView
from plane.api.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.api.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
def post(self, request, slug):
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
provider = request.data.get("provider", False)
multiple = request.data.get("multiple", False)
project_ids = request.data.get("project", [])
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
exporter = ExporterHistory.objects.create(
workspace=workspace,
project=project_ids,
initiated_by=request.user,
provider=provider,
)
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
project_ids=project_ids,
token_id=exporter.token,
multiple=multiple,
slug=slug,
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": f"Provider '{provider}' not found."},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace 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 get(self, request, slug):
try:
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
exporter_history,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"initiated_by",
"status",
"url",
"token",
"project",
"provider",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
],
many=True,
).data,
)
else:
return Response(
{"error": "per_page and cursor are required"},
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,
)
+21 -3
View File
@@ -14,7 +14,7 @@ from django.conf import settings
from .base import BaseAPIView
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.api.serializers import ProjectSerializer, WorkSpaceSerializer
class GPTIntegrationEndpoint(BaseAPIView):
@@ -57,8 +57,26 @@ class GPTIntegrationEndpoint(BaseAPIView):
{
"response": text,
"response_html": text_html,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
"project_detail": ProjectSerializer(
project,
fields=[
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
"created_by",
"updated_by",
"created_at",
"updated_at",
],
read_only=True,
).data,
"workspace_detail": WorkSpaceSerializer(
workspace,
fields=["id", "name", "slug"],
).data,
},
status=status.HTTP_200_OK,
)
+168 -6
View File
@@ -42,7 +42,6 @@ from plane.utils.html_processor import strip_tags
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
try:
if service == "github":
@@ -177,7 +176,47 @@ class ImportServiceEndpoint(BaseAPIView):
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
serializer = ImporterSerializer(
importer,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"workspace",
"project",
"service",
"status",
"initiated_by",
"metadata",
"config",
"data",
"token",
"imported_data",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
],
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
if service == "jira":
@@ -213,7 +252,47 @@ class ImportServiceEndpoint(BaseAPIView):
)
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
serializer = ImporterSerializer(
importer,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"workspace",
"project",
"service",
"status",
"initiated_by",
"metadata",
"config",
"data",
"token",
"imported_data",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
],
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
@@ -243,7 +322,48 @@ class ImportServiceEndpoint(BaseAPIView):
.order_by("-created_at")
.select_related("initiated_by", "project", "workspace")
)
serializer = ImporterSerializer(imports, many=True)
serializer = ImporterSerializer(
imports,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"workspace",
"project",
"service",
"status",
"initiated_by",
"metadata",
"config",
"data",
"token",
"imported_data",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
],
many=True,
)
return Response(serializer.data)
except Exception as e:
capture_exception(e)
@@ -284,7 +404,49 @@ class ImportServiceEndpoint(BaseAPIView):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(importer, data=request.data, partial=True)
serializer = ImporterSerializer(
importer,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"workspace",
"project",
"service",
"status",
"initiated_by",
"metadata",
"config",
"data",
"token",
"imported_data",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
],
data=request.data,
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -458,7 +620,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
+613 -20
View File
@@ -15,7 +15,6 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Project,
Inbox,
InboxIssue,
Issue,
@@ -23,6 +22,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
ProjectDeployBoard,
)
from plane.api.serializers import (
IssueSerializer,
@@ -152,7 +152,22 @@ class InboxIssueViewSet(BaseViewSet):
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
issues_data = IssueStateInboxSerializer(
issues,
fields=[
{
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
}
],
many=True,
).data
return Response(
issues_data,
status=status.HTTP_200_OK,
@@ -222,7 +237,19 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
serializer = IssueStateInboxSerializer(
issue,
nested_fields={
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -237,10 +264,17 @@ class InboxIssueViewSet(BaseViewSet):
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)
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)
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)
@@ -251,15 +285,99 @@ class InboxIssueViewSet(BaseViewSet):
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
# viewers and guests since only viewers and guests
# 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)
"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
issue,
data=issue_data,
fields=[
{
"created_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
{
"state_detail": [
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"description",
"color",
"slug",
"sequence",
"group",
"default",
"project",
"workspace",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
"assignees_list",
"blockers_list",
"labels_list",
"blocks_list",
"PRIORITY_CHOICES",
"parent",
"state",
"estimate_point",
"name",
"description",
"description_html",
"description_stripped",
"priority",
"start_date",
"target_date",
"assignees",
"sequence_id",
"labels",
"sort_order",
"completed_at",
"archived_at",
],
partial=True,
)
if issue_serializer.is_valid():
@@ -287,7 +405,30 @@ class InboxIssueViewSet(BaseViewSet):
# Only project admins and members can edit inbox issue attributes
if project_member.role > 10:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
inbox_issue,
fields=[
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
"id",
"created_at",
"updated_at",
"inbox",
"issue",
"status",
"snoozed_till",
"duplicate_to",
"source",
],
data=request.data,
partial=True,
)
if serializer.is_valid():
@@ -300,7 +441,9 @@ class InboxIssueViewSet(BaseViewSet):
project_id=project_id,
)
state = State.objects.filter(
group="cancelled", workspace__slug=slug, project_id=project_id
group="cancelled",
workspace__slug=slug,
project_id=project_id,
).first()
if state is not None:
issue.state = state
@@ -318,7 +461,9 @@ class InboxIssueViewSet(BaseViewSet):
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
workspace__slug=slug,
project_id=project_id,
default=True,
).first()
if state is not None:
issue.state = state
@@ -327,7 +472,9 @@ class InboxIssueViewSet(BaseViewSet):
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)
return Response(
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
@@ -348,7 +495,19 @@ class InboxIssueViewSet(BaseViewSet):
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
serializer = IssueStateInboxSerializer(
issue,
nested_fields={
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
@@ -363,18 +522,452 @@ class InboxIssueViewSet(BaseViewSet):
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)
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)
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)
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,
)
)
class InboxIssuePublicViewSet(BaseViewSet):
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if project_deploy_board.inbox is None:
return Response(
{"error": "Inbox is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
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,
nested_fields={
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
many=True,
).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board 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 create(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if project_deploy_board.inbox is None:
return Response(
{"error": "Inbox is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
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,
nested_fields={
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
)
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:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if project_deploy_board.inbox is None:
return Response(
{"error": "Inbox is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
if 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)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# 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,
fields=[
{
"created_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
{
"state_detail": [
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"description",
"color",
"slug",
"sequence",
"group",
"default",
"project",
"workspace",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
"assignees_list",
"blockers_list",
"labels_list",
"blocks_list",
"PRIORITY_CHOICES",
"parent",
"state",
"estimate_point",
"name",
"description",
"description_html",
"description_stripped",
"priority",
"start_date",
"target_date",
"assignees",
"sequence_id",
"labels",
"sort_order",
"completed_at",
"archived_at",
],
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()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
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:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if project_deploy_board.inbox is None:
return Response(
{"error": "Inbox is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
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,
nested_fields={
"assignee_details": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
)
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:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if project_deploy_board.inbox is None:
return Response(
{"error": "Inbox is not enabled for this Project Board"},
status=status.HTTP_400_BAD_REQUEST,
)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
if 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,
)
+15 -1
View File
@@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
serializer = SlackProjectSyncSerializer(data=request.data)
@@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
+608 -15
View File
@@ -48,6 +48,7 @@ from plane.api.serializers import (
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
@@ -70,6 +71,8 @@ from plane.db.models import (
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -168,7 +171,6 @@ class IssueViewSet(BaseViewSet):
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
print(filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
@@ -279,6 +281,86 @@ class IssueViewSet(BaseViewSet):
serializer = IssueCreateSerializer(
data=request.data,
fields=[
{
"created_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
{
"state_detail": [
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"description",
"color",
"slug",
"sequence",
"group",
"default",
"project",
"workspace",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
"id",
"created_at",
"created_by",
"updated_at",
"updated_by",
"workspace",
"project",
"assignees_list",
"blockers_list",
"labels_list",
"blocks_list",
"PRIORITY_CHOICES",
"parent",
"state",
"estimate_point",
"name",
"description",
"description_html",
"description_stripped",
"priority",
"start_date",
"target_date",
"assignees",
"sequence_id",
"labels",
"sort_order",
"completed_at",
"archived_at",
],
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
@@ -361,8 +443,14 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
)
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -485,8 +573,34 @@ class IssueActivityEndpoint(BaseAPIView):
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
issue_activities = IssueActivitySerializer(
issue_activities,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
many=True,
).data
issue_comments = IssueCommentSerializer(
issue_comments,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
many=True,
).data
result_list = sorted(
chain(issue_activities, issue_comments),
@@ -514,6 +628,9 @@ class IssueCommentViewSet(BaseViewSet):
"workspace__id",
]
# def get_serializer_class(self):
# return IssueCommentSerializer(nested_fields={"actor_detail":("id", "first_name", "last_name", "avatar", "is_bot", "display_name")})
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
@@ -542,7 +659,19 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueCommentSerializer(current_instance).data,
IssueCommentSerializer(
current_instance,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
).data,
cls=DjangoJSONEncoder,
),
)
@@ -563,7 +692,19 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueCommentSerializer(current_instance).data,
IssueCommentSerializer(
current_instance,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
).data,
cls=DjangoJSONEncoder,
),
)
@@ -743,21 +884,27 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
state_distribution = (
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
.annotate(
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
State.objects.filter(
workspace__slug=slug, state_issue__parent_id=issue_id
)
.order_by("group")
.values("group", "state_count")
.annotate(state_group=F("group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
result = {item["group"]: item["state_count"] for item in state_distribution}
result = {
item["state_group"]: item["state_count"] for item in state_distribution
}
serializer = IssueLiteSerializer(
sub_issues,
@@ -1445,3 +1592,449 @@ class CommentReactionViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
else:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(
data=request.data,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, 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 partial_update(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer(
comment,
data=request.data,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
partial=True,
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
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=json.dumps(
IssueCommentSerializer(
comment,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
).data,
cls=DjangoJSONEncoder,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(
comment,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
).data,
cls=DjangoJSONEncoder,
),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent 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 IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
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"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board 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 destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction 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,
)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board 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 destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction 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,
)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, issue_id):
try:
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.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 destroy(self, request, slug, project_id, issue_id):
try:
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
issue_export_task.delay(
email=request.user.email,
data=request.data,
slug=slug,
exporter_name=request.user.first_name,
)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
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,
)
+5 -2
View File
@@ -53,6 +53,8 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
@@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
filter=Q(issue_module__issue__state__group="backlog"),
)
)
.order_by("-is_favorite", "name")
.order_by(order_by, "name")
)
def perform_destroy(self, instance):
@@ -173,8 +175,9 @@ class ModuleViewSet(BaseViewSet):
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
+1 -1
View File
@@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"{request.user.email} created the issue from {page_block.name} block",
comment=f"created the issue from {page_block.name} block",
verb="created",
)
+375 -12
View File
@@ -5,7 +5,21 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.core.validators import validate_email
from django.conf import settings
@@ -13,6 +27,7 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports
@@ -23,9 +38,16 @@ from plane.api.serializers import (
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from plane.api.permissions import ProjectBasePermission
from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
)
from plane.db.models import (
Project,
@@ -48,9 +70,17 @@ from plane.db.models import (
IssueAssignee,
ModuleMember,
Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet):
@@ -61,10 +91,10 @@ class ProjectViewSet(BaseViewSet):
ProjectBasePermission,
]
def get_serializer_class(self, *args, **kwargs):
if self.action == "update" or self.action == "partial_update":
return ProjectSerializer
return ProjectDetailSerializer
# def get_serializer_class(self, *args, **kwargs):
# if self.action == "update" or self.action == "partial_update":
# return ProjectSerializer(fields=['id', {'workspace_detail': ["id", "name", "slug"]}, 'created_at', 'updated_at', 'name', 'description', 'description_text', 'description_html', 'network', 'identifier', 'emoji', 'icon_prop', 'module_view', 'cycle_view', 'issue_views_view', 'page_view', 'inbox_view', 'cover_image', 'archive_in', 'close_in', 'created_by', 'updated_by', 'workspace', 'default_assignee', 'project_lead', 'estimate', 'default_state', 'sort_order'],)
# return ProjectDetailSerializer
def get_queryset(self):
subquery = ProjectFavorite.objects.filter(
@@ -92,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
)
)
.annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -109,6 +141,20 @@ class ProjectViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct()
)
@@ -170,7 +216,38 @@ class ProjectViewSet(BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
data={**request.data},
fields=[
"id",
{"workspace_detail": ["id", "name", "slug"]},
"created_at",
"updated_at",
"name",
"description",
"description_text",
"description_html",
"network",
"identifier",
"emoji",
"icon_prop",
"module_view",
"cycle_view",
"issue_views_view",
"page_view",
"inbox_view",
"cover_image",
"archive_in",
"close_in",
"created_by",
"updated_by",
"workspace",
"default_assignee",
"project_lead",
"estimate",
"default_state",
"sort_order",
],
context={"workspace_id": workspace.id},
)
if serializer.is_valid():
serializer.save()
@@ -180,7 +257,9 @@ class ProjectViewSet(BaseViewSet):
project_id=serializer.data["id"], member=request.user, role=20
)
if serializer.data["project_lead"] is not None:
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
@@ -282,6 +361,36 @@ class ProjectViewSet(BaseViewSet):
serializer = ProjectSerializer(
project,
data={**request.data},
fields=[
"id",
{"workspace_detail": ["id", "name", "slug"]},
"created_at",
"updated_at",
"name",
"description",
"description_text",
"description_html",
"network",
"identifier",
"emoji",
"icon_prop",
"module_view",
"cycle_view",
"issue_views_view",
"page_view",
"inbox_view",
"cover_image",
"archive_in",
"close_in",
"created_by",
"updated_by",
"workspace",
"default_assignee",
"project_lead",
"estimate",
"default_state",
"sort_order",
],
context={"workspace_id": workspace.id},
partial=True,
)
@@ -347,7 +456,9 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email)
# Check if user is already a member of workspace
if ProjectMember.objects.filter(
project_id=project_id, member__email=email
project_id=project_id,
member__email=email,
member__is_bot=False,
).exists():
return Response(
{"error": "User is already member of workspace"},
@@ -451,14 +562,14 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -984,3 +1095,255 @@ class ProjectFavoritesViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def create(self, request, slug, project_id):
try:
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
views = request.data.get(
"views",
{
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
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 ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, 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 ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board does not exists"},
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 ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.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)
issues = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
# Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, 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,
)
+64 -3
View File
@@ -42,7 +42,36 @@ class StateViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
serializer = StateSerializer(data=request.data)
serializer = StateSerializer(
data=request.data,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"description",
"color",
"slug",
"sequence",
"group",
"default",
"project",
"workspace",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
],
)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -62,7 +91,37 @@ class StateViewSet(BaseViewSet):
def list(self, request, slug, project_id):
try:
state_dict = dict()
states = StateSerializer(self.get_queryset(), many=True).data
states = StateSerializer(
self.get_queryset(),
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"description",
"color",
"slug",
"sequence",
"group",
"default",
"project",
"workspace",
{"workspace_detail": ["id", "name", "slug"]},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
],
many=True,
).data
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
@@ -82,7 +141,9 @@ class StateViewSet(BaseViewSet):
try:
state = State.objects.get(
~Q(name="Triage"),
pk=pk, project_id=project_id, workspace__slug=slug,
pk=pk,
project_id=project_id,
workspace__slug=slug,
)
if state.default:
@@ -147,7 +147,18 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
issue_activities, many=True
issue_activities,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
many=True,
).data,
)
except Exception as e:
+7
View File
@@ -19,6 +19,7 @@ from plane.db.models import (
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
)
from plane.utils.issue_filters import issue_filters
@@ -77,6 +78,12 @@ class ViewIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
+261 -26
View File
@@ -42,7 +42,7 @@ from plane.api.serializers import (
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
UserLiteSerializer,
UserSerializer,
ProjectMemberSerializer,
WorkspaceThemeSerializer,
IssueActivitySerializer,
@@ -106,7 +106,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self):
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -161,8 +163,8 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
## Handling unique integrity error for now
## TODO: Extend this to handle other common errors which are not automatically handled by APIException
# Handling unique integrity error for now
# TODO: Extend this to handle other common errors which are not automatically handled by APIException
except IntegrityError as e:
if "already exists" in str(e):
return Response(
@@ -191,7 +193,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request):
try:
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -295,7 +299,31 @@ class InviteWorkspaceEndpoint(BaseAPIView):
{
"error": "Some users are already member of workspace",
"workspace_users": WorkSpaceMemberSerializer(
workspace_members, many=True
workspace_members,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"logo",
"owner",
"slug",
"organization_size",
{"workspace": ["id", "name", "slug"]},
{
"member": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
],
many=True,
).data,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -545,7 +573,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -586,7 +614,32 @@ class WorkSpaceMemberViewSet(BaseViewSet):
)
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
workspace_member,
data=request.data,
partial=True,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"logo",
"owner",
"slug",
"organization_size",
{"workspace": ["id", "name", "slug"]},
{
"member": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
],
)
if serializer.is_valid():
@@ -624,7 +677,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20
workspace__slug=slug,
role=20,
member__is_bot=False,
).count()
== 1
):
@@ -690,7 +745,7 @@ class TeamMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -718,7 +773,18 @@ class TeamMemberViewSet(BaseViewSet):
users = list(set(request.data.get("members", [])).difference(members))
users = User.objects.filter(pk__in=users)
serializer = UserLiteSerializer(users, many=True)
serializer = UserSerializer(
users,
fields=[
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
many=True,
)
return Response(
{
"error": f"{len(users)} of the member(s) are not a part of the workspace",
@@ -784,14 +850,95 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
)
workspace = Workspace.objects.get(pk=last_workspace_id)
workspace_serializer = WorkSpaceSerializer(workspace)
workspace_serializer = WorkSpaceSerializer(
workspace,
fields=[
"id",
{
"owner": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
"created_at",
"updated_at",
"name",
"logo",
"slug",
"organization_size",
"created_by",
"updated_by",
],
)
project_member = ProjectMember.objects.filter(
workspace_id=last_workspace_id, member=request.user
).select_related("workspace", "project", "member", "workspace__owner")
project_member_serializer = ProjectMemberSerializer(
project_member, many=True
project_member,
fields=[
"id",
{
"workspace": [
"id",
{
"owner": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
"created_at",
"updated_at",
"name",
"logo",
"slug",
"organization_size",
"created_by",
"updated_by",
]
},
{
"project": [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{
"member": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
"created_at",
"updated_at",
"comment",
"role",
"view_props",
"default_props",
"preferences",
"sort_order",
"created_by",
"updated_by",
],
many=True,
)
return Response(
@@ -818,7 +965,32 @@ class WorkspaceMemberUserEndpoint(BaseAPIView):
workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug
)
serializer = WorkSpaceMemberSerializer(workspace_member)
serializer = WorkSpaceMemberSerializer(
workspace_member,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"logo",
"owner",
"slug",
"organization_size",
{"workspace": ["id", "name", "slug"]},
{
"member": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
],
)
return Response(serializer.data, status=status.HTTP_200_OK)
except (Workspace.DoesNotExist, WorkspaceMember.DoesNotExist):
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
@@ -987,11 +1159,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(),
start_date__gte=timezone.now(),
workspace__slug=slug,
assignees__in=[request.user],
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
).values("id", "name", "workspace__slug", "project_id", "start_date")
return Response(
{
@@ -1048,7 +1220,6 @@ class WorkspaceThemeViewSet(BaseViewSet):
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
@@ -1146,14 +1317,18 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
return Response(
@@ -1166,7 +1341,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
"pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles,
"upcoming_cycles": upcoming_cycles,
}
)
except Exception as e:
@@ -1184,7 +1359,6 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
@@ -1200,7 +1374,18 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
issue_activities, many=True
issue_activities,
nested_fields={
"actor_detail": (
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
)
},
many=True,
).data,
)
except Exception as e:
@@ -1212,12 +1397,13 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user)
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
projects = []
if requesting_workspace_member.role >= 10:
projects = (
@@ -1227,7 +1413,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
)
.annotate(
created_issues=Count(
"project_issue", filter=Q(project_issue__created_by_id=user_id)
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
)
)
.annotate(
@@ -1282,6 +1469,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"cover_image": user_data.cover_image,
"date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone,
"display_name": user_data.display_name,
},
},
status=status.HTTP_200_OK,
@@ -1405,7 +1593,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
# Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
@@ -1439,3 +1627,50 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
try:
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serializer = WorkSpaceMemberSerializer(
workspace_members,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"name",
"logo",
"owner",
"slug",
"organization_size",
{"workspace": ["id", "name", "slug"]},
{
"member": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
},
],
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,
)
+11 -11
View File
@@ -21,7 +21,7 @@ row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__email": "Assignee Name",
"assignees__display_name": "Assignee Name",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
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")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
if segment:
@@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]:
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__id"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
+346
View File
@@ -0,0 +1,346 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
zipf.writestr(filename, file_content)
zip_buffer.seek(0)
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
exporter_instance.key = file_name
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
def generate_table_row(issue):
return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["priority"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
issue["labels__name"],
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
]
def generate_json_row(issue):
return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Priority": issue["priority"],
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
}
def update_json_row(rows, row):
matched_index = next(
(
index
for index, existing_row in enumerate(rows)
if existing_row["ID"] == row["ID"]
),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
rows = [
header,
]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
.order_by("project__identifier","sequence_id")
.distinct()
)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
]
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
project_id,
issues,
files,
)
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
workspace_id,
workspace_issues,
files,
)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -0,0 +1,38 @@
# Python imports
import boto3
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
from botocore.client import Config
# Module imports
from plane.db.models import ExporterHistory
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
+41 -1
View File
@@ -161,7 +161,47 @@ def service_importer(service, importer_id):
if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"}
import_data_json = json.dumps(
ImporterSerializer(importer).data,
ImporterSerializer(
importer,
fields=[
"id",
"created_by",
"created_at",
"updated_at",
"updated_by",
"workspace",
"project",
"service",
"status",
"initiated_by",
"metadata",
"config",
"data",
"token",
"imported_data",
{
"initiated_by_detail": [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
],
},
{
"project_detail": [
"id",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
},
{"workspace_detail": ["id", "name", "slug"]},
],
).data,
cls=DjangoJSONEncoder,
)
res = requests.post(
+60 -55
View File
@@ -48,7 +48,7 @@ def track_name(
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
comment=f"updated the name to {requested_data.get('name')}",
)
)
@@ -75,7 +75,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
@@ -95,7 +95,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
@@ -123,7 +123,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
comment=f"updated the priority to None",
)
)
else:
@@ -137,7 +137,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
comment=f"updated the priority to {requested_data.get('priority')}",
)
)
@@ -165,7 +165,7 @@ def track_state(
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
@@ -184,19 +184,24 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
last_activity.created_at = timezone.now()
last_activity.save(update_fields=["created_at"])
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
@@ -220,7 +225,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
comment=f"updated the target date to None",
)
)
else:
@@ -234,7 +239,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
comment=f"updated the target date to {requested_data.get('target_date')}",
)
)
@@ -260,7 +265,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
comment=f"updated the start date to None",
)
)
else:
@@ -274,7 +279,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
comment=f"updated the start date to {requested_data.get('start_date')}",
)
)
@@ -303,7 +308,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
@@ -324,7 +329,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
@@ -353,12 +358,12 @@ def track_assignees(
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
new_value=assignee.display_name,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
)
)
@@ -374,13 +379,13 @@ def track_assignees(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
old_value=assignee.display_name,
new_value="",
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
)
)
@@ -419,7 +424,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -441,7 +446,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -481,7 +486,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -503,7 +508,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -517,7 +522,7 @@ def create_issue_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created the issue",
comment=f"created the issue",
verb="created",
actor=actor,
)
@@ -539,7 +544,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to None",
comment=f"updated the estimate point to None",
)
)
else:
@@ -553,7 +558,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
)
)
@@ -567,7 +572,7 @@ def track_archive_at(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} has restored the issue",
comment=f"has restored the issue",
verb="updated",
actor=actor,
field="archived_at",
@@ -661,7 +666,7 @@ def delete_issue_activity(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the issue",
comment=f"deleted the issue",
verb="deleted",
actor=actor,
field="issue",
@@ -682,7 +687,7 @@ def create_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a comment",
comment=f"created a comment",
verb="created",
actor=actor,
field="comment",
@@ -707,7 +712,7 @@ def update_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a comment",
comment=f"updated a comment",
verb="updated",
actor=actor,
field="comment",
@@ -728,7 +733,7 @@ def delete_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the comment",
comment=f"deleted the comment",
verb="deleted",
actor=actor,
field="comment",
@@ -766,7 +771,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
@@ -787,7 +792,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
@@ -816,7 +821,7 @@ def delete_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
)
)
@@ -852,7 +857,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
@@ -872,7 +877,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
comment=f"added module {module.name}",
new_identifier=module.id,
)
)
@@ -901,7 +906,7 @@ def delete_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
)
)
@@ -920,7 +925,7 @@ def create_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a link",
comment=f"created a link",
verb="created",
actor=actor,
field="link",
@@ -944,7 +949,7 @@ def update_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a link",
comment=f"updated a link",
verb="updated",
actor=actor,
field="link",
@@ -969,7 +974,7 @@ def delete_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the link",
comment=f"deleted the link",
verb="deleted",
actor=actor,
field="link",
@@ -992,7 +997,7 @@ def create_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created an attachment",
comment=f"created an attachment",
verb="created",
actor=actor,
field="attachment",
@@ -1010,7 +1015,7 @@ def delete_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the attachment",
comment=f"deleted the attachment",
verb="deleted",
actor=actor,
field="attachment",
@@ -1098,7 +1103,7 @@ def issue_activity(
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
IssueActivitySerializer(issue_activity,fields=[{"actor_detail":["id", "first_name", "last_name", "avatar", "is_bot", "display_name"]}]).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
+4
View File
@@ -20,6 +20,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -0,0 +1,243 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
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)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, 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')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', 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_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
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)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('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')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
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)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
('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='votes', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.AlterField(
model_name='modulelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]
+5 -1
View File
@@ -18,6 +18,7 @@ from .project import (
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
from .issue import (
@@ -36,6 +37,7 @@ from .issue import (
IssueSubscriber,
IssueReaction,
CommentReaction,
IssueVote,
)
from .asset import FileAsset
@@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification
from .notification import Notification
from .exporter import ExporterHistory
+12
View File
@@ -17,6 +17,7 @@ class Cycle(ProjectBaseModel):
related_name="owned_by_cycle",
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Cycle"
@@ -24,6 +25,17 @@ class Cycle(ProjectBaseModel):
db_table = "cycles"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Cycle, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the cycle"""
return f"{self.name} <{self.project.name}>"
+56
View File
@@ -0,0 +1,56 @@
import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Module imports
from . import BaseModel
def generate_token():
return uuid4().hex
class ExporterHistory(BaseModel):
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
)
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
provider = models.CharField(
max_length=50,
choices=(
("json", "json"),
("csv", "csv"),
("xlsx", "xlsx"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
)
class Meta:
verbose_name = "Exporter"
verbose_name_plural = "Exporters"
db_table = "exporters"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"
+35 -13
View File
@@ -108,11 +108,7 @@ class Issue(ProjectBaseModel):
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state
except ImportError:
pass
@@ -127,8 +123,6 @@ class Issue(ProjectBaseModel):
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
elif self.state.group == "started":
self.start_date = timezone.now().date()
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
@@ -153,9 +147,6 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser
self.description_stripped = (
None
@@ -310,6 +301,14 @@ class IssueComment(ProjectBaseModel):
related_name="comments",
null=True,
)
access = models.CharField(
choices=(
("INTERNAL", "INTERNAL"),
("EXTERNAL", "EXTERNAL"),
),
default="INTERNAL",
max_length=100,
)
def save(self, *args, **kwargs):
self.comment_stripped = (
@@ -425,13 +424,14 @@ class IssueSubscriber(ProjectBaseModel):
class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_reactions",
)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -446,13 +446,14 @@ class IssueReaction(ProjectBaseModel):
class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comment_reactions",
)
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -466,6 +467,27 @@ class CommentReaction(ProjectBaseModel):
return f"{self.issue.name} {self.actor.email}"
class IssueVote(ProjectBaseModel):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
)
vote = models.IntegerField(
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
)
class Meta:
unique_together = ["issue", "actor"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
+13 -1
View File
@@ -40,6 +40,7 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"),
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["name", "project"]
@@ -48,6 +49,17 @@ class Module(ProjectBaseModel):
db_table = "modules"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Module, self).save(*args, **kwargs)
def __str__(self):
return f"{self.name} {self.start_date} {self.target_date}"
@@ -86,7 +98,7 @@ class ModuleIssue(ProjectBaseModel):
class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
title = models.CharField(max_length=255, blank=True, null=True)
url = models.URLField()
module = models.ForeignKey(
Module, on_delete=models.CASCADE, related_name="link_module"
+43 -6
View File
@@ -1,3 +1,6 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
@@ -31,12 +34,9 @@ def get_default_props():
"showEmptyGroups": True,
}
def get_default_preferences():
return {
"pages": {
"block_display": True
}
}
return {"pages": {"block_display": True}}
class Project(BaseModel):
@@ -157,7 +157,6 @@ class ProjectMember(ProjectBaseModel):
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
@@ -217,3 +216,41 @@ class ProjectFavorite(ProjectBaseModel):
def __str__(self):
"""Return user of the project"""
return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
def get_default_views():
return {
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
}
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
comments = models.BooleanField(default=False)
reactions = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
)
votes = models.BooleanField(default=False)
views = models.JSONField(default=get_default_views)
class Meta:
unique_together = ["project", "anchor"]
verbose_name = "Project Deploy Board"
verbose_name_plural = "Project Deploy Boards"
db_table = "project_deploy_boards"
ordering = ("-created_at",)
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
+12 -1
View File
@@ -1,6 +1,7 @@
# Python imports
from enum import unique
import uuid
import string
import random
# Django imports
from django.db import models
@@ -18,6 +19,7 @@ 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,
@@ -26,6 +28,7 @@ def get_default_onboarding():
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -81,6 +84,7 @@ 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)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
@@ -107,6 +111,13 @@ class User(AbstractBaseUser, PermissionsMixin):
self.token = uuid.uuid4().hex + uuid.uuid4().hex
self.token_updated_at = timezone.now()
if not self.display_name:
self.display_name = (
self.email.split("@")[0]
if len(self.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
if self.is_superuser:
self.is_staff = True
+1
View File
@@ -33,6 +33,7 @@ def get_default_props():
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
+1 -1
View File
@@ -214,4 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
+29 -16
View File
@@ -124,10 +124,11 @@ def filter_created_at(params, filter, method):
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = query.get("datetime")
filter["created_at__date__lte"] = created_at_query[0]
return filter
@@ -144,10 +145,11 @@ def filter_updated_at(params, filter, method):
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = query.get("datetime")
filter["updated_at__date__lte"] = updated_at_query[0]
return filter
@@ -164,10 +166,11 @@ def filter_start_date(params, filter, method):
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
if query.get("timeline", "after") == "after":
filter["start_date__gte"] = query.get("datetime")
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = query.get("datetime")
filter["start_date__lte"] = start_date_query[0]
return filter
@@ -184,10 +187,11 @@ def filter_target_date(params, filter, method):
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__gt"] = query.get("datetime")
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
else:
filter["target_date__lt"] = query.get("datetime")
filter["target_date__lt"] = target_date_query[0]
return filter
@@ -205,10 +209,11 @@ def filter_completed_at(params, filter, method):
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = query.get("datetime")
filter["completed_at__lte"] = completed_at_query[0]
return filter
@@ -292,9 +297,16 @@ def filter_subscribed_issues(params, filter, method):
return filter
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method):
filter = dict()
print(query_params)
ISSUE_FILTER = {
"state": filter_state,
@@ -318,6 +330,7 @@ def issue_filters(query_params, method):
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}
for key, value in ISSUE_FILTER.items():
+2 -1
View File
@@ -32,4 +32,5 @@ celery==5.3.1
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
scout-apm==2.26.1
scout-apm==2.26.1
openpyxl==3.1.2
@@ -0,0 +1,9 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>
@@ -14,17 +14,17 @@ type Props = {
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = "";
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
if (params.segment) {
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else if (params.segment === "assignees__email") {
const assignee = analytics.extras.assignee_details.find(
(a) => a.assignees__email === datum.id
);
if (assignee)
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
else tooltipValue = "No assignees";
} else tooltipValue = datum.id;
else tooltipValue = datum.id;
} else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
@@ -49,7 +49,10 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
: ""
}`}
>
{tooltipValue}:
{params.segment === "assignees__id"
? renderAssigneeName(tooltipValue.toString())
: tooltipValue}
:
</span>
<span>{datum.value}</span>
</div>
@@ -29,6 +29,14 @@ export const AnalyticsGraph: React.FC<Props> = ({
yAxisKey,
fullScreen,
}) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "?";
return assignee.assignees__display_name || "?";
};
const generateYAxisTickValues = () => {
if (!analytics) return [];
@@ -70,17 +78,17 @@ export const AnalyticsGraph: React.FC<Props> = ({
height={fullScreen ? "400px" : "300px"}
margin={{
right: 20,
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
bottom: params.x_axis === "assignees__id" ? 50 : longestXAxisLabel.length * 5 + 20,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick:
params.x_axis === "assignees__email"
params.x_axis === "assignees__id"
? (datum) => {
const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__email === datum?.value
(a) => a?.assignees__display_name === datum?.value
)?.assignees__avatar;
if (avatar && avatar !== "")
@@ -101,7 +109,11 @@ export const AnalyticsGraph: React.FC<Props> = ({
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value && datum.value !== "None"
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? renderAssigneeName(datum.value)[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
@@ -277,9 +277,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -305,10 +303,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -22,15 +22,12 @@ type Props = {
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (email: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
if (assignee.assignees__first_name !== "")
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
return email;
return assignee.assignees__display_name || "No assignee";
};
return (
@@ -65,10 +62,10 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: params.segment === "assignees__email"
{params.segment === "assignees__id"
? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: key}
</div>
</th>
@@ -108,7 +105,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{params.x_axis === "assignees__email"
{params.x_axis === "assignees__id"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
@@ -1,22 +1,38 @@
// ui
import { ProfileEmptyState } from "components/ui";
// image
import emptyUsers from "public/empty-state/empty_users.svg";
type Props = {
users: {
avatar: string | null;
email: string | null;
display_name: string | null;
firstName: string;
lastName: string;
count: number;
id: string;
}[];
title: string;
emptyStateMessage: string;
workspaceSlug: string;
};
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
export const AnalyticsLeaderboard: React.FC<Props> = ({
users,
title,
emptyStateMessage,
workspaceSlug,
}) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (
<div className="mt-3 space-y-3">
{users.map((user) => (
<div
key={user.email ?? "None"}
<a
key={user.display_name ?? "None"}
href={`/${workspaceSlug}/profile/${user.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-start justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
@@ -25,24 +41,26 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<img
src={user.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt={user.email ?? "None"}
alt={user.display_name ?? "None"}
/>
</div>
) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
{user.firstName !== "" ? user.firstName[0] : "?"}
{user.display_name !== "" ? user?.display_name?.[0] : "?"}
</div>
)}
<span className="break-words text-custom-text-200">
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
{user.display_name !== "" ? `${user.display_name}` : "No assignee"}
</span>
</div>
<span className="flex-shrink-0">{user.count}</span>
</div>
</a>
))}
</div>
) : (
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
<div className="px-7 py-4">
<ProfileEmptyState title="No Data yet" description={emptyStateMessage} image={emptyUsers} />
</div>
)}
</div>
);
@@ -56,22 +56,28 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
email: user?.created_by__email,
firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name,
display_name: user?.created_by__display_name,
count: user?.count,
id: user?.created_by__id,
}))}
title="Most issues created"
emptyStateMessage="Co-workers and the number issues created by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
email: user?.assignees__email,
firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name,
display_name: user?.assignees__display_name,
count: user?.count,
id: user?.assignees__id,
}))}
title="Most issues closed"
emptyStateMessage="Co-workers and the number issues closed by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<div className={fullScreen ? "md:col-span-2" : ""}>
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
@@ -1,5 +1,7 @@
// ui
import { BarGraph } from "components/ui";
import { BarGraph, ProfileEmptyState } from "components/ui";
// image
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
// types
import { IDefaultAnalyticsResponse } from "types";
@@ -16,23 +18,20 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
{defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email"
indexBy="assignees__display_name"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__email === `${datum.indexValue}`
(a) => a.assignees__display_name === `${datum.indexValue}`
);
return (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span className="font-medium text-custom-text-200">
{assignee
? assignee.assignees__first_name + " " + assignee.assignees__last_name
: "No assignee"}
:{" "}
{assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
</span>
{datum.value}
</div>
@@ -73,8 +72,12 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}}
/>
) : (
<div className="text-custom-text-200 text-center text-sm py-8">
No matching data found.
<div className="px-7 py-4">
<ProfileEmptyState
title="No Data yet"
description="Analysis of pending issues by co-workers appears here."
image={emptyBarGraph}
/>
</div>
)}
</div>
@@ -1,5 +1,7 @@
// ui
import { LineGraph } from "components/ui";
import { LineGraph, ProfileEmptyState } from "components/ui";
// image
import emptyGraph from "public/empty-state/empty_graph.svg";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
@@ -48,7 +50,13 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
enableArea
/>
) : (
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
<div className="px-7 py-4">
<ProfileEmptyState
title="No Data yet"
description="Close issues to view analysis of the same in the form of a graph."
image={emptyGraph}
/>
</div>
)}
</div>
);
@@ -7,12 +7,20 @@ import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons";
import userService from "services/user.service";
import useUser from "hooks/use-user";
// helper
import { unsetCustomCssVariables } from "helpers/theme.helper";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
};
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpen }) => {
const store: any = useMobxStore();
const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme();
@@ -21,27 +29,11 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const updateUserTheme = (newTheme: string) => {
if (!user) return;
setTheme(newTheme);
mutateUser((prevData) => {
if (!prevData) return prevData;
return {
...prevData,
theme: {
...prevData.theme,
theme: newTheme,
},
};
}, false);
userService.updateUser({
theme: {
...user.theme,
theme: newTheme,
},
});
return store.user
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
.then((response: any) => response)
.catch((error: any) => error);
};
// useEffect only runs on the client, so now we can safely show the UI
@@ -70,4 +62,4 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
))}
</>
);
};
});
@@ -354,8 +354,8 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Item
key={item.id}
onSelect={() => {
router.push(currentSection.path(item));
setIsPaletteOpen(false);
router.push(currentSection.path(item));
}}
value={`${key}-${item?.name}`}
className="focus:outline-none"
@@ -379,6 +379,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
@@ -460,6 +461,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Issue">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "c",
});
@@ -479,6 +481,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "p",
});
@@ -500,6 +503,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "q",
});
@@ -517,6 +521,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "m",
});
@@ -534,6 +539,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="View">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "v",
});
@@ -551,6 +557,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "d",
});
@@ -568,11 +575,12 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
{projectDetails && projectDetails.inbox_view && (
<Command.Group heading="Inbox">
<Command.Item
onSelect={() =>
onSelect={() => {
setIsPaletteOpen(false);
redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
)
}
);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
@@ -731,12 +739,21 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import/Export
Import
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
@@ -1,11 +1,8 @@
import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
@@ -24,8 +21,14 @@ import issuesService from "services/issues.service";
import inboxService from "services/inbox.service";
// fetch keys
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { observable } from "mobx";
import { observer } from "mobx-react-lite";
export const CommandPalette: React.FC = observer(() => {
const store: any = useMobxStore();
export const CommandPalette: React.FC = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
@@ -43,13 +46,12 @@ export const CommandPalette: React.FC = () => {
const { user } = useUser();
const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
@@ -74,53 +76,52 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
// if on input, textarea or editor, don't do anything
if (
!(e.target instanceof HTMLTextAreaElement) &&
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
if ((ctrlKey || metaKey) && keyPressed === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "c") {
if (altKey) {
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
(e.target as Element).classList?.contains("ProseMirror")
)
return;
if (cmdClicked) {
if (keyPressed === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if (keyPressed === "c" && altKey) {
e.preventDefault();
copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
e.preventDefault();
store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
}
} else {
if (keyPressed === "c") {
setIsIssueModalOpen(true);
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
} else if (keyPressed === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "b") {
e.preventDefault();
toggleCollapsed();
} else if (key === "Delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if (
singleShortcutKeys.includes(keyPressed) &&
(ctrlKey || metaKey || altKey || shiftKey)
) {
e.preventDefault();
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
}
}
},
[toggleCollapsed, copyIssueUrlToClipboard]
[copyIssueUrlToClipboard]
);
useEffect(() => {
@@ -195,4 +196,4 @@ export const CommandPalette: React.FC = () => {
/>
</>
);
};
})
@@ -34,15 +34,12 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
const options =
members?.map(({ member }) => ({
value: member.id,
query:
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
+52 -4
View File
@@ -35,6 +35,22 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
);
};
const UserLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<a
href={`/${workspaceSlug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center hover:underline"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
);
};
const activityDetails: {
[key: string]: {
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
@@ -46,8 +62,7 @@ const activityDetails: {
if (activity.old_value === "")
return (
<>
added a new assignee{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
added a new assignee <UserLink activity={activity} />
{showIssue && (
<>
{" "}
@@ -60,8 +75,7 @@ const activityDetails: {
else
return (
<>
removed the assignee{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
removed the assignee <UserLink activity={activity} />
{showIssue && (
<>
{" "}
@@ -428,6 +442,40 @@ const activityDetails: {
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
start_date: {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the start date
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the start date to{" "}
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
state: {
message: (activity, showIssue) => (
<>
@@ -157,10 +157,10 @@ export const FiltersList: React.FC<Props> = ({
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
@@ -184,7 +184,7 @@ export const FiltersList: React.FC<Props> = ({
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
@@ -113,49 +113,51 @@ export const IssuesFilterView: React.FC = () => {
))}
</div>
)}
<SelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
{issueView !== "gantt_chart" && (
<SelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(
filters.target_date ?? [],
option.value
);
setFilters({
target_date: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters(
{
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
},
!Boolean(viewId)
if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(
filters.target_date ?? [],
option.value
);
else
setFilters(
{
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
},
!Boolean(viewId)
);
}
}}
direction="left"
height="rg"
/>
setFilters({
target_date: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters(
{
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
},
!Boolean(viewId)
);
else
setFilters(
{
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
},
!Boolean(viewId)
);
}
}}
direction="left"
height="rg"
/>
)}
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
@@ -177,8 +179,9 @@ export const IssuesFilterView: React.FC = () => {
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<div className="w-28">
@@ -206,34 +209,34 @@ export const IssuesFilterView: React.FC = () => {
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
)}
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</>
</div>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
@@ -263,16 +266,19 @@ export const IssuesFilterView: React.FC = () => {
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={showSubIssues}
onChange={() => setShowSubIssues(!showSubIssues)}
/>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={showSubIssues}
onChange={() => setShowSubIssues(!showSubIssues)}
/>
</div>
</div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<div className="w-28">
@@ -282,6 +288,10 @@ export const IssuesFilterView: React.FC = () => {
/>
</div>
</div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
@@ -294,47 +304,48 @@ export const IssuesFilterView: React.FC = () => {
Set as default
</button>
</div>
</>
)}
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
{issueView !== "gantt_chart" && (
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
if (
issueView === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
)
return null;
if (
issueView === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
)
return null;
if (
issueView !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;
if (
issueView !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
)}
</div>
</Popover.Panel>
</Transition>
@@ -27,8 +27,8 @@ const unsplashEnabled =
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
key: "images",
title: "Images",
},
{
key: "upload",
@@ -1,7 +1,6 @@
import { useEffect, useState, forwardRef, useRef } from "react";
import React, { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form
import { useForm } from "react-hook-form";
@@ -15,6 +14,7 @@ import useUserAuth from "hooks/use-user-auth";
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = {
isOpen: boolean;
handleClose: () => void;
@@ -32,17 +32,11 @@ type FormData = {
task: string;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
TiptapEditor.displayName = "TiptapEditor";
export const GptAssistantModal: React.FC<Props> = ({
isOpen,
@@ -151,10 +145,10 @@ export const GptAssistantModal: React.FC<Props> = ({
}`}
>
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm">
<div className="text-sm">
Content:
<WrappedRemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>}
<TiptapEditor
value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3"
noBorder
borderOnFocus={false}
@@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC<Props> = ({
{response !== "" && (
<div className="page-block-section text-sm">
Response:
<RemirrorRichTextEditor
<Tiptap
value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3"
noBorder
@@ -131,10 +131,10 @@ export const ImageUploadModal: React.FC<Props> = ({
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-80 w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
+44 -12
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
@@ -7,12 +7,15 @@ import { Dialog, Transition } from "@headlessui/react";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
import type { IIssueLink, ModuleLink } from "types";
import type { IIssueLink, linkDetails, ModuleLink } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
data?: linkDetails | null;
status: boolean;
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<void>;
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<void>;
};
const defaultValues: IIssueLink | ModuleLink = {
@@ -20,7 +23,14 @@ const defaultValues: IIssueLink | ModuleLink = {
url: "",
};
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
export const LinkModal: React.FC<Props> = ({
isOpen,
handleClose,
createIssueLink,
updateIssueLink,
status,
data,
}) => {
const {
register,
formState: { errors, isSubmitting },
@@ -30,11 +40,6 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
defaultValues,
});
const onSubmit = async (formData: IIssueLink | ModuleLink) => {
await onFormSubmit({ title: formData.title, url: formData.url });
onClose();
};
const onClose = () => {
handleClose();
const timeout = setTimeout(() => {
@@ -43,6 +48,27 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
}, 500);
};
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
onClose();
};
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
@@ -70,14 +96,14 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-200 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div>
<div className="space-y-5">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Add Link
{status ? "Update Link" : "Add Link"}
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
@@ -113,7 +139,13 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"}
{status
? isSubmitting
? "Updating Link..."
: "Update Link"
: isSubmitting
? "Adding Link..."
: "Add Link"}
</PrimaryButton>
</div>
</form>
+18 -12
View File
@@ -1,25 +1,24 @@
// icons
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// types
import { IUserLite, UserAuth } from "types";
import { linkDetails, UserAuth } from "types";
type Props = {
links: {
id: string;
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
metadata: any;
title: string;
url: string;
}[];
links: linkDetails[];
handleDeleteLink: (linkId: string) => void;
handleEditLink: (link: linkDetails) => void;
userAuth: UserAuth;
};
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
export const LinksList: React.FC<Props> = ({
links,
handleDeleteLink,
handleEditLink,
userAuth,
}) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
@@ -28,6 +27,13 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
<div key={link.id} className="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
onClick={() => handleEditLink(link)}
>
<Icon iconName="edit" className="text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
@@ -62,7 +68,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.email}
: link.created_by_detail.display_name}
</p>
</div>
</a>
@@ -133,9 +133,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.first_name}</span>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
@@ -21,13 +21,21 @@ import { ICustomTheme } from "types";
type Props = {
name: keyof ICustomTheme;
position?: "left" | "right";
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
register: UseFormRegister<any>;
};
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => {
export const ColorPickerInput: React.FC<Props> = ({
name,
position = "left",
watch,
setValue,
error,
register,
}) => {
const handleColorChange = (newColor: ColorResult) => {
const { hex } = newColor;
setValue(name, hex);
@@ -104,7 +112,11 @@ export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
<Popover.Panel
className={`absolute bottom-8 z-20 mt-1 max-w-xs px-2 sm:px-0 ${
position === "right" ? "left-0" : "right-0"
}`}
>
<SketchPicker color={watch(name)} onChange={handleColorChange} />
</Popover.Panel>
</Transition>
@@ -4,17 +4,15 @@ import { useTheme } from "next-themes";
import { useForm } from "react-hook-form";
// hooks
import useUser from "hooks/use-user";
// ui
import { PrimaryButton } from "components/ui";
import { ColorPickerInput } from "components/core";
// services
import userService from "services/user.service";
// helper
import { applyTheme } from "helpers/theme.helper";
// types
import { ICustomTheme } from "types";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
preLoadedData?: Partial<ICustomTheme> | null;
@@ -31,9 +29,11 @@ const defaultValues: ICustomTheme = {
theme: "custom",
};
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
const [darkPalette, setDarkPalette] = useState(false);
export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData }) => {
const store: any = useMobxStore();
const { setTheme } = useTheme();
const [darkPalette, setDarkPalette] = useState(false);
const {
register,
formState: { errors, isSubmitting },
@@ -44,11 +44,14 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
} = useForm<ICustomTheme>({
defaultValues,
});
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
const { setTheme } = useTheme();
const { mutateUser } = useUser();
const handleFormSubmit = async (formData: ICustomTheme) => {
const handleUpdateTheme = async (formData: any) => {
const payload: ICustomTheme = {
background: formData.background,
text: formData.text,
@@ -60,34 +63,14 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
theme: "custom",
};
await userService
.updateUser({
theme: payload,
})
.then((res) => {
mutateUser((prevData) => {
if (!prevData) return prevData;
setTheme("custom");
return { ...prevData, ...res };
}, false);
setTheme("custom");
applyTheme(payload.palette, darkPalette);
})
.catch((err) => console.log(err));
return store.user
.updateCurrentUserSettings({ theme: payload })
.then((response: any) => response)
.catch((error: any) => error);
};
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
@@ -100,6 +83,7 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</h3>
<ColorPickerInput
name="background"
position="right"
error={errors.background}
watch={watch}
setValue={setValue}
@@ -137,6 +121,7 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</h3>
<ColorPickerInput
name="sidebarBackground"
position="right"
error={errors.sidebarBackground}
watch={watch}
setValue={setValue}
@@ -166,4 +151,4 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</div>
</form>
);
};
});
+103 -129
View File
@@ -1,9 +1,5 @@
import { useState, useEffect } from "react";
// next-themes
import { useTheme } from "next-themes";
// services
import userService from "services/user.service";
// hooks
import useUser from "hooks/use-user";
// constants
@@ -13,6 +9,10 @@ import { CustomSelect } from "components/ui";
// types
import { ICustomTheme } from "types";
import { unsetCustomCssVariables } from "helpers/theme.helper";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
setPreLoadedData: React.Dispatch<React.SetStateAction<ICustomTheme | null>>;
@@ -20,142 +20,116 @@ type Props = {
setCustomThemeSelectorOptions: React.Dispatch<React.SetStateAction<boolean>>;
};
export const ThemeSwitch: React.FC<Props> = ({
setPreLoadedData,
customThemeSelectorOptions,
setCustomThemeSelectorOptions,
}) => {
const [mounted, setMounted] = useState(false);
export const ThemeSwitch: React.FC<Props> = observer(
({ setPreLoadedData, customThemeSelectorOptions, setCustomThemeSelectorOptions }) => {
const store: any = useMobxStore();
const { theme, setTheme } = useTheme();
const { user, mutateUser } = useUser();
const { theme, setTheme } = useTheme();
const { user, mutateUser } = useUser();
const updateUserTheme = (newTheme: string) => {
if (!user) return;
setTheme(newTheme);
return store.user
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
.then((response: any) => response)
.catch((error: any) => error);
};
const updateUserTheme = (newTheme: string) => {
if (!user) return;
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
setTheme(newTheme);
mutateUser((prevData) => {
if (!prevData) return prevData;
return {
...prevData,
theme: {
...prevData.theme,
theme: newTheme,
},
};
}, false);
userService.updateUser({
theme: {
...user.theme,
theme: newTheme,
},
});
};
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return (
<CustomSelect
value={theme}
label={
currentThemeObj ? (
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: currentThemeObj.icon.border,
}}
>
return (
<CustomSelect
value={theme}
label={
currentThemeObj ? (
<div className="flex items-center gap-2">
<div
className="h-full w-1/2 rounded-l-full"
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
background: currentThemeObj.icon.color1,
borderColor: currentThemeObj.icon.border,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: currentThemeObj.icon.border,
background: currentThemeObj.icon.color2,
}}
/>
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: currentThemeObj.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: currentThemeObj.icon.border,
background: currentThemeObj.icon.color2,
}}
/>
</div>
{currentThemeObj.label}
</div>
{currentThemeObj.label}
</div>
) : (
"Select your theme"
)
}
onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") {
if (user?.theme.palette) {
setPreLoadedData({
background: user.theme.background !== "" ? user.theme.background : "#0d101b",
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
sidebarBackground:
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
darkPalette: false,
palette:
user.theme.palette !== ",,,,"
? user.theme.palette
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
theme: "custom",
});
) : (
"Select your theme"
)
}
onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") {
if (user?.theme.palette) {
setPreLoadedData({
background: user.theme.background !== "" ? user.theme.background : "#0d101b",
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
sidebarBackground:
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
darkPalette: false,
palette:
user.theme.palette !== ",,,,"
? user.theme.palette
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
theme: "custom",
});
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
unsetCustomCssVariables();
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
unsetCustomCssVariables();
}
updateUserTheme(value);
document.documentElement.style.setProperty("--color-scheme", type);
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: icon.border,
}}
>
updateUserTheme(value);
document.documentElement.style.setProperty("--color-scheme", type);
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
<div className="flex items-center gap-2">
<div
className="h-full w-1/2 rounded-l-full"
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
background: icon.color1,
borderColor: icon.border,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: icon.border,
background: icon.color2,
}}
/>
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: icon.border,
background: icon.color2,
}}
/>
</div>
{label}
</div>
{label}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
);
};
</CustomSelect.Option>
))}
</CustomSelect>
);
}
);
@@ -10,7 +10,7 @@ import projectService from "services/project.service";
// hooks
import useProjects from "hooks/use-projects";
// component
import { Avatar } from "components/ui";
import { Avatar, Icon } from "components/ui";
// icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
@@ -81,10 +81,7 @@ export const BoardHeader: React.FC<Props> = ({
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
title = member?.display_name ?? "";
break;
}
@@ -143,24 +140,22 @@ export const BoardHeader: React.FC<Props> = ({
>
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
<div
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${
className={`flex cursor-pointer items-center gap-x-2 max-w-[316px] ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
>
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className="text-lg font-semibold capitalize truncate"
className={`text-lg font-semibold truncate ${
selectedGroup === "created_by" ? "" : "capitalize"
}`}
style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}}
>
{getGroupTitle()}
</h2>
<span
className={`${
isCollapsed ? "ml-0.5" : ""
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
>
<span className={`${isCollapsed ? "ml-0.5" : ""} py-1 text-center text-sm`}>
{groupedIssues?.[groupTitle].length ?? 0}
</span>
</div>
@@ -175,9 +170,12 @@ export const BoardHeader: React.FC<Props> = ({
}}
>
{isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" />
<Icon
iconName="close_fullscreen"
className="text-base font-medium text-custom-text-900"
/>
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
)}
</button>
{!disableUserActions && selectedGroup !== "created_by" && (
@@ -24,6 +24,7 @@ import {
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// ui
@@ -87,6 +88,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDropdownActive, setIsDropdownActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
@@ -124,7 +126,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
);
} else {
mutateIssues(
(prevData) =>
(prevData: any) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
@@ -231,7 +233,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
</a>
</ContextMenu>
<div
className={`mb-3 rounded bg-custom-background-90 shadow ${
className={`mb-3 rounded bg-custom-background-100 shadow ${
snapshot.isDragging ? "border-2 border-custom-primary shadow-lg" : ""
}`}
ref={provided.innerRef}
@@ -244,7 +246,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<div className="group/card relative select-none p-3.5">
<div className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px]">
{!isNotAllowed && (
<div
ref={actionSectionRef}
@@ -294,16 +296,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
<a className="flex flex-col gap-1.5">
{properties.key && (
<div className="mb-2.5 text-xs font-medium text-custom-text-200">
<div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<h5 className="text-sm break-words line-clamp-3">{issue.name}</h5>
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
</a>
</Link>
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
<div
className={`flex items-center gap-2 text-xs ${
isDropdownActive ? "" : "overflow-x-scroll"
}`}
>
{properties.priority && (
<ViewPrioritySelect
issue={issue}
@@ -322,10 +328,22 @@ export const SingleBoardIssue: React.FC<Props> = ({
selfPositioned
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
handleOnOpen={() => setIsDropdownActive(true)}
handleOnClose={() => setIsDropdownActive(false)}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
handleOnOpen={() => setIsDropdownActive(true)}
handleOnClose={() => setIsDropdownActive(false)}
user={user}
isNotAllowed={isNotAllowed}
/>
@@ -338,6 +356,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
customButton
user={user}
selfPositioned
/>
@@ -21,6 +21,7 @@ import {
ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// icons
@@ -230,7 +231,14 @@ export const SingleCalendarIssue: React.FC<Props> = ({
user={user}
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
@@ -16,6 +16,7 @@ import {
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// ui
@@ -107,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
);
} else {
mutateIssues(
(prevData) =>
(prevData: any) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
@@ -244,6 +245,14 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
@@ -96,10 +96,7 @@ export const SingleList: React.FC<Props> = ({
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
title = member?.display_name ?? "";
break;
}
@@ -163,7 +160,11 @@ export const SingleList: React.FC<Props> = ({
<div className="flex items-center">{getGroupIcon()}</div>
)}
{selectedGroup !== null ? (
<h2 className="text-sm font-semibold capitalize leading-6 text-custom-text-100">
<h2
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
selectedGroup === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
@@ -12,6 +12,7 @@ import {
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
import { Popover2 } from "@blueprintjs/popover2";
@@ -315,6 +316,19 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
</div>
)}
{properties.start_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.due_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewDueDateSelect
@@ -361,14 +361,14 @@ export const ActiveCycleDetails: React.FC = () => {
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
{cycle.assignees.length > 0 && (
@@ -88,9 +88,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.first_name}</span>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
@@ -1,21 +1,28 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
import { KeyedMutator } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useUser from "hooks/use-user";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
// types
import { ICycle } from "types";
type Props = {
cycles: ICycle[];
mutateCycles: KeyedMutator<ICycle[]>;
};
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
const { user } = useUser();
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
@@ -28,53 +35,63 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: ICycle }) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return;
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
mutateCycles((prevData: any) => {
if (!prevData) return prevData;
const newList = prevData.map((p: any) => ({
...p,
...(p.id === cycle.id
? {
start_date: payload.start_date ? payload.start_date : p.start_date,
target_date: payload.target_date ? payload.target_date : p.end_date,
sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order,
}
: {}),
}));
if (payload.sort_order) {
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
}
return newList;
}, false);
const newPayload: any = { ...payload };
if (newPayload.sort_order && payload.sort_order)
newPayload.sort_order = payload.sort_order.newSortOrder;
cyclesService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload, user);
};
const blockFormat = (blocks: any) =>
const blockFormat = (blocks: ICycle[]) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
? blocks
.filter((b) => b.start_date && b.end_date)
.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: new Date(block.start_date ?? ""),
target_date: new Date(block.end_date ?? ""),
}))
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title={"Cycles"}
title="Cycles"
loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={handleUpdateDates}
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
blockRender={(data: any) => <CycleGanttBlock cycle={data as ICycle} />}
enableLeftDrag={false}
enableRightDrag={false}
/>
</div>
);
@@ -17,7 +17,7 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: allCyclesList } = useSWR(
const { data: allCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
@@ -25,5 +25,5 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
: null
);
return <CyclesView cycles={allCyclesList} viewType={viewType} />;
return <CyclesView cycles={allCyclesList} mutateCycles={mutate} viewType={viewType} />;
};
@@ -17,7 +17,7 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: completedCyclesList } = useSWR(
const { data: completedCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
@@ -29,5 +29,5 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
: null
);
return <CyclesView cycles={completedCyclesList} viewType={viewType} />;
return <CyclesView cycles={completedCyclesList} mutateCycles={mutate} viewType={viewType} />;
};
@@ -17,7 +17,7 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: draftCyclesList } = useSWR(
const { data: draftCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
@@ -25,5 +25,5 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
: null
);
return <CyclesView cycles={draftCyclesList} viewType={viewType} />;
return <CyclesView cycles={draftCyclesList} mutateCycles={mutate} viewType={viewType} />;
};
@@ -17,7 +17,7 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: upcomingCyclesList } = useSWR(
const { data: upcomingCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () =>
@@ -29,5 +29,5 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
: null
);
return <CyclesView cycles={upcomingCyclesList} viewType={viewType} />;
return <CyclesView cycles={upcomingCyclesList} mutateCycles={mutate} viewType={viewType} />;
};
+4 -3
View File
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { KeyedMutator, mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
@@ -35,10 +35,11 @@ import {
type Props = {
cycles: ICycle[] | undefined;
mutateCycles: KeyedMutator<ICycle[]>;
viewType: string | null;
};
export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType }) => {
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
@@ -202,7 +203,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
))}
</div>
) : (
<CyclesListGanttChartView cycles={cycles ?? []} />
<CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
)
) : (
<div className="h-full grid place-items-center text-center">
+23 -75
View File
@@ -1,20 +1,27 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useUser from "hooks/use-user";
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import {
GanttChartRoot,
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types
import { IIssue } from "types";
type Props = {};
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
export const CycleIssuesGanttChartView = () => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
workspaceSlug as string,
projectId as string,
@@ -32,77 +39,18 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Cycles"
loaderTitle="Cycles"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />}
enableReorder={orderBy === "sort_order"}
/>
</div>
);
+3 -3
View File
@@ -450,14 +450,14 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
height={12}
width={12}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
@@ -45,7 +45,6 @@ type TSingleStatProps = {
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
isCompleted?: boolean;
};
const stateGroups = [
@@ -82,7 +81,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
isCompleted = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@@ -90,6 +88,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
@@ -250,14 +249,14 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">

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