Compare commits

...

107 Commits

Author SHA1 Message Date
pablohashescobar
56ea45f44c chore: migrations for constraints 2024-08-14 14:26:44 +05:30
pablohashescobar
729bad4344 fix: migration 2024-08-14 13:57:59 +05:30
dependabot[bot]
5f26ce2466 chore(deps): bump axios from 1.7.2 to 1.7.4 (#5364)
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 13:41:16 +05:30
guru_sainath
c02a54ef31 [WEB-2214] chore: migration for user favorite, file asset, and deploy board (#5339)
* chore: migrations for user favorite, file asset, and deply boards

* fix: migration fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-08-14 13:07:08 +05:30
Anmol Singh Bhatia
d9c9d85d38 [WEB-2221] fix: app sidebar and favorites improvement (#5357)
* fix: project collapsible toggle

* fix: project favorite redirection

* chore: favorite redirection scroll into view implementation

* fix: use favorite item details project details
2024-08-14 12:53:53 +05:30
pablohashescobar
edb04a33fd chore: issue type migration 2024-08-14 12:46:31 +05:30
NarayanBavisetti
033e7703b4 chore: project issue type migration 2024-08-13 21:53:51 +05:30
Satish Gandham
3f4c95412d Fix the missing eexport in EE folder (#5358) 2024-08-12 19:59:53 +05:30
Aaryan Khandelwal
4792c1cdf5 fix: project modal shortcut (#5353) 2024-08-12 19:17:10 +05:30
Akshita Goyal
041f2b16c3 [WEB-1986] chore: Build Fix, project page import (#5356)
* chore: seperated project components for CE

* chore: splitted the code for project creation form

* fix: code structure optimization

* fix: project page root moved

* fix: synced with preview

* fix: component splitting and refactoring

* fix: build error

* fix: import error
2024-08-12 19:12:35 +05:30
Akshita Goyal
91693b2269 chore: seperated project components for CE (#5324)
* chore: seperated project components for CE

* chore: splitted the code for project creation form

* fix: code structure optimization

* fix: project page root moved

* fix: synced with preview

* fix: component splitting and refactoring

* fix: build error
2024-08-12 18:24:42 +05:30
Aaryan Khandelwal
3ffaa4f2ca [WEB-2217] fix: drag handle positioning and action (#5349)
* fix: drag handle click action

* fix: drag handle positioning
2024-08-12 15:51:23 +05:30
Henit Chobisa
f817d70f78 fix: unable to added issues to a completed cycle (#5348) 2024-08-12 13:04:07 +05:30
Anmol Singh Bhatia
269e6ccd18 [WEB-2204] chore: asset optimization (#5346)
* chore: dashboard empty state asset updated and remove unwanted asset

* chore: workspace active cycle asset updated

* chore: onboarding pages asset updated and remove unwanted asset from web and space app

* chore: onboarding profile setup and create workspace asset updated and remove unwanted asset from web and space app

* chore: code refactor
2024-08-10 12:09:57 +05:30
M. Palanikannan
6e435df613 fix: state creation from external apis (#5345) 2024-08-09 19:29:17 +05:30
Aaryan Khandelwal
85f8fe9247 [WEB-2045] dev: editor variable font sizes and styles support (#5340)
* chore: added variable font size and font style support

* chore: remove font style switcher

* chore: update typography
2024-08-09 19:22:47 +05:30
Anmol Singh Bhatia
6d0cf1b4e9 [WEB-2190] fix: unauthorised delete and redirections (#5342)
* fix: cycle unauthorised delete action redirection

* fix: intake unauthorised delete action redirection
2024-08-09 19:14:38 +05:30
Anmol Singh Bhatia
679b0b6465 [WEB-2189] fix: issue peek overview and issue detail unauthorised delete action (#5341)
* fix: issue peek overview and issue detail delete action

* chore: code refactor

* chore: code refactor
2024-08-09 19:09:25 +05:30
Anmol Singh Bhatia
421bf2abc7 [WEB-2178] fix: empty folder title (#5344)
* fix: empty folder title

* fix: collapsible overflow issue
2024-08-09 19:03:25 +05:30
guru_sainath
f457048644 chore: handling the archived module ids in the issue list and issue detail endpoints (#5343) 2024-08-09 17:16:37 +05:30
Anmol Singh Bhatia
24b1e71cbf [WEB-2211] fix: input autoComplete (#5333)
* fix: input autoComplete

* chore: code refactor

* chore: set autoComplete on for email, password and name
2024-08-09 16:42:31 +05:30
vamsi
0b72bd373b fix: adding signup enabled flag in instance settings endpoint 2024-08-09 16:35:52 +05:30
vamsi
fc205efd6d fix: remove user count from instance settings 2024-08-09 16:23:53 +05:30
dependabot[bot]
f54e1b922d chore(deps): bump django in /apiserver/requirements (#5337)
Bumps [django](https://github.com/django/django) from 4.2.14 to 4.2.15.
- [Commits](https://github.com/django/django/compare/4.2.14...4.2.15)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-08 20:18:05 +05:30
timf34
644d1db44c Fixed typo in manifest.json (#5310) 2024-08-08 20:13:09 +05:30
Manish Gupta
b05d72e29a fixed setup.sh for macos support (#5336)
* fixed setup.sh for macos support

* updated as per coderabbit suggestions
2024-08-08 20:13:01 +05:30
Anmol Singh Bhatia
48cb0f5afc [WEB-2202] chore: user favorites mutation and code refactor (#5330)
* chore: fav item drag and drop improvement

* chore: user favorite type updated

* chore: user favorites helper function added

* dev: favorite item common component added

* dev: favorite item component added and code refactor

* fix: build error

* chore: code refactor

* chore: code refactor

* chore: code refactor
2024-08-08 20:11:18 +05:30
guru_sainath
a2098ffb5e chore: made cursor update on created_by in issue poprities pane in issue deatil, and issue peekoverview (#5331) 2024-08-08 17:13:52 +05:30
rahulramesha
3b21018154 fix issue description in space app's peek overview (#5328) 2024-08-08 17:00:15 +05:30
Anmol Singh Bhatia
1b624ef3ac fix: work log activity validation (#5332) 2024-08-08 16:43:45 +05:30
Aaryan Khandelwal
be82cbb8e8 [WEB-2047] chore: add missing exports (#5334)
* chore: add missing exports

* chore: delete unnecessary files
2024-08-08 16:41:49 +05:30
Aaryan Khandelwal
e805c49e69 [WEB-2047] refactor: editor side menu (#5329)
* refactor: editor side menu

* chore: change editor side menu selector to be id based
2024-08-08 14:48:05 +05:30
Aaryan Khandelwal
943dd593fa dev: editor extensions feature flagging (#5279) 2024-08-07 20:06:15 +05:30
Nikhil
520938ab5c chore: add rate limiting in magic generate endpoint (#5322) 2024-08-07 19:35:00 +05:30
Anmol Singh Bhatia
86909cff14 [WEB-2182] chore: user favorites item enhancements (#5321)
* fix: user favorties item icon type and alignment

* chore: user favorite item clickable area improvement
2024-08-07 17:56:20 +05:30
Anmol Singh Bhatia
598846adc4 [WEB-2182] chore: user favorites improvement (#5318)
* chore: favorite collapsible spacing

* chore: favorite collapsible tooltip added

* chore: user favorites icon improvement and code refactor

* chore: favorites empty state added

* chore: project identifier message updated

* chore: favorties collapsible improvement

* chore: code refactor

* fix: build error

* fix: app sidebar draft issue z-index
2024-08-07 15:28:25 +05:30
rahulramesha
91142659ca [WEB-2192] fix: order of state groups in space app (#5317)
* chore: added sequence in the states endpoint

* fix state grouping order in space app

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-07 13:49:45 +05:30
Akshita Goyal
806eae0139 fix: reloading on favorite action (#5313) 2024-08-07 12:58:24 +05:30
Anmol Singh Bhatia
3279bb6ac9 [WEB-2182] fix: favorite item alignment and redirection (#5316)
* fix: favorite item alignment

* fix: favorite item redirection

* chore: code refactor
2024-08-06 18:21:53 +05:30
Henit Chobisa
976784bc84 feat: added deleted_at as read-only property for the label serializer (#5306) 2024-08-06 17:26:40 +05:30
Henit Chobisa
983769a944 feat: added endpoint for creating service tokens (#5312)
* feat: added endpoint for creating service tokens

* fix: removed filtering of APITokens without being a service token
2024-08-06 17:26:20 +05:30
Anmol Singh Bhatia
3f9523804b fix: delete action mutation (#5315) 2024-08-06 16:42:13 +05:30
guru_sainath
9715922fc1 [WEB-2103] chore: intercom trigger updates from sidebar and command palette helper actions (#5314)
* chore: handled intercom operations programatically.

* fix: app sidebar improvement

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-08-06 16:02:01 +05:30
Nikhil
2fa92fda75 chore: update cache command to delete the cache entry for the cache key (#5309) 2024-08-06 13:34:21 +05:30
Prateek Shourya
95641f31af fix: sidebar help section padding. (#5311) 2024-08-06 13:08:39 +05:30
Akshita Goyal
a93dfc1b8d fix: favorite improvements (#5307) 2024-08-05 20:17:59 +05:30
Bavisetti Narayan
07574b4222 [WEB-2092] chore: favorite delete changes (#5302)
* chore: favorite delete changes

* chore: removed deploy board deletion

* chore: favorite entity deletion
2024-08-05 17:40:49 +05:30
Akshita Goyal
91e4da502a [WEB-1907] Fix/favorite move out of folder (#5305)
* fix: fav feature review changes

* fix: enabled moving out of folder on hovering

* fix: removed consoles
2024-08-05 17:06:53 +05:30
Akshita Goyal
fafa2c06c3 fix: fav feature review changes (#5304) 2024-08-05 16:33:30 +05:30
sriram veeraghanta
86a982e8ce fix: upgrading the turbo version 2024-08-05 15:35:57 +05:30
Aaryan Khandelwal
dd806dfa2f chore: remove yjs resolve (#5301) 2024-08-05 15:30:17 +05:30
rahulramesha
42462c78f7 modify cycle options (#5299) 2024-08-05 15:15:11 +05:30
Anmol Singh Bhatia
21343034c2 [WEB-2173] fix: app sidebar spacing and build error (#5300)
* fix: app sidebar spacing

* fix: build error
2024-08-05 15:13:51 +05:30
Aaryan Khandelwal
f9e7a5826b [WEB-2166] chore: smoother drag experience in the document editor (#5296)
* chore: update drag and drop behaviour

* chore: update drag and drop behaviour

* chore: disable pwa updates on development mode
2024-08-05 13:59:14 +05:30
Aaryan Khandelwal
c99f2fcdbb fix: yjs duplicate import error (#5297) 2024-08-05 13:37:35 +05:30
guru_sainath
0619f1b6d1 [WEB-2103]: chore: Intercom integration (#5295)
* fix: intecom sdk integration

* dev: integrated intercom in god-mode

* dev: intercom default value true

* dev: updated intercom keys in intercom provider

* chore: added restriction values

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-08-05 13:37:11 +05:30
Akshita Goyal
34820eec7a [WEB-1907] Fix: favorites (#5292)
* chore: workspace user favorites

* chore: added project id in entity type

* chore: removed the extra key

* chore: removed the project member filter

* chore: updated the project permission layer

* chore: updated the workspace group favorite filter

* fix: project favorite toggle

* chore: Fav feature

* fix: build errors + added navigation

* fix: added remove entity icon

* fix: nomenclature

* chore: hard delete favorites

* fix: review changes

* fix: added optimistic addition to the store

* chore: user favorite hard delete

* fix: linting fixed

* fix: favorite bugs

* fix: ts bugs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-04 10:15:26 +05:30
rahulramesha
93e6c3b6e0 Optimistically update distribution (#5290) 2024-08-04 10:14:25 +05:30
Aaryan Khandelwal
8f8a97589d fix: casing throughout the platform (#5293) 2024-08-04 10:09:29 +05:30
rahulramesha
3a5c77e8a4 fetch issue activity on peek issue update (#5289) 2024-08-02 19:00:30 +05:30
guru_sainath
79fbcaa2b2 fix: initial fetch filters is not being applied when we have a undefined currentTab in params (#5288) 2024-08-02 18:20:52 +05:30
Bavisetti Narayan
76983a57e9 [WEB-2092] chore: soft delete migration (#5286)
* chore: soft delete migration

* chore: page deletion role check
2024-08-02 13:15:59 +05:30
Anmol Singh Bhatia
e9b1151702 fix: project intake store (#5283) 2024-08-02 12:31:00 +05:30
Akshita Goyal
f4f5e5a0d3 [WEB-1907] feat: Favorites Enhancements (#5262)
* chore: workspace user favorites

* chore: added project id in entity type

* chore: removed the extra key

* chore: removed the project member filter

* chore: updated the project permission layer

* chore: updated the workspace group favorite filter

* fix: project favorite toggle

* chore: Fav feature

* fix: build errors + added navigation

* fix: added remove entity icon

* fix: nomenclature

* chore: hard delete favorites

* fix: review changes

* fix: added optimistic addition to the store

* chore: user favorite hard delete

* fix: linting fixed

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-02 12:25:26 +05:30
sriram veeraghanta
f55c135052 fix: adding icons 2024-08-01 21:29:31 +05:30
sriram veeraghanta
8924e303da fix: PWA related fixes and mainfest added 2024-08-01 21:08:57 +05:30
sriram veeraghanta
c89fe9a313 fix: url mismatches in space app 2024-08-01 14:12:57 +05:30
Bavisetti Narayan
b381331b75 chore: hard delete favorites (#5282) 2024-08-01 13:13:43 +05:30
Anmol Singh Bhatia
ee76cb1dc7 [WEB-1999] dev: interactive active cycle stats (#5280)
* chore: list layout item improvement

* dev: active cycle interactive stats implementation

* dev: in cycle list interactive date picker added
2024-08-01 12:55:57 +05:30
Bavisetti Narayan
daaa04c6ea [WEB-2092] fix: added unique constraints for project, module and states (#5281)
* fix: added unique constraints

* chore: migration indetaton
2024-07-31 19:38:53 +05:30
Anmol Singh Bhatia
67f2e2fdb2 fix: member setting role edit validation (#5278) 2024-07-31 17:12:53 +05:30
Anmol Singh Bhatia
18df1530c1 [WEB-2130] chore: list layout responsiveness improvement (#5276)
* chore: issue list layout responsiveness improvement

* fix: list layout item component improvement

* chore: cycle, module and view list layout responsiveness improvement
2024-07-31 17:10:16 +05:30
Akshita Goyal
dd3df20319 [WEB-2121] fix: project issue creation (#5266)
* fix: project issue creation

* fix: refactored
2024-07-31 14:13:09 +05:30
Akshita Goyal
569b592711 [WEB-1671] fix: expired snooze issues fixed (#5270)
* fix: expired snooze issues fixed

* fix: refactored
2024-07-31 14:12:28 +05:30
Akshita Goyal
f75df83ca1 [WEB-2028] fix: added states to module progress bar (#5273)
* fix: added multiple states to module progress bar

* fix: refactored
2024-07-31 14:12:00 +05:30
Bavisetti Narayan
8415df4cf3 [WEB-1989] chore: archived modules and cycles (#5212)
* chore: added estimates in module, cycle endpoint

* fix fetching of cycles and modules from appropriate endpoints

* chore: added archived at in the cycle detail

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-07-30 20:08:52 +05:30
Bavisetti Narayan
3c684ecab7 [WEB-2092] chore: changed the hard delete days (#5255)
* chore: changed the hard delete days

* chore: hard delete key change

* chore: restrict deletion of project

* chore: draft issue delete filter
2024-07-30 20:05:08 +05:30
Anmol Singh Bhatia
0b01d3e88d fix: workspace export settings mutation (#5268) 2024-07-30 19:57:57 +05:30
rahulramesha
889393e1d1 fix empty grouping in Kanban (#5269) 2024-07-30 19:51:47 +05:30
Aaryan Khandelwal
6fa45d8723 fix: editor width transition duration added (#5267) 2024-07-30 19:46:16 +05:30
Akshita Goyal
88533933b4 fix: duplicate label creation in project (#5271) 2024-07-30 19:34:40 +05:30
rahulramesha
fffa8648bb Space app Kanban block reactions (#5272) 2024-07-30 19:32:24 +05:30
Bavisetti Narayan
1f8f6d1b26 chore: bulk delete operation (#5258) 2024-07-30 15:31:52 +05:30
Bavisetti Narayan
cce7bddbcc chore: deploy board publish validation (#5264) 2024-07-30 15:31:15 +05:30
Aaryan Khandelwal
518327e380 [WEB-1974] fix: images getting replaced on resize (#5233)
* fix: image resizer error

* refactor: created common function to get the active image element

* fix: build errors
2024-07-30 14:58:40 +05:30
Anmol Singh Bhatia
6bb534dabc fix: completed cycle date picker validation (#5265) 2024-07-30 14:03:53 +05:30
guru_sainath
dc2e293058 [WEB-2107] fix: Default filters and sorting on the initial load, filter mutation on tab change (#5259)
* chore: Default filters and sorting on the initial load, filter mutation on tab change

* Typo: changed method name in project intake store
2024-07-30 14:02:16 +05:30
Aaryan Khandelwal
1adfb4dbe4 fix: copy page link url (#5263) 2024-07-30 13:53:45 +05:30
rahulramesha
f2af5f0653 fix modules and cycle peek views (#5261) 2024-07-30 13:53:19 +05:30
rahulramesha
e3143ff00b [WEB-1812] fix : Avoid loader when parent is added in issue detail / peek overview (#5257)
* use common getIssues from issue service instead of multiple different services for modules and cycles

* fix parent issue refresh

* Revert "use common getIssues from issue service instead of multiple different services for modules and cycles"

This reverts commit 957e981168.
2024-07-30 13:48:52 +05:30
Anmol Singh Bhatia
7b82d1c62f fix: profile layout (#5256) 2024-07-30 13:45:19 +05:30
Henit Chobisa
3c2aec2776 feat: removed created_by from read_only serializer field, and ProjectMemberEndpoint updates (#5260)
* feat: removed created by and created_at as readonly fields from issue serializers

* feat: modified serializers for accepting created_by, and changed workspacememberendpoint to projectmemberendpoint

* fix: code suggestions

* chore: resolved code review

* chore: removed unused imports

* fix: passed default user if created_by is absent, and permission classes

* fix: default value for the issue creation

* dev: fix nomenclature

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-07-30 13:03:14 +05:30
Anmol Singh Bhatia
35e58e9ec7 [WEB-2043] fix: delete action validation and toast alert (#5254)
* dev: canPerformProjectAdminActions helper function added

* chore: deleteInboxIssue action updated

* dev: bulk delete modal validation updated

* chore: issue, intake, cycle and module delete action toast updated

* chore: code refactor
2024-07-29 19:08:18 +05:30
Anmol Singh Bhatia
ba9d9fd5eb chore: load more button color updated (#5253) 2024-07-29 16:50:44 +05:30
Anmol Singh Bhatia
040ee4b256 [WEB-2026] fix: avatar visibility on project list after user leaves project (#5241)
* fix: project leave mutation

* chore: code refactor
2024-07-29 16:50:30 +05:30
Nikhil
f48bc5a876 fix: google auth integrity error (#5229) 2024-07-29 14:29:45 +05:30
Bavisetti Narayan
10e9122c1d [WEB-2092] chore: soft delete operation (#5244)
* chore: soft delete opration

* chore: migration files

* chore: celery time change

* chore: changed the deletion time
2024-07-29 14:29:08 +05:30
rahulramesha
d5cbe3283b remove issue from cycle while changing cycle (#5246) 2024-07-29 13:26:27 +05:30
Anmol Singh Bhatia
ae931f8172 [WEB-2054] fix: kanban layout loader enhancements and issue count alignment (#5232)
* fix: kanban layout issue count alignment

* fix: kanban layout loader spacing and padding
2024-07-29 13:23:12 +05:30
Anmol Singh Bhatia
a8c6483c60 fix: profile display name error message (#5237) 2024-07-29 11:35:16 +05:30
Anmol Singh Bhatia
9c761a614f fix: inbox filters checkbox (#5239) 2024-07-29 11:34:36 +05:30
Anmol Singh Bhatia
adf88a0f13 fix: issue link modal preloadedData reset (#5240) 2024-07-29 11:33:25 +05:30
Aaryan Khandelwal
5d2983d027 fix: creation of new todo list item in comments (#5242) 2024-07-29 11:29:09 +05:30
Anmol Singh Bhatia
8339daa3ee fix: member role edit validation (#5236) 2024-07-29 11:28:23 +05:30
Aaryan Khandelwal
4a9e09a54a fix: image outline on load (#5230) 2024-07-29 11:24:23 +05:30
Bavisetti Narayan
2c609670c8 [WEB-2043] chore: updated permissions for delete operation (#5231)
* chore: added permission for delete operation

* chore: added permission for external apis

* chore: condition changes

* chore: minor changes
2024-07-26 16:42:51 +05:30
Akshita Goyal
dfcba4dfc1 fix: revoked issue height change (#5238) 2024-07-26 13:38:26 +05:30
389 changed files with 9210 additions and 5709 deletions

View File

@@ -9,8 +9,9 @@ import { IInstance, IInstanceAdmin } from "@plane/types";
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -20,11 +21,13 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { updateInstanceInfo } = useInstance();
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance>>({
defaultValues: {
@@ -36,7 +39,16 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
console.log("payload", payload);
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
@@ -74,6 +86,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled
/>
</div>
@@ -93,7 +106,8 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
</div>
<div className="space-y-3">
<div className="text-lg font-medium">Telemetry</div>
<div className="text-lg font-medium">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">

View File

@@ -0,0 +1,82 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<MessageSquare className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -7,7 +7,7 @@ import { GeneralConfigurationForm } from "./form";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
console.log("instance", instance);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">

View File

@@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 min-w-[10rem] ${
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}

View File

@@ -174,6 +174,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -190,6 +191,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
@@ -208,6 +210,7 @@ export const InstanceSetupForm: FC = (props) => {
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
@@ -247,6 +250,7 @@ export const InstanceSetupForm: FC = (props) => {
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button

View File

@@ -57,8 +57,6 @@ export const InstanceSignInForm: FC = (props) => {
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
console.log("csrfToken", csrfToken);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
@@ -129,6 +127,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -147,6 +146,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button

View File

@@ -18,7 +18,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.6.7",
"axios": "^1.7.4",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",

View File

@@ -50,3 +50,6 @@ GUNICORN_WORKERS=2
ADMIN_BASE_URL=
SPACE_BASE_URL=
APP_BASE_URL=
# Hard delete files after days
HARD_DELETE_AFTER_DAYS=

View File

@@ -40,6 +40,7 @@ class CycleSerializer(BaseSerializer):
"workspace",
"project",
"owned_by",
"deleted_at",
]

View File

@@ -53,7 +53,6 @@ class IssueSerializer(BaseSerializer):
"id",
"workspace",
"project",
"created_by",
"updated_by",
"updated_at",
]
@@ -270,6 +269,7 @@ class LabelSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
@@ -338,9 +338,7 @@ class IssueAttachmentSerializer(BaseSerializer):
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]

View File

@@ -39,6 +39,7 @@ class ModuleSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def to_representation(self, instance):

View File

@@ -31,6 +31,7 @@ class ProjectSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"deleted_at",
]
def validate(self, data):

View File

@@ -23,6 +23,7 @@ class StateSerializer(BaseSerializer):
"updated_at",
"workspace",
"project",
"deleted_at",
]

View File

@@ -1,13 +1,13 @@
from django.urls import path
from plane.api.views import (
WorkspaceMemberAPIEndpoint,
ProjectMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(),
"workspaces/<str:slug>/projects/<str:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(),
name="users",
),
]

View File

@@ -25,6 +25,7 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import WorkspaceMemberAPIEndpoint
from .member import ProjectMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint

View File

@@ -34,6 +34,8 @@ from plane.db.models import (
Project,
IssueAttachment,
IssueLink,
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
@@ -363,14 +365,28 @@ class CycleAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if cycle.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the cycle"},
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk")
).values_list("issue", flat=True)
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -389,6 +405,16 @@ class CycleAPIEndpoint(BaseAPIView):
)
# Delete the cycle
cycle.delete()
# Delete the cycle issues
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -646,61 +672,63 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
)
CycleIssue.objects.bulk_create(
record_to_create,
batch_size=10,
ignore_conflicts=True,
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
existing_issues = [
str(cycle_issue.issue_id)
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
cycle_id=cycle_id,
issue_id=issue,
)
for issue in new_issues
],
ignore_conflicts=True,
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Update the cycle issues
CycleIssue.objects.bulk_update(
updated_records, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": str(issues)}),
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -708,13 +736,14 @@ class CycleIssueAPIEndpoint(BaseAPIView):
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
return Response(
CycleIssueSerializer(self.get_queryset(), many=True).data,

View File

@@ -390,29 +390,26 @@ class InboxIssueAPIEndpoint(BaseAPIView):
inbox_id=inbox.id,
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member=request.user,
is_active=True,
)
# Check the inbox issue created
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,
)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
).delete()
).first()
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -151,6 +151,25 @@ class IssueAPIEndpoint(BaseAPIView):
).distinct()
def get(self, request, slug, project_id, pk=None):
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")
if external_id and external_source:
issue = Issue.objects.get(
external_id=external_id,
external_source=external_source,
workspace__slug=slug,
project_id=project_id,
)
return Response(
IssueSerializer(
issue,
fields=self.fields,
expand=self.expand,
).data,
status=status.HTTP_200_OK,
)
if pk:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -310,10 +329,16 @@ class IssueAPIEndpoint(BaseAPIView):
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at")
issue.save(update_fields=["created_at"])
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
pk=serializer.data["id"],
).first()
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get(
"created_by", request.user.id
)
issue.save(update_fields=["created_at", "created_by"])
# Track the issue
issue_activity.delay(
@@ -386,6 +411,19 @@ class IssueAPIEndpoint(BaseAPIView):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
@@ -594,14 +632,20 @@ class IssueLinkAPIEndpoint(BaseAPIView):
project_id=project_id,
issue_id=issue_id,
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get(
"created_by", request.user.id
)
link.save(update_fields=["created_by"])
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
actor_id=str(link.created_by_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
@@ -755,12 +799,24 @@ class IssueCommentAPIEndpoint(BaseAPIView):
issue_id=issue_id,
actor=request.user,
)
issue_comment = IssueComment.objects.get(
pk=serializer.data.get("id")
)
# Update the created_at and the created_by and save the comment
issue_comment.created_at = request.data.get(
"created_at", timezone.now()
)
issue_comment.created_by_id = request.data.get(
"created_by", request.user.id
)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
actor_id=str(issue_comment.created_by_id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,

View File

@@ -21,11 +21,19 @@ from plane.db.models import (
ProjectMember,
)
from plane.app.permissions import (
ProjectMemberPermission,
)
# API endpoint to get and insert users inside the workspace
class WorkspaceMemberAPIEndpoint(BaseAPIView):
class ProjectMemberAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectMemberPermission,
]
# Get all the users that are present inside the workspace
def get(self, request, slug):
def get(self, request, slug, project_id):
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
@@ -34,14 +42,14 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
)
# Get the workspace members that are present inside the workspace
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
)
project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug
).values_list("member_id", flat=True)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(
id__in=workspace_members.values_list("member_id", flat=True)
id__in=project_members,
),
many=True,
).data
@@ -49,14 +57,13 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug):
def post(self, request, slug, project_id):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
or request.data.get("project_id") is None
):
return Response(
{
@@ -76,9 +83,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(
pk=request.data.get("project_id")
).first()
project = Project.objects.filter(pk=project_id).first()
if not all([workspace, project]):
return Response(
@@ -145,3 +150,4 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)

View File

@@ -27,6 +27,8 @@ from plane.db.models import (
ModuleIssue,
ModuleLink,
Project,
ProjectMember,
UserFavorite,
)
from .base import BaseAPIView
@@ -265,6 +267,20 @@ class ModuleAPIEndpoint(BaseAPIView):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the module"},
status=status.HTTP_403_FORBIDDEN,
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
@@ -286,6 +302,17 @@ class ModuleAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()),
)
module.delete()
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -26,6 +26,7 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
UserFavorite,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -356,6 +357,12 @@ class ProjectAPIEndpoint(BaseAPIView):
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="project",
entity_identifier=pk,
project_id=pk,
).delete()
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -121,3 +121,5 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer

View File

@@ -0,0 +1,101 @@
from rest_framework import serializers
from plane.db.models import (
UserFavorite,
Cycle,
Module,
Issue,
IssueView,
Page,
Project,
)
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["id", "name", "logo_props"]
class PageFavoriteLiteSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
class Meta:
model = Page
fields = ["id", "name", "logo_props", "project_id"]
def get_project_id(self, obj):
project = (
obj.projects.first()
) # This gets the first project related to the Page
return project.id if project else None
class CycleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Cycle
fields = ["id", "name", "logo_props", "project_id"]
class ModuleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Module
fields = ["id", "name", "logo_props", "project_id"]
class ViewFavoriteSerializer(serializers.ModelSerializer):
class Meta:
model = IssueView
fields = ["id", "name", "logo_props", "project_id"]
def get_entity_model_and_serializer(entity_type):
entity_map = {
"cycle": (Cycle, CycleFavoriteLiteSerializer),
"issue": (Issue, None),
"module": (Module, ModuleFavoriteLiteSerializer),
"view": (IssueView, ViewFavoriteSerializer),
"page": (Page, PageFavoriteLiteSerializer),
"project": (Project, ProjectFavoriteLiteSerializer),
"folder": (None, None),
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserFavorite
fields = [
"id",
"entity_type",
"entity_identifier",
"entity_data",
"name",
"is_folder",
"sequence",
"parent",
"workspace_id",
"project_id",
]
read_only_fields = ["workspace", "created_by", "updated_by"]
def get_entity_data(self, obj):
entity_type = obj.entity_type
entity_identifier = obj.entity_identifier
entity_model, entity_serializer = get_entity_model_and_serializer(
entity_type
)
if entity_model and entity_serializer:
try:
entity = entity_model.objects.get(pk=entity_identifier)
return entity_serializer(entity).data
except entity_model.DoesNotExist:
return None
return None

View File

@@ -533,6 +533,7 @@ class IssueReactionSerializer(BaseSerializer):
"project",
"issue",
"actor",
"deleted_at"
]
@@ -551,7 +552,7 @@ class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor"]
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
class IssueVoteSerializer(BaseSerializer):

View File

@@ -39,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer):
"created_at",
"updated_at",
"archived_at",
"deleted_at",
]
def to_representation(self, instance):

View File

@@ -28,6 +28,7 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"deleted_at",
]
def create(self, validated_data):

View File

@@ -1,5 +1,5 @@
from django.urls import path
from plane.app.views import ApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -13,5 +13,10 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]

View File

@@ -25,6 +25,8 @@ from plane.app.views import (
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
@@ -237,4 +239,19 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
path(
"workspaces/<str:slug>/user-favorites/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/group/",
WorkspaceFavoriteGroupEndpoint.as_view(),
name="workspace-user-favorites-groups",
),
]

View File

@@ -40,6 +40,11 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
@@ -169,8 +174,10 @@ from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import ApiTokenEndpoint
from .api import (
ApiTokenEndpoint,
ServiceApiTokenEndpoint,
)
from .page.base import (
PageViewSet,

View File

@@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug
user=request.user, workspace__slug=slug, is_service=False
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -61,6 +61,7 @@ class ApiTokenEndpoint(BaseAPIView):
workspace__slug=slug,
user=request.user,
pk=pk,
is_service=False,
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -78,3 +79,44 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
workspace=workspace,
is_service=True,
).first()
if api_token:
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_200_OK,
)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_201_CREATED,
)

View File

@@ -14,21 +14,18 @@ from django.db.models import (
UUIDField,
Value,
When,
Subquery,
Sum,
FloatField,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
UserFavorite,
Issue,
Label,
User,
)
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.utils.analytics_plot import burndown_plot
# Module imports
@@ -49,6 +46,89 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("total_estimate_points")[:1]
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
@@ -172,6 +252,42 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=FloatField()),
),
)
.order_by("-is_favorite", "name")
.distinct()
)
@@ -179,17 +295,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
self.get_queryset().values(
# necessary fields
"id",
"workspace_id",
@@ -255,7 +361,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"external_id",
"progress_snapshot",
"sub_issues",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -265,17 +374,114 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
"archived_at",
)
.first()
)
queryset = queryset.first()
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["estimate_distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if data["start_date"] and data["end_date"]:
data["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=pk,
)
)
# Assignee Distribution
assignee_distribution = (
Issue.issue_objects.filter(
@@ -298,7 +504,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(
@@ -338,7 +547,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
),
)
.annotate(

View File

@@ -47,6 +47,7 @@ from plane.db.models import (
Label,
User,
Project,
ProjectMember,
)
from plane.utils.analytics_plot import burndown_plot
@@ -384,7 +385,7 @@ class CycleViewSet(BaseViewSet):
data[0]["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
@@ -422,7 +423,7 @@ class CycleViewSet(BaseViewSet):
)
label_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
@@ -476,7 +477,7 @@ class CycleViewSet(BaseViewSet):
)
assignee_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
@@ -518,7 +519,7 @@ class CycleViewSet(BaseViewSet):
)
label_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=data[0]["id"],
workspace__slug=slug,
project_id=project_id,
@@ -833,7 +834,7 @@ class CycleViewSet(BaseViewSet):
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -871,7 +872,7 @@ class CycleViewSet(BaseViewSet):
)
label_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -926,7 +927,7 @@ class CycleViewSet(BaseViewSet):
# Assignee Distribution
assignee_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -977,7 +978,7 @@ class CycleViewSet(BaseViewSet):
# Label Distribution
label_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -1039,14 +1040,28 @@ class CycleViewSet(BaseViewSet):
)
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if cycle.owned_by_id != request.user.id and not (
ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or owner can delete the cycle"},
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk")
).values_list("issue", flat=True)
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -1067,6 +1082,17 @@ class CycleViewSet(BaseViewSet):
)
# Delete the cycle
cycle.delete()
# Delete the cycle issues
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
user=request.user,
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1135,7 +1161,7 @@ class CycleFavoriteViewSet(BaseViewSet):
workspace__slug=slug,
entity_identifier=cycle_id,
)
cycle_favorite.delete()
cycle_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -20,6 +20,7 @@ from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
@@ -45,7 +46,6 @@ from plane.utils.paginator import (
SubGroupedOffsetPaginator,
)
# Module imports
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -334,7 +334,7 @@ class CycleIssueViewSet(BaseViewSet):
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
cycle_issue = CycleIssue.objects.filter(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,

View File

@@ -160,7 +160,8 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -553,28 +554,27 @@ class InboxIssueViewSet(BaseViewSet):
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,
is_active=True,
)
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,
)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
).delete()
).first()
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -66,6 +66,7 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(deleted_at__isnull=True)
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))

View File

@@ -14,7 +14,7 @@ from rest_framework.parsers import MultiPartParser, FormParser
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueAttachment
from plane.db.models import IssueAttachment, ProjectMember
from plane.bgtasks.issue_activites_task import issue_activity
@@ -49,6 +49,19 @@ class IssueAttachmentEndpoint(BaseAPIView):
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
if issue_attachment.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the attachment"},
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(

View File

@@ -44,6 +44,7 @@ from plane.db.models import (
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
)
from plane.utils.grouper import (
issue_group_values,
@@ -63,7 +64,6 @@ from plane.utils.user_timezone_converter import user_timezone_converter
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -165,6 +165,7 @@ class IssueListEndpoint(BaseAPIView):
"link_count",
"is_draft",
"archived_at",
"deleted_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
@@ -399,6 +400,7 @@ class IssueViewSet(BaseViewSet):
"link_count",
"is_draft",
"archived_at",
"deleted_at",
)
.first()
)
@@ -435,7 +437,8 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -549,6 +552,20 @@ class IssueViewSet(BaseViewSet):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
@@ -602,6 +619,18 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
]
def delete(self, request, slug, project_id):
if ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role__in=[15, 10, 5],
project_id=project_id,
is_active=True,
).exists():
return Response(
{"error": "Only admin can perform this action"},
status=status.HTTP_403_FORBIDDEN,
)
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):

View File

@@ -40,6 +40,7 @@ from plane.db.models import (
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
)
from plane.utils.grouper import (
issue_group_values,
@@ -67,6 +68,7 @@ class IssueDraftViewSet(BaseViewSet):
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.filter(deleted_at__isnull=True)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
@@ -380,6 +382,19 @@ class IssueDraftViewSet(BaseViewSet):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
issue_activity.delay(
type="issue_draft.activity.deleted",

View File

@@ -12,7 +12,8 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
Sum
Sum,
FloatField,
)
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
@@ -44,8 +45,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
entity_identifier=OuterRef("pk"),
entity_type="module",
entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -102,8 +103,93 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("total_estimate_points")[:1]
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("cancelled_estimate_point")[:1]
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("workspace", "project", "lead")
@@ -152,6 +238,42 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
@@ -232,7 +354,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
data["estimate_distribution"] = {}
if estimate_type:
label_distribution = (
assignee_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
@@ -252,12 +374,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
Cast("estimate_point__value", FloatField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -267,7 +389,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -278,7 +400,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("first_name", "last_name")
)
assignee_distribution = (
label_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
@@ -290,12 +412,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", IntegerField())
Cast("estimate_point__value", FloatField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -305,7 +427,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", IntegerField()),
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -315,8 +437,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.order_by("label_name")
)
data["estimate_distribution"]["assignee"] = assignee_distribution
data["estimate_distribution"]["label"] = label_distribution
data["estimate_distribution"]["assignees"] = assignee_distribution
data["estimate_distribution"]["labels"] = label_distribution
if modules and modules.start_date and modules.target_date:
data["estimate_distribution"]["completion_chart"] = (
@@ -328,6 +450,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
module_id=pk,
)
)
assignee_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
@@ -353,7 +476,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
archived_at__isnull=True,
is_draft=False,
),
)
),
)
.annotate(
completed_issues=Count(
@@ -425,8 +548,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,

View File

@@ -48,6 +48,7 @@ from plane.db.models import (
ModuleLink,
ModuleUserProperties,
Project,
ProjectMember,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
@@ -443,6 +444,12 @@ class ModuleViewSet(BaseViewSet):
)
)
if not queryset.exists():
return Response(
{"error": "Module not found"},
status=status.HTTP_404_NOT_FOUND,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
@@ -554,7 +561,7 @@ class ModuleViewSet(BaseViewSet):
)
assignee_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -604,7 +611,7 @@ class ModuleViewSet(BaseViewSet):
)
label_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -737,6 +744,21 @@ class ModuleViewSet(BaseViewSet):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the module"},
status=status.HTTP_403_FORBIDDEN,
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
@@ -757,6 +779,18 @@ class ModuleViewSet(BaseViewSet):
for issue in module_issues
]
module.delete()
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
user=request.user,
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -820,7 +854,7 @@ class ModuleFavoriteViewSet(BaseViewSet):
entity_type="module",
entity_identifier=module_id,
)
module_favorite.delete()
module_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -250,7 +250,6 @@ class ModuleIssueViewSet(BaseViewSet):
removed_modules = request.data.get("removed_modules", [])
project = Project.objects.get(pk=project_id)
if modules:
_ = ModuleIssue.objects.bulk_create(
[
@@ -284,7 +283,7 @@ class ModuleIssueViewSet(BaseViewSet):
]
for module_id in removed_modules:
module_issue = ModuleIssue.objects.get(
module_issue = ModuleIssue.objects.filter(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
@@ -297,7 +296,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
{"module_name": module_issue.first().module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
@@ -308,7 +307,7 @@ class ModuleIssueViewSet(BaseViewSet):
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
module_issue = ModuleIssue.objects.filter(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
@@ -321,7 +320,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.module.name}
{"module_name": module_issue.first().module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,

View File

@@ -333,33 +333,39 @@ class PageViewSet(BaseViewSet):
pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner and admin can delete the page
if (
ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
is_active=True,
role__gt=20,
).exists()
or request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can delete the page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or owner can delete the page"},
status=status.HTTP_403_FORBIDDEN,
)
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
# Delete the user favorite page
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="page",
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -387,7 +393,7 @@ class PageFavoriteViewSet(BaseViewSet):
entity_identifier=pk,
entity_type="page",
)
page_favorite.delete()
page_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -599,7 +599,7 @@ class ProjectFavoritesViewSet(BaseViewSet):
user=request.user,
workspace__slug=slug,
)
project_favorite.delete()
project_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -52,8 +52,14 @@ class IssueSearchEndpoint(BaseAPIView):
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id),
~Q(issue_related__issue=issue),
~Q(issue_relation__related_issue=issue),
~Q(
issue_related__issue=issue,
issue_related__deleted_at__isnull=True,
),
~Q(
issue_relation__related_issue=issue,
issue_related__deleted_at__isnull=True,
),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)

View File

@@ -116,6 +116,20 @@ class WorkspaceViewViewSet(BaseViewSet):
pk=pk,
workspace__slug=slug,
)
if not (
WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
is_active=True,
).exists()
and workspace_view.owned_by_id != request.user.id
):
return Response(
{"error": "You do not have permission to delete this view"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_member = WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
@@ -127,6 +141,13 @@ class WorkspaceViewViewSet(BaseViewSet):
or workspace_view.owned_by == request.user
):
workspace_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
workspace__slug=slug,
entity_identifier=pk,
project__isnull=True,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},
@@ -202,7 +223,8 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -412,15 +434,24 @@ class IssueViewViewSet(BaseViewSet):
project_id=project_id,
workspace__slug=slug,
)
project_member = ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=20,
is_active=True,
)
if project_member.exists() or project_view.owned_by == request.user:
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=20,
is_active=True,
).exists()
or project_view.owned_by_id == request.user.id
):
project_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},
@@ -458,5 +489,5 @@ class IssueViewFavoriteViewSet(BaseViewSet):
entity_type="view",
entity_identifier=view_id,
)
view_favorite.delete()
view_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,88 @@
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Django modules
from django.db.models import Q
# Module imports
from plane.app.views.base import BaseAPIView
from plane.db.models import UserFavorite, Workspace
from plane.app.serializers import UserFavoriteSerializer
from plane.app.permissions import WorkspaceEntityPermission
class WorkspaceFavoriteEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent__isnull=True,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
serializer = UserFavoriteSerializer(
favorite, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug, favorite_id):
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent_id=favorite_id,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -100,10 +100,8 @@ class OauthAdapter(Adapter):
account, created = Account.objects.update_or_create(
user=user,
provider=self.provider,
provider_account_id=self.user_data.get("user").get("provider_id"),
defaults={
"provider_account_id": self.user_data.get("user").get(
"provider_id"
),
"access_token": self.token_data.get("access_token"),
"refresh_token": self.token_data.get("refresh_token", None),
"access_token_expired_at": self.token_data.get(

View File

@@ -29,6 +29,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
class MagicGenerateEndpoint(APIView):
@@ -37,6 +38,10 @@ class MagicGenerateEndpoint(APIView):
AllowAny,
]
throttle_classes = [
AuthenticationThrottle,
]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()

View File

@@ -0,0 +1,161 @@
# Django imports
from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
from celery import shared_task
@shared_task
def soft_delete_related_objects(
app_label, model_name, instance_pk, using=None
):
model_class = apps.get_model(app_label, model_name)
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# @shared_task
def restore_related_objects(app_label, model_name, instance_pk, using=None):
pass
@shared_task
def hard_delete():
from plane.db.models import (
Workspace,
Project,
Cycle,
Module,
Issue,
Page,
IssueView,
Label,
State,
IssueActivity,
IssueComment,
IssueLink,
IssueReaction,
UserFavorite,
ModuleIssue,
CycleIssue,
Estimate,
EstimatePoint,
)
days = settings.HARD_DELETE_AFTER_DAYS
# check delete workspace
_ = Workspace.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete project
_ = Project.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete cycle
_ = Cycle.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete module
_ = Module.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete issue
_ = Issue.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete page
_ = Page.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete view
_ = IssueView.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete label
_ = Label.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# check delete state
_ = State.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = IssueActivity.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = IssueComment.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = IssueLink.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = IssueReaction.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = UserFavorite.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = ModuleIssue.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = CycleIssue.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = Estimate.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
_ = EstimatePoint.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
# at last, check for every thing which ever is left and delete it
# Get all Django models
all_models = apps.get_models()
# Iterate through all models
for model in all_models:
# Check if the model has a 'deleted_at' field
if hasattr(model, "deleted_at"):
# Get all instances where 'deleted_at' is greater than 30 days ago
_ = model.all_objects.filter(
deleted_at__lt=timezone.now()
- timezone.timedelta(days=days)
).delete()
return

View File

@@ -593,7 +593,8 @@ def create_issue_activity(
epoch=epoch,
)
issue_activity.created_at = issue.created_at
issue_activity.save(update_fields=["created_at"])
issue_activity.actor_id = issue.created_by_id
issue_activity.save(update_fields=["created_at", "actor_id"])
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
@@ -671,6 +672,7 @@ def delete_issue_activity(
IssueActivity(
project_id=project_id,
workspace_id=workspace_id,
issue_id=issue_id,
comment="deleted the issue",
verb="deleted",
actor_id=actor_id,
@@ -878,7 +880,6 @@ def delete_cycle_issue_activity(
cycle_name = requested_data.get("cycle_name", "")
cycle = Cycle.objects.filter(pk=cycle_id).first()
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:

View File

@@ -221,7 +221,6 @@ def notifications(
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",

View File

@@ -36,6 +36,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete-hard-delete": {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.

View File

@@ -6,8 +6,23 @@ from django.core.management import BaseCommand
class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values"
def add_arguments(self, parser):
# Positional argument
parser.add_argument(
"--key", type=str, nargs="?", help="Key to clear cache"
)
def handle(self, *args, **options):
try:
if options["key"]:
cache.delete(options["key"])
self.stdout.write(
self.style.SUCCESS(
f"Cache Cleared for key: {options['key']}"
)
)
return
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
return

View File

@@ -0,0 +1,203 @@
# Generated by Django 4.2.11 on 2024-08-13 16:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0073_alter_commentreaction_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="deployboard",
name="is_activity_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="fileasset",
name="is_archived",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="userfavorite",
name="sequence",
field=models.FloatField(default=65535),
),
migrations.CreateModel(
name="ProjectIssueType",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("level", models.PositiveIntegerField(default=0)),
("is_default", models.BooleanField(default=False)),
],
options={
"verbose_name": "Project Issue Type",
"verbose_name_plural": "Project Issue Types",
"db_table": "project_issue_types",
"ordering": ("project", "issue_type"),
},
),
migrations.AlterModelOptions(
name="issuetype",
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
},
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_project_when_deleted_at_null",
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.AlterField(
model_name="issuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_types",
to="db.workspace",
),
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together={("workspace", "name", "deleted_at")},
),
migrations.AddConstraint(
model_name="issuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
),
migrations.AddField(
model_name="projectissuetype",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="issue_type",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_issue_types",
to="db.issuetype",
),
),
migrations.AddField(
model_name="projectissuetype",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
migrations.AddField(
model_name="projectissuetype",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
migrations.RemoveField(
model_name="issuetype",
name="is_default",
),
migrations.RemoveField(
model_name="issuetype",
name="project",
),
migrations.RemoveField(
model_name="issuetype",
name="sort_order",
),
migrations.RemoveField(
model_name="issuetype",
name="weight",
),
migrations.AddConstraint(
model_name="projectissuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "issue_type"),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectissuetype",
unique_together={("project", "issue_type", "deleted_at")},
),
migrations.AddField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="issuetype",
name="level",
field=models.PositiveIntegerField(default=0),
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
]

View File

@@ -1,7 +1,9 @@
# Python imports
# Django imports
from django.db import models
from django.utils import timezone
# Module imports
from plane.bgtasks.deletion_task import soft_delete_related_objects
class TimeAuditModel(models.Model):
@@ -41,7 +43,45 @@ class UserAuditModel(models.Model):
abstract = True
class AuditModel(TimeAuditModel, UserAuditModel):
class SoftDeletionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(deleted_at__isnull=True)
class SoftDeleteModel(models.Model):
"""To soft delete records"""
deleted_at = models.DateTimeField(
verbose_name="Deleted At",
null=True,
blank=True,
)
objects = SoftDeletionManager()
all_objects = models.Manager()
class Meta:
abstract = True
def delete(self, using=None, soft=True, *args, **kwargs):
if soft:
# Soft delete the current instance
self.deleted_at = timezone.now()
self.save(using=using)
soft_delete_related_objects.delay(
self._meta.app_label,
self._meta.model_name,
self.pk,
using=using,
)
else:
# Perform hard delete if soft deletion is not enabled
return super().delete(using=using, *args, **kwargs)
class AuditModel(TimeAuditModel, UserAuditModel, SoftDeleteModel):
"""To path when the record was created and last modified"""
class Meta:

View File

@@ -42,6 +42,7 @@ class FileAsset(BaseModel):
related_name="assets",
)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
class Meta:
verbose_name = "File Asset"

View File

@@ -116,6 +116,7 @@ class CycleIssue(ProjectBaseModel):
return f"{self.cycle}"
# DEPRECATED TODO: - Remove in next release
class CycleFavorite(ProjectBaseModel):
"""_summary_
CycleFavorite (model): To store all the cycle favorite of the user
@@ -160,7 +161,14 @@ class CycleUserProperties(ProjectBaseModel):
)
class Meta:
unique_together = ["cycle", "user"]
unique_together = ["cycle", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["cycle", "user"],
condition=models.Q(deleted_at__isnull=True),
name="cycle_user_properties_unique_cycle_user_when_deleted_at_null",
)
]
verbose_name = "Cycle User Property"
verbose_name_plural = "Cycle User Properties"
db_table = "cycle_user_properties"

View File

@@ -88,7 +88,14 @@ class DashboardWidget(BaseModel):
return f"{self.dashboard.name} {self.widget.key}"
class Meta:
unique_together = ("widget", "dashboard")
unique_together = ("widget", "dashboard", "deleted_at")
constraints = [
models.UniqueConstraint(
fields=["widget", "dashboard"],
condition=models.Q(deleted_at__isnull=True),
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"

View File

@@ -40,13 +40,21 @@ class DeployBoard(WorkspaceBaseModel):
)
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
is_activity_enabled = models.BooleanField(default=True)
def __str__(self):
"""Return name of the deploy board"""
return f"{self.entity_identifier} <{self.entity_name}>"
class Meta:
unique_together = ["entity_name", "entity_identifier"]
unique_together = ["entity_name", "entity_identifier", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["entity_name", "entity_identifier"],
condition=models.Q(deleted_at__isnull=True),
name="deploy_board_unique_entity_name_entity_identifier_when_deleted_at_null",
)
]
verbose_name = "Deploy Board"
verbose_name_plural = "Deploy Boards"
db_table = "deploy_boards"

View File

@@ -1,6 +1,7 @@
# Django imports
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
@@ -19,7 +20,14 @@ class Estimate(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="estimate_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Estimate"
verbose_name_plural = "Estimates"
db_table = "estimates"

View File

@@ -21,7 +21,7 @@ class UserFavorite(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True, blank=True)
name = models.CharField(max_length=255, blank=True, null=True)
is_folder = models.BooleanField(default=False)
sequence = models.IntegerField(default=65535)
sequence = models.FloatField(default=65535)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
@@ -31,7 +31,19 @@ class UserFavorite(WorkspaceBaseModel):
)
class Meta:
unique_together = ["entity_type", "user", "entity_identifier"]
unique_together = [
"entity_type",
"user",
"entity_identifier",
"deleted_at",
]
constraints = [
models.UniqueConstraint(
fields=["entity_type", "entity_identifier", "user"],
condition=models.Q(deleted_at__isnull=True),
name="user_favorite_unique_entity_type_entity_identifier_user_when_deleted_at_null",
)
]
verbose_name = "User Favorite"
verbose_name_plural = "User Favorites"
db_table = "user_favorites"
@@ -39,9 +51,14 @@ class UserFavorite(WorkspaceBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
largest_sequence = UserFavorite.objects.filter(
workspace=self.project.workspace
).aggregate(largest=models.Max("sequence"))["largest"]
if self.project:
largest_sequence = UserFavorite.objects.filter(
workspace=self.project.workspace
).aggregate(largest=models.Max("sequence"))["largest"]
else:
largest_sequence = UserFavorite.objects.filter(
workspace=self.workspace,
).aggregate(largest=models.Max("sequence"))["largest"]
if largest_sequence is not None:
self.sequence = largest_sequence + 10000

View File

@@ -19,7 +19,14 @@ class Inbox(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=models.Q(deleted_at__isnull=True),
name="inbox_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Inbox"
verbose_name_plural = "Inboxes"
db_table = "inboxes"

View File

@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
# Module imports
from plane.utils.html_processor import strip_tags
@@ -89,6 +90,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__status=2)
| models.Q(issue_inbox__isnull=True)
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
@@ -293,7 +295,14 @@ class IssueRelation(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "related_issue"]
unique_together = ["issue", "related_issue", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "related_issue"],
condition=Q(deleted_at__isnull=True),
name="issue_relation_unique_issue_related_issue_when_deleted_at_null",
)
]
verbose_name = "Issue Relation"
verbose_name_plural = "Issue Relations"
db_table = "issue_relations"
@@ -314,7 +323,14 @@ class IssueMention(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "mention"]
unique_together = ["issue", "mention", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "mention"],
condition=Q(deleted_at__isnull=True),
name="issue_mention_unique_issue_mention_when_deleted_at_null",
)
]
verbose_name = "Issue Mention"
verbose_name_plural = "Issue Mentions"
db_table = "issue_mentions"
@@ -335,7 +351,14 @@ class IssueAssignee(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "assignee"]
unique_together = ["issue", "assignee", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "assignee"],
condition=Q(deleted_at__isnull=True),
name="issue_assignee_unique_issue_assignee_when_deleted_at_null",
)
]
verbose_name = "Issue Assignee"
verbose_name_plural = "Issue Assignees"
db_table = "issue_assignees"
@@ -510,7 +533,14 @@ class IssueUserProperty(ProjectBaseModel):
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
unique_together = ["user", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "project"],
condition=Q(deleted_at__isnull=True),
name="issue_user_property_unique_user_project_when_deleted_at_null",
)
]
def __str__(self):
"""Return properties status of the issue"""
@@ -533,7 +563,14 @@ class Label(ProjectBaseModel):
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="label_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Label"
verbose_name_plural = "Labels"
db_table = "labels"
@@ -601,7 +638,14 @@ class IssueSubscriber(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "subscriber"]
unique_together = ["issue", "subscriber", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "subscriber"],
condition=models.Q(deleted_at__isnull=True),
name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null",
)
]
verbose_name = "Issue Subscriber"
verbose_name_plural = "Issue Subscribers"
db_table = "issue_subscribers"
@@ -623,7 +667,14 @@ class IssueReaction(ProjectBaseModel):
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["issue", "actor", "reaction"]
unique_together = ["issue", "actor", "reaction", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "actor", "reaction"],
condition=models.Q(deleted_at__isnull=True),
name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null",
)
]
verbose_name = "Issue Reaction"
verbose_name_plural = "Issue Reactions"
db_table = "issue_reactions"
@@ -647,7 +698,14 @@ class CommentReaction(ProjectBaseModel):
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["comment", "actor", "reaction"]
unique_together = ["comment", "actor", "reaction", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["comment", "actor", "reaction"],
condition=models.Q(deleted_at__isnull=True),
name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null",
)
]
verbose_name = "Comment Reaction"
verbose_name_plural = "Comment Reactions"
db_table = "comment_reactions"
@@ -678,6 +736,14 @@ class IssueVote(ProjectBaseModel):
unique_together = [
"issue",
"actor",
"deleted_at",
]
constraints = [
models.UniqueConstraint(
fields=["issue", "actor"],
condition=models.Q(deleted_at__isnull=True),
name="issue_vote_unique_issue_actor_when_deleted_at_null",
)
]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"

View File

@@ -1,37 +1,56 @@
# Django imports
from django.db import models
from django.db.models import Q
# Module imports
from .workspace import WorkspaceBaseModel
from .project import ProjectBaseModel
from .base import BaseModel
class IssueType(WorkspaceBaseModel):
class IssueType(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
related_name="issue_types",
on_delete=models.CASCADE,
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
level = models.PositiveIntegerField(default=0)
class Meta:
unique_together = ["project", "name"]
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order
if self._state.adding:
# Get the largest sort order for the project
largest_sort_order = IssueType.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
# If there are issue types, set the sort order to the largest + 10000
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs)
class ProjectIssueType(ProjectBaseModel):
issue_type = models.ForeignKey(
"db.IssueType",
related_name="project_issue_types",
on_delete=models.CASCADE,
)
level = models.PositiveIntegerField(default=0)
is_default = models.BooleanField(default=False)
class Meta:
unique_together = ["project", "issue_type", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "issue_type"],
condition=Q(deleted_at__isnull=True),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
)
]
verbose_name = "Project Issue Type"
verbose_name_plural = "Project Issue Types"
db_table = "project_issue_types"
ordering = ("project", "issue_type")
def __str__(self):
return f"{self.project} - {self.issue_type}"

View File

@@ -1,6 +1,7 @@
# Django imports
from django.conf import settings
from django.db import models
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
@@ -96,7 +97,14 @@ class Module(ProjectBaseModel):
logo_props = models.JSONField(default=dict)
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=['name', 'project'],
condition=Q(deleted_at__isnull=True),
name='module_unique_name_project_when_deleted_at_null'
)
]
verbose_name = "Module"
verbose_name_plural = "Modules"
db_table = "modules"
@@ -122,7 +130,14 @@ class ModuleMember(ProjectBaseModel):
member = models.ForeignKey("db.User", on_delete=models.CASCADE)
class Meta:
unique_together = ["module", "member"]
unique_together = ["module", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["module", "member"],
condition=models.Q(deleted_at__isnull=True),
name="module_member_unique_module_member_when_deleted_at_null",
)
]
verbose_name = "Module Member"
verbose_name_plural = "Module Members"
db_table = "module_members"
@@ -141,7 +156,14 @@ class ModuleIssue(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "module"]
unique_together = ["issue", "module", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["issue", "module"],
condition=models.Q(deleted_at__isnull=True),
name="module_issue_unique_issue_module_when_deleted_at_null",
)
]
verbose_name = "Module Issue"
verbose_name_plural = "Module Issues"
db_table = "module_issues"
@@ -169,6 +191,7 @@ class ModuleLink(ProjectBaseModel):
return f"{self.module.name} {self.url}"
# DEPRECATED TODO: - Remove in next release
class ModuleFavorite(ProjectBaseModel):
"""_summary_
ModuleFavorite (model): To store all the module favorite of the user
@@ -213,7 +236,14 @@ class ModuleUserProperties(ProjectBaseModel):
)
class Meta:
unique_together = ["module", "user"]
unique_together = ["module", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["module", "user"],
condition=models.Q(deleted_at__isnull=True),
name="module_user_properties_unique_module_user_when_deleted_at_null",
)
]
verbose_name = "Module User Property"
verbose_name_plural = "Module User Property"
db_table = "module_user_properties"

View File

@@ -119,6 +119,7 @@ class PageLog(BaseModel):
return f"{self.page.name} {self.entity_name}"
# DEPRECATED TODO: - Remove in next release
class PageBlock(ProjectBaseModel):
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="blocks"
@@ -175,6 +176,7 @@ class PageBlock(ProjectBaseModel):
return f"{self.page.name} <{self.name}>"
# DEPRECATED TODO: - Remove in next release
class PageFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -232,7 +234,14 @@ class ProjectPage(BaseModel):
)
class Meta:
unique_together = ["project", "page"]
unique_together = ["project", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "page"],
condition=models.Q(deleted_at__isnull=True),
name="project_page_unique_project_page_when_deleted_at_null",
)
]
verbose_name = "Project Page"
verbose_name_plural = "Project Pages"
db_table = "project_pages"
@@ -254,7 +263,14 @@ class TeamPage(BaseModel):
)
class Meta:
unique_together = ["team", "page"]
unique_together = ["team", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "page"],
condition=models.Q(deleted_at__isnull=True),
name="team_page_unique_team_page_when_deleted_at_null",
)
]
verbose_name = "Team Page"
verbose_name_plural = "Team Pages"
db_table = "team_pages"

View File

@@ -5,6 +5,7 @@ from uuid import uuid4
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
# Modeule imports
from plane.db.mixins import AuditModel
@@ -124,7 +125,22 @@ class Project(BaseModel):
return f"{self.name} <{self.workspace.name}>"
class Meta:
unique_together = [["identifier", "workspace"], ["name", "workspace"]]
unique_together = [
["identifier", "workspace", "deleted_at"],
["name", "workspace", "deleted_at"],
]
constraints = [
models.UniqueConstraint(
fields=["identifier", "workspace"],
condition=Q(deleted_at__isnull=True),
name="project_unique_identifier_workspace_when_deleted_at_null",
),
models.UniqueConstraint(
fields=["name", "workspace"],
condition=Q(deleted_at__isnull=True),
name="project_unique_name_workspace_when_deleted_at_null",
),
]
verbose_name = "Project"
verbose_name_plural = "Projects"
db_table = "projects"
@@ -198,7 +214,14 @@ class ProjectMember(ProjectBaseModel):
super(ProjectMember, self).save(*args, **kwargs)
class Meta:
unique_together = ["project", "member"]
unique_together = ["project", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "member"],
condition=Q(deleted_at__isnull=True),
name="project_member_unique_project_member_when_deleted_at_null",
)
]
verbose_name = "Project Member"
verbose_name_plural = "Project Members"
db_table = "project_members"
@@ -223,13 +246,21 @@ class ProjectIdentifier(AuditModel):
name = models.CharField(max_length=12, db_index=True)
class Meta:
unique_together = ["name", "workspace"]
unique_together = ["name", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "workspace"],
condition=Q(deleted_at__isnull=True),
name="unique_name_workspace_when_deleted_at_null",
)
]
verbose_name = "Project Identifier"
verbose_name_plural = "Project Identifiers"
db_table = "project_identifiers"
ordering = ("-created_at",)
# DEPRECATED TODO: - Remove in next release
class ProjectFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -300,7 +331,14 @@ class ProjectPublicMember(ProjectBaseModel):
)
class Meta:
unique_together = ["project", "member"]
unique_together = ["project", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "member"],
condition=models.Q(deleted_at__isnull=True),
name="project_public_member_unique_project_member_when_deleted_at_null",
)
]
verbose_name = "Project Public Member"
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"

View File

@@ -1,6 +1,7 @@
# Django imports
from django.db import models
from django.template.defaultfilters import slugify
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
@@ -36,7 +37,14 @@ class State(ProjectBaseModel):
return f"{self.name} <{self.project.name}>"
class Meta:
unique_together = ["name", "project"]
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="state_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "State"
verbose_name_plural = "States"
db_table = "states"

View File

@@ -52,7 +52,6 @@ def get_default_display_properties():
"updated_on": True,
}
# DEPRECATED TODO: - Remove in next release
class GlobalView(BaseModel):
workspace = models.ForeignKey(
@@ -142,6 +141,7 @@ class IssueView(WorkspaceBaseModel):
return f"{self.name} <{self.project.name}>"
# DEPRECATED TODO: - Remove in next release
class IssueViewFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -185,7 +185,14 @@ class WorkspaceMember(BaseModel):
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ["workspace", "member"]
unique_together = ["workspace", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "member"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_member_unique_workspace_member_when_deleted_at_null",
)
]
verbose_name = "Workspace Member"
verbose_name_plural = "Workspace Members"
db_table = "workspace_members"
@@ -210,7 +217,14 @@ class WorkspaceMemberInvite(BaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
class Meta:
unique_together = ["email", "workspace"]
unique_together = ["email", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["email", "workspace"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_member_invite_unique_email_workspace_when_deleted_at_null",
)
]
verbose_name = "Workspace Member Invite"
verbose_name_plural = "Workspace Member Invites"
db_table = "workspace_member_invites"
@@ -240,7 +254,14 @@ class Team(BaseModel):
return f"{self.name} <{self.workspace.name}>"
class Meta:
unique_together = ["name", "workspace"]
unique_together = ["name", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "workspace"],
condition=models.Q(deleted_at__isnull=True),
name="team_unique_name_workspace_when_deleted_at_null",
)
]
verbose_name = "Team"
verbose_name_plural = "Teams"
db_table = "teams"
@@ -264,7 +285,14 @@ class TeamMember(BaseModel):
return self.team.name
class Meta:
unique_together = ["team", "member"]
unique_together = ["team", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "member"],
condition=models.Q(deleted_at__isnull=True),
name="team_member_unique_team_member_when_deleted_at_null",
)
]
verbose_name = "Team Member"
verbose_name_plural = "Team Members"
db_table = "team_members"
@@ -287,7 +315,14 @@ class WorkspaceTheme(BaseModel):
return str(self.name) + str(self.actor.email)
class Meta:
unique_together = ["workspace", "name"]
unique_together = ["workspace", "name", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "name"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_theme_unique_workspace_name_when_deleted_at_null",
)
]
verbose_name = "Workspace Theme"
verbose_name_plural = "Workspace Themes"
db_table = "workspace_themes"
@@ -312,7 +347,14 @@ class WorkspaceUserProperties(BaseModel):
)
class Meta:
unique_together = ["workspace", "user"]
unique_together = ["workspace", "user", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_properties_unique_workspace_user_when_deleted_at_null",
)
]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
db_table = "workspace_user_properties"

View File

@@ -13,6 +13,7 @@ class InstanceSerializer(BaseSerializer):
model = Instance
exclude = [
"license_key",
"user_count"
]
read_only_fields = [
"id",

View File

@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
data["is_activated"] = True
# Get all the configuration
(
ENABLE_SIGNUP,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
@@ -66,8 +67,14 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@@ -116,11 +123,21 @@ class InstanceEndpoint(BaseAPIView):
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
@@ -151,6 +168,10 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL

View File

@@ -143,6 +143,19 @@ class Command(BaseCommand):
"category": "UNSPLASH",
"is_encrypted": True,
},
# intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
for item in config_keys:
@@ -265,7 +278,11 @@ class Command(BaseCommand):
]
)
)
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
if (
bool(GITLAB_HOST)
and bool(GITLAB_CLIENT_ID)
and bool(GITLAB_CLIENT_SECRET)
):
value = "1"
else:
value = "0"

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.2.11 on 2024-07-26 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('license', '0003_alter_changelog_title_alter_changelog_version_and_more'),
]
operations = [
migrations.AddField(
model_name='changelog',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instance',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instanceadmin',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
migrations.AddField(
model_name='instanceconfiguration',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
]

View File

@@ -355,3 +355,5 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))

View File

@@ -69,7 +69,7 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
workspace__slug=slug, project_id=project_id, entity_name="project"
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -27,14 +27,11 @@ class ProjectStatesEndpoint(BaseAPIView):
status=status.HTTP_404_NOT_FOUND,
)
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
states = State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("name", "group", "color", "id", "sequence")
return Response(
states,

View File

@@ -27,4 +27,9 @@ RESTRICTED_WORKSPACE_SLUGS = [
"channels",
"upgrade",
"billing",
"sign-in",
"sign-up",
"signin",
"signup",
"config",
]

View File

@@ -18,7 +18,6 @@ from plane.db.models import (
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
@@ -30,7 +29,10 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
),
),
}
default_annotations = {
@@ -51,7 +53,6 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",

View File

@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.14
Django==4.2.15
# rest framework
djangorestframework==3.15.2
# postgres

View File

@@ -9,11 +9,20 @@ export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
UPPER_CPU_ARCH=$(tr '[:lower:]' '[:upper:]' <<< "$CPU_ARCH")
mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
SED_PREFIX=()
if [ "$OS_NAME" == "Darwin" ]; then
SED_PREFIX=("-i" "")
else
SED_PREFIX=("-i")
fi
function print_header() {
clear
@@ -51,12 +60,12 @@ function spinner() {
}
function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${CPU_ARCH^^} support." >&2
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
if [ "$CUSTOM_BUILD" == "true" ]; then
echo "" >&2
echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "build"
return 1
fi
@@ -78,7 +87,7 @@ function initialize(){
else
echo "" >&2
echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "" >&2
echo "build"
return 1
@@ -122,7 +131,7 @@ function updateEnvFile() {
return
else
# if key exists, update the value
sed -i "s/^$key=.*/$key=$value/g" "$file"
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
fi
else
echo "File not found: $file"

View File

@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.9"
"turbo": "^2.0.11"
},
"resolutions": {
"@types/react": "18.2.48"

View File

@@ -0,0 +1,13 @@
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
const view = () => {};
const domEvents = {};
return {
view,
domEvents,
};
};

View File

@@ -0,0 +1 @@
export * from "./handle";

View File

@@ -1,10 +1,14 @@
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
};
@@ -12,7 +16,7 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions = [SlashCommand(fileHandler.upload)];
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
return extensions;
};

View File

@@ -1 +1,2 @@
export * from "./ai-features";
export * from "./document-extensions";

View File

@@ -1,18 +1,28 @@
import React, { useState } from "react";
import React from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useDocumentEditor } from "@/hooks/use-document-editor";
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
interface IDocumentEditor {
containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
fileHandler: TFileHandler;
@@ -32,6 +42,8 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
@@ -44,16 +56,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
tabIndex,
value,
} = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
// use document editor
const { editor, isIndexedDbSynced } = useDocumentEditor({
disabledExtensions,
id,
editorClassName,
embedHandler,
@@ -64,7 +70,6 @@ const DocumentEditor = (props: IDocumentEditor) => {
forwardedRef,
mentionHandler,
placeholder,
setHideDragHandleFunction,
tabIndex,
});
@@ -78,10 +83,11 @@ const DocumentEditor = (props: IDocumentEditor) => {
return (
<PageRenderer
tabIndex={tabIndex}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
id={id}
tabIndex={tabIndex}
/>
);
};

View File

@@ -16,16 +16,19 @@ import { Editor, ReactRenderer } from "@tiptap/react";
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links";
import { BlockMenu } from "@/components/menus";
// types
import { TDisplayConfig } from "@/types";
type IPageRenderer = {
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
hideDragHandle?: () => void;
id: string;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
const { displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
@@ -129,12 +132,13 @@ export const PageRenderer = (props: IPageRenderer) => {
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
hideDragHandle={hideDragHandle}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
{editor && editor.isEditable && <BlockMenu editor={editor} />}
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (

View File

@@ -1,6 +1,8 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
@@ -10,11 +12,13 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// plane web types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
interface IDocumentReadOnlyEditor {
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
tabIndex?: number;
@@ -28,8 +32,10 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
id,
initialValue,
forwardedRef,
tabIndex,
@@ -37,17 +43,17 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [
embedHandler?.issue &&
IssueWidget({
widgetCallback: embedHandler?.issue.widgetCallback,
}),
],
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
});
if (!editor) {
@@ -58,7 +64,15 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
containerClassName,
});
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
return (
<PageRenderer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
tabIndex={tabIndex}
/>
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (

View File

@@ -1,17 +1,22 @@
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { cn } from "@/helpers/common";
// types
import { TDisplayConfig } from "@/types";
interface EditorContainerProps {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor | null;
editorContainerClassName: string;
children: ReactNode;
hideDragHandle?: () => void;
id: string;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorContainerClassName, hideDragHandle, children } = props;
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = () => {
if (!editor) return;
@@ -52,16 +57,25 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
}
};
const handleContainerMouseLeave = () => {
const dragHandleElement = document.querySelector("#editor-side-menu");
if (!dragHandleElement?.classList.contains("side-menu-hidden")) {
dragHandleElement?.classList.add("side-menu-hidden");
}
};
return (
<div
id="editor-container"
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={hideDragHandle}
onMouseLeave={handleContainerMouseLeave}
className={cn(
"cursor-text relative",
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>

View File

@@ -4,18 +4,19 @@ import { Editor, EditorContent } from "@tiptap/react";
import { ImageResizer } from "@/extensions/image";
interface EditorContentProps {
editor: Editor | null;
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
}
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, tabIndex, children } = props;
const { editor, children, id, tabIndex } = props;
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
{children}
</div>
);

View File

@@ -1,6 +1,8 @@
import { Editor, Extension } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// hooks
import { getEditorClassNames } from "@/helpers/common";
import { useEditor } from "@/hooks/use-editor";
@@ -11,17 +13,16 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[];
hideDragHandleOnMouseLeave: () => void;
};
export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
hideDragHandleOnMouseLeave,
id = "",
id,
initialValue,
fileHandler,
forwardedRef,
@@ -57,13 +58,14 @@ export const EditorWrapper: React.FC<Props> = (props) => {
return (
<EditorContainer
hideDragHandle={hideDragHandleOnMouseLeave}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
{children?.(editor)}
<div className="flex flex-col">
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
</div>
</EditorContainer>
);

View File

@@ -11,7 +11,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
const extensions = [EnterKeyExtension(onEnterKeyPress)];
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />;
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (

View File

@@ -1,5 +1,7 @@
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -8,12 +10,20 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props;
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
id,
initialValue,
forwardedRef,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
forwardedRef,
initialValue,
mentionHandler,
});
@@ -24,9 +34,14 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
if (!editor) return null;
return (
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} />
<EditorContentWrapper editor={editor} id={id} />
</div>
</EditorContainer>
);

View File

@@ -1,37 +1,30 @@
import { forwardRef, useCallback, useState } from "react";
import { forwardRef, useCallback } from "react";
// components
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { DragAndDrop, SlashCommand } from "@/extensions";
import { SideMenuExtension, SlashCommand } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const getExtensions = useCallback(() => {
const extensions = [
SlashCommand(fileHandler.upload),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
];
const extensions = [SlashCommand(fileHandler.upload)];
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction));
extensions.push(
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
})
);
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
return (
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}>
<EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper>
);

View File

@@ -14,7 +14,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
if (target.matches("#drag-handle")) {
event.preventDefault();
popup.current?.setProps({

View File

@@ -0,0 +1,7 @@
// types
import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
};

View File

@@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
<NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);

View File

@@ -1,414 +0,0 @@
import { Extension } from "@tiptap/core";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
scrollThreshold: {
up: number;
down: number;
};
}
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
Extension.create({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
scrollThreshold: { up: 300, down: 100 },
setHideDragHandle,
}),
];
},
});
function createDragHandleElement(): HTMLElement {
const dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
const dragHandleContainer = document.createElement("div");
dragHandleContainer.classList.add("drag-handle-container");
dragHandleElement.appendChild(dragHandleContainer);
const dotsContainer = document.createElement("div");
dotsContainer.classList.add("drag-handle-dots");
for (let i = 0; i < 6; i++) {
const spanElement = document.createElement("span");
spanElement.classList.add("drag-handle-dot");
dotsContainer.appendChild(spanElement);
}
dragHandleContainer.appendChild(dotsContainer);
return dragHandleElement;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
}
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView, node: Element) {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
}
function DragHandle(options: DragHandleOptions) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view, node);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view, node);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
listType = node.parentElement!.tagName;
}
if (node.matches("blockquote")) {
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view);
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize));
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) {
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined) return;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hidden");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hidden");
}
}
options.setHideDragHandle?.(hideDragHandle);
return new Plugin({
key: new PluginKey("dragHandle"),
view: (view) => {
dragHandleElement = createDragHandleElement();
dragHandleElement.addEventListener("dragstart", (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("contextmenu", (e) => {
handleClick(e, view);
});
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const a = document.querySelector(".frame-renderer");
if (!a) return;
if (e.clientY < options.scrollThreshold.up) {
a.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
a.scrollBy({ top: 70, behavior: "smooth" });
}
});
hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {
rect.top += 8;
rect.left -= 8;
}
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
rect.left += 8;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
dragenter: (view) => {
view.dom.classList.add("dragging");
hideDragHandle();
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
let isDroppedInsideList = false;
// Traverse up the document tree to find if we're inside a list item
for (let i = resolvedPos.depth; i > 0; i--) {
if (resolvedPos.node(i).type.name === "listItem") {
isDroppedInsideList = true;
break;
}
}
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}

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