Compare commits

...
Author SHA1 Message Date
Palanikannan M f0da90f184 fix: title update starting 2025-04-03 19:00:47 +05:30
Palanikannan M 84d403fb40 fix: 3s debounce delay post save 2025-04-03 18:56:01 +05:30
Palanikannan M 8becfffed2 fix: title sync done 2025-04-03 18:46:44 +05:30
Palanikannan M bdb14d519d Merge branch 'preview' into feat/title-sync 2025-04-03 18:05:25 +05:30
d9e3405f5a [WEB-3700] chore: improve authentication redirections (#6836)
* chore: update redirections to be from allowed hosts

* chore: update redirection logic

* chore: add web url in settings

* chore: add next path validation

* chore: update typings

* chore: update typings

* chore: update types

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-04-02 23:09:27 +05:30
Vamsi KrishnaandGitHub adee686ea3 [WEB-3699]fix: create link modal text flicker (#6860) 2025-04-02 23:08:18 +05:30
Dheeraj Kumar KetireddyandGitHub 81fae36c23 [WEB-3744] Append the deleted_at timestamp to workspcace slug when it's soft deleted (#6862) 2025-04-02 23:07:26 +05:30
Akshita GoyalandGitHub 3f652ba44e [WEB-3746] fix: intake form css (#6864) 2025-04-02 23:06:32 +05:30
Vamsi KrishnaandGitHub 16aa1d7034 [WEB-3273]fix: editor bubble menu z-index #6858 2025-04-02 17:35:30 +05:30
Anmol Singh BhatiaandGitHub 0db581509c [WEB-3745] fix: color picker event propagation (#6859) 2025-04-02 17:35:04 +05:30
M. PalanikannanandGitHub 523ab3f4a1 [WEB-3747] regression: readonly mode with fragments (#6861) 2025-04-02 17:34:28 +05:30
Palanikannan M d55b92ab1c Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 16:42:15 +05:30
Palanikannan M f168bd3beb chore: remove services 2025-04-02 16:42:08 +05:30
Palanikannan M a74fdf235b fix: common initializer 2025-04-02 15:44:52 +05:30
Palanikannan M 9f441ef136 Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 15:31:22 +05:30
Palanikannan M ad9888ce45 fix: registering handlers made simplified 2025-04-02 15:31:05 +05:30
Palanikannan M 16bd86dc0c Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 14:22:16 +05:30
M. PalanikannanandGitHub a57c37c26c [PE-304] feat: make floating link generic and use it for all editors (#6552)
* fix: make floating link generic and use it for all editors

* fix: link component behaviour with selected text fixed and storage is now typed

* chore: link view seperated

* fix: editor link edit view across multiple links resets now

* fix: link view container

* fix: cleaning up

* fix: url validation
2025-04-02 13:42:34 +05:30
Palanikannan M 4eef115fcd Merge branch 'preview' into fix/live-server-restructuring 2025-04-02 13:19:29 +05:30
Palanikannan M cabd5f4275 fix: moving around pages 2025-04-02 13:10:47 +05:30
SangeethaandGitHub 65a0530cfe [WEB-2804] fix: subscribed issue count (#6845) 2025-04-01 20:48:25 +05:30
Prateek ShouryaandGitHub 7bb291408d [WEB-3712] improvement: create draft work item logic (#6847) 2025-04-01 20:47:44 +05:30
Anmol Singh BhatiaandGitHub 4be94adaca [WEB-2597] fix: handle favorite entity data causing application error (#6853) 2025-04-01 20:47:01 +05:30
sriram veeraghantaandGitHub 2d1b3fb39e [WEB-3732 | WEB-3731] feat: Vietnamese and Portuguese language support #6854 2025-04-01 16:43:16 +05:30
Anmol Singh Bhatia 585432824f chore: portuguese translation updated 2025-04-01 15:33:49 +05:30
Anmol Singh Bhatia fe9640533c chore: vietnamese translation updated 2025-04-01 15:30:21 +05:30
Trần Huy DuẫnandGitHub 5ec817ba37 feat: add Vietnamese language support and translations (#6842)
- Added Vietnamese (Tiếng việt) to the list of supported languages.
- Created a new translations file for Vietnamese with comprehensive translations for various UI elements.
- Updated the TranslationStore to include the new Vietnamese language option.
2025-04-01 15:17:21 +05:30
HenriqueandGitHub 9279b5f1fb feat(i18n): add Brazilian Portuguese (pt-BR) translations (#6840)
Updated TranslationStore to include support for Brazilian Portuguese by importing the corresponding translations file.
Extended TLanguage type to include "pt-BR" as a valid language option.
2025-04-01 15:16:58 +05:30
Anmol Singh BhatiaandGitHub 921dfe3222 [WEB-3704] chore: work item store optimization and code refactor (#6846)
* chore: work item store optimization and code refactor

* chore: code refactor
2025-03-28 18:38:44 +05:30
Anmol Singh BhatiaandGitHub 8216785b27 [WEB-3704] fix: sub work item #6844 2025-03-28 17:02:17 +05:30
sriram veeraghanta 2bfe4d6a6e fix: tsup version upgrade 2025-03-28 15:52:56 +05:30
Prateek ShouryaandGitHub 691cbef1f2 [WEB-3701] fix: use getCycleById to ensure null handling for cycle access (#6838)
* [WEB-3701] fix: use `getCycleById` to ensure null handling for cycle access

* fix: cycle sidebar storage values
2025-03-28 15:12:40 +05:30
Prateek ShouryaandGitHub fed0ef6185 [WEB-3705] improvement: clear local db on version change (#6843)
* [WEB-3705] improvement: clear local db on version change

* chore: remove console.log
2025-03-28 15:12:03 +05:30
Akshita GoyalandGitHub e8779511ad [WEB-3673] fix: password change form (#6839)
* fix: change password

* fix: added store action for change password

* fix: type

* fix: store refactor
2025-03-28 13:35:42 +05:30
Anmol Singh BhatiaandGitHub 99dba80d19 [WEB-3540] dev: color picker component (#6823)
* dev: color picker component added

* chore: helper function added

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-03-27 17:48:39 +05:30
Anmol Singh BhatiaandGitHub 471fefce8b [WEB-3697] chore: chart components (#6835) 2025-03-27 17:46:43 +05:30
869c755065 [WEB-3698] fix: comments refactor (#6759)
* fix: comments refactor

* fix: add edited at

* chore: add edited_at validation at issue comment update

* fix: comment mentions

* fix: edited at

* fix: css

* fix: added bulk asset upload api

* fix: projectId prop fixed

* fix: css

* fix: refactor

* fix: translation

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-27 17:28:52 +05:30
Vamsi KrishnaandGitHub a5ffbffed9 [WEB-3694] feat: added dates display in user timezone in analytics sidebar (#6834) 2025-03-27 17:18:09 +05:30
Dheeraj Kumar KetireddyandGitHub 784d651c5b fix: Removed hardcoded timezone offsets and reduced the cache to 2 hours (#6837) 2025-03-27 16:53:20 +05:30
DanciaandGitHub b19bca3b50 docs: updated links in README.md file (#6833) 2025-03-27 15:11:43 +05:30
Aaryan Khandelwal c5c3344ba0 fix: title position 2025-03-27 15:08:08 +05:30
Vamsi KrishnaandGitHub 1121c58ada fix: label update for date dropdown (#6832) 2025-03-27 13:52:30 +05:30
sriram veeraghanta fb2987e9ef chore: updated gitignore 2025-03-27 12:53:29 +05:30
Palanikannan M 7393f2ed7f merge live-server-restructuring 2025-03-26 21:34:39 +05:30
Palanikannan M bc4398e344 Merge branch 'preview' into feat/title-sync 2025-03-26 21:30:09 +05:30
Aaryan KhandelwalandGitHub a25cd426a9 style: page editor width and layout updates (#6826) 2025-03-26 21:10:44 +05:30
M. PalanikannanandGitHub 993713925a feat: express decorators for rest apis and websocket (#6818)
* feat: express decorators for rest apis and websocket

* fix: added package dependency

* fix: refactor decorators
2025-03-26 20:24:05 +05:30
Vamsi KrishnaandGitHub ae6e5a48fa [WEB-3681]feat: added user timezone dates for cycle (#6820)
* feat: added user timezone dates for cycle

* *chore: added translations
*chore: refactored user timezone functions
2025-03-26 20:23:19 +05:30
c125bc54ba [WEB-3686] feat: romanian and indonesian language support (#6825)
* Add ro Romanian Language locale (#6809)

* feat: add Indonesian language support (#6794)

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>

* chore: core translation added and code refactor

---------

Co-authored-by: mnbro <107358316+mnbro@users.noreply.github.com>
Co-authored-by: Rasyid Ridho <rasyid@sekeco.id>
2025-03-26 20:10:20 +05:30
Palanikannan M 7cbdcd4c94 fix: added zod validation 2025-03-26 19:58:32 +05:30
Palanikannan M cef60b55e4 fix: error factory types and document controller error handling 2025-03-26 15:52:36 +05:30
Palanikannan M 07bf68c8ca fix: exit with error 1 2025-03-26 14:55:41 +05:30
Palanikannan M 3d07d0f678 fix: add missing packages 2025-03-26 14:52:11 +05:30
Palanikannan M 6c2fb4b287 merge decorators 2025-03-26 14:40:55 +05:30
Palanikannan M c1b8feaf6f chore: Merge preview 2025-03-26 14:01:50 +05:30
Palanikannan M 8a9cdc6133 fix: refactor decorators 2025-03-26 13:59:28 +05:30
Palanikannan M 6db75fe29c fix: added package dependency 2025-03-26 00:05:19 +05:30
Palanikannan M ea7ebe66b1 feat: express decorators for rest apis and websocket 2025-03-25 20:30:47 +05:30
41447e566a [WEB-3600] fix: private project join issue (#6799)
* fix: private project join issue

* chore: return network value

* fix: refactor

* fix: refactor

* fix: type

* chore: added restricition for private projects

* chore: removed extra validations

* chore: added value to access enum

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-03-25 20:17:16 +05:30
Prateek ShouryaandGitHub cebd0b3599 [RANTS-68] fix: z-index for image picker popover button (change project cover) (#6812) 2025-03-25 20:16:06 +05:30
Prateek ShouryaandGitHub caa522118d [RANTS-29] fix: enter key does not work in the workspace member invite modal (#6816) 2025-03-25 20:15:35 +05:30
Prateek ShouryaandGitHub aea5f39059 [RANTS-31] improvement: optimistic update for home widget reordering (#6817) 2025-03-25 20:15:00 +05:30
Palanikannan M 93066ef5d5 Merge branch 'fix/live-server-restructuring' into fix/live-server-restructuring 2025-03-25 19:46:58 +05:30
Palanikannan M 2db9d35678 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-25 19:42:44 +05:30
Palanikannan M 37cd01e306 feat: express decorators for rest apis and websocket 2025-03-25 17:51:53 +05:30
sriram veeraghanta f29867968a chore: removed sentry instrumentation dependencies 2025-03-25 15:54:44 +05:30
sriram veeraghanta c91972cc0a chore: removing sentry instrumentation 2025-03-25 15:45:31 +05:30
Palanikannan M d3defc9785 fix: redis process improved a lot 2025-03-24 22:03:59 +05:30
Anmol Singh BhatiaandGitHub 84c7375d2a [WEB-3601] chore: content updated (#6811) 2025-03-24 19:57:13 +05:30
Anmol Singh BhatiaandGitHub 5cb37a0b9c [WEB-3560] fix: table layout issue block and code refactor (#6805) 2025-03-24 19:06:36 +05:30
Anmol Singh BhatiaandGitHub c347dd7dcd [WEB-3614] chore: list layout display filters (#6801) 2025-03-24 19:05:53 +05:30
sriram veeraghanta 0ec206b75d fix: transpile packages update on space and admin apps 2025-03-24 18:55:59 +05:30
Vamsi KrishnaandGitHub e8718a84fe chore: issue detail refactor (#6803) 2025-03-24 18:33:22 +05:30
Anmol Singh BhatiaandGitHub 983e0fa081 [WEB-3438] fix: transfer completed cycle issue modal (#6802) 2025-03-24 18:30:31 +05:30
Aaryan KhandelwalandGitHub ef108839c4 [RANTS-57] chore: replace target date with due date in work item filters dropdown (#6806) 2025-03-24 18:24:10 +05:30
Bavisetti NarayanandGitHub fe04e5a292 [WEB-3658] fix: remove cycles and modules when issues are bulk deleted (#6807) 2025-03-24 18:23:09 +05:30
Aaryan KhandelwalandGitHub 50e0cb7ffd [RANTS-75] chore: update profile sidebar icons and copy for consistency (#6808) 2025-03-24 18:21:12 +05:30
Akshita GoyalandGitHub d37d210921 [WEB-3677] fix: settings dynamic pages permissions (#6804)
* fix: settings dynamic pages permissions

* fix: refactor
2025-03-24 18:15:43 +05:30
Anmol Singh BhatiaandGitHub ab3eadf767 [WEB-3614] fix: cmd-k item focus state (#6800) 2025-03-24 18:13:49 +05:30
sriram veeraghanta dbdf2f001a fix: transpile packages for web application 2025-03-24 13:47:00 +05:30
Prateek ShouryaandGitHub 0d069bf46e [RANTS-65] fix: undefined work item sequence in bulk delete work item modal (#6797) 2025-03-24 13:41:02 +05:30
Prateek ShouryaandGitHub 962923ff4f fix: admin build (#6798) 2025-03-24 13:40:07 +05:30
Samuel TorresandGitHub f720a9afb2 feat: validate github organization during OAuth login (#6700)
* feat: add GITHUB_ORGANIZATION_ID support for GitHub OAuth integration

* fix: remove debug print statements from InstanceConfigurationEndpoint
2025-03-24 12:55:20 +05:30
4032aa62c5 [WEB-3551] fix: role improvements (#6763)
* Return Cycle start and end dates in project's timezone

* fix: role improvements

* chore: role updates

* chore: update role endpoint to update workspace admin permissions

* fix: conditions

* chore: update member role for workspace members

* chore: update workspace permission role

* fix: currentAdmin permissions

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-24 12:52:57 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cbe248591e chore(deps): bump next in the npm_and_yarn group across 1 directory (#6796)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.24 to 14.2.25
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.24...v14.2.25)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 12:52:16 +05:30
Dheeraj Kumar KetireddyandGitHub 75a9b71edb [WEB-3513] fix: return cycle start and end dates in project's timezone 2025-03-24 12:51:44 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ef42ce04a4 chore(deps): bump gunicorn (#6793)
Bumps the pip group with 1 update in the /apiserver/requirements directory: [gunicorn](https://github.com/benoitc/gunicorn).


Updates `gunicorn` from 22.0.0 to 23.0.0
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 12:48:05 +05:30
6bafdb6dd8 [PE-298] Fix: Copy markdown to clipboard (#6675)
* fix: markdown for mentions fixed

* fix: copying text in mentions

* fix: refactored the component to use the same function

* chore: renamed funcion name

* add the new copy extension

* init working fix

* remove useless code

* improve readibility

* update node import

* better smaller logic

* remove log

* add open close end handler

* update readabliity

* handle tables

* handle triple click in cell

* triple tap select current line

* handle block and list

* lists fixed

* handle all possible cases of copy in table

* update the min elements

* handle multi types in table

* handle table seletion cases

* handle whole table handler

* feat: all case converd

* update markdown handling code

* update return statement

* handle using group block

* handle param

* handle multple cell in table

* handle using recursion

* add types

* fix code rabbit  suggestions

* fix root node bug

* update recursion with loop

* update transform copied to false

* refactor clipboard extension: remove options and integrate MarkdownClipboard into core extensions

* fix: header and code handler

* fix: store hooks fixed

* fix: mention id

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-03-24 12:32:11 +05:30
Palanikannan M 6d087168e2 fix: syncing title using mobx 2025-03-22 03:47:08 +05:30
Palanikannan M 8ba72e7c3d Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-03-22 03:17:39 +05:30
Palanikannan M 3a0891e0ee fix: extensions index ts cleaned up 2025-03-22 03:06:12 +05:30
Palanikannan M b035e63d38 fix: title updation with new structure 2025-03-22 02:58:13 +05:30
Palanikannan M 251149bbfa Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-03-22 02:23:59 +05:30
Palanikannan M 53efad3399 chore: restructuring project-page methods, transformers and handlers 2025-03-22 02:23:37 +05:30
Palanikannan M 304ef1a80c Merge branch 'preview' into fix/live-server-restructuring 2025-03-22 01:51:37 +05:30
Palanikannan M 16d41a3841 fix: not shutting down the app for any reason 2025-03-22 01:51:28 +05:30
Palanikannan M c2b53cc38e fix: title saving logic with debounce and abortion handled neatly 2025-03-22 00:22:00 +05:30
sriram veeraghanta 72307ec100 chore: update package version 2025-03-21 17:00:14 +05:30
NarayanBavisetti 629d1943f3 chore: added title of page in binary request 2025-03-21 15:12:54 +05:30
Palanikannan M a9f4427b21 fix: seperated decorators into it's own package 2025-03-21 03:04:19 +05:30
Palanikannan M 22905ca662 fix: navigation between title and main editor made seamless 2025-03-21 02:22:01 +05:30
sriram veeraghanta 2eb1d03c20 fix: transpile and optimize package imports 2025-03-21 01:51:50 +05:30
Palanikannan M d9df474b66 fix: added title sync capabilities along with migration 2025-03-20 20:21:37 +05:30
Prateek ShouryaandGitHub 94bf90dac5 [WEB-3597] fix: guest work item view access when hyper mode is enabled (#6785)
* [WEB-3597] fix: guest work item view access when hyper mode is enabled

* fix: only show work item created by the guest user if the guest_view_all_features is disabled
2025-03-20 19:43:40 +05:30
Anmol Singh BhatiaandGitHub b0e941e4e2 [WEB-3590] chore: sidebar enhancements (#6780)
* chore: implement optimistic update for extended sidebar item

* chore: replace eye icon with pin icon for show/hide functionality

* chore: code refactor

* chore: code refactor
2025-03-20 16:43:45 +05:30
e22265dc93 fix: intake refactor (#6698)
* fix: refactor

* fix: refactor

* fix: type

* chore: added source data in intake

* fix: css

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-20 14:06:36 +05:30
SangeethaandGitHub 075c234385 [WEB-3286] fix: allow admins to delete other admins views (#6769)
* fix: allow admins to delete other admins views

* fix: allow admins to delete other admins views
2025-03-20 14:04:28 +05:30
Vamsi KrishnaandGitHub bc539e0d01 [WEB-3175]fix: favorites menu (#6773)
* fix: favrotites menu open

* fix: open fav menu on starring projec

* chore: added constant for hardcoded text
2025-03-20 14:03:24 +05:30
NikhilandGitHub 04fb13cbca chore: update celery cron configurations (#6776) 2025-03-20 14:02:55 +05:30
Vamsi KrishnaandGitHub ca5cf27957 [WEB-3262]fix: incomplete activity render for activity in notifications (#6777)
* fix: incomplete activity render for activity in notifications

* fix: handled content overflow for long notification messages
2025-03-20 14:02:06 +05:30
Vamsi KrishnaandGitHub 433682e913 fix: space app icons display (#6784) 2025-03-20 13:58:48 +05:30
Aaryan KhandelwalandGitHub f181b671f3 chore: update year range in the calendar component (#6781) 2025-03-19 19:06:58 +05:30
Palanikannan M e4f31aea08 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-19 16:01:26 +05:30
Palanikannan M c2a3e47d3d fix: stop event prop on error 2025-03-19 16:01:09 +05:30
Prateek ShouryaandGitHub f82d4a9ead [WEB-3589] improvement: reset language to default on sign out (#6775) 2025-03-19 14:42:19 +05:30
Palanikannan M cef4110eb0 fix: handlers 2025-03-19 14:01:19 +05:30
Palanikannan M 3672ee4ef1 fix: errors and imports 2025-03-18 19:25:31 +05:30
Palanikannan M c56097b8c0 fix: better error handling for redis client 2025-03-18 18:56:50 +05:30
Palanikannan M 0d57e0ab32 fix: file structure for error handling 2025-03-18 17:32:46 +05:30
Palanikannan M df35ccecc9 fix: better error handling 2025-03-18 16:59:04 +05:30
Anmol Singh BhatiaandGitHub 3f22642732 [WEB-3580] fix: automations settings translation (#6767) 2025-03-18 16:34:58 +05:30
e339b7ad8f [WEB-3545] feat: language translations (#6762)
* feat(translations): add Korean translation (#6755)

* feat(translations): init Korean translation

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

* feat(translations): add rough Korean translation

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

---------

Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

* feat(translations): add Slovak, Deutsch, Ukrainian and Polish translation (#6743)

* feat(translation): add Slovak translation

* feat(translation): add Slovak translation for workspace

* feat(translation): improved Slovak translation for views

* feat(translation): add Deutsch translation

* feat(translation): add Ukrainian translation

* feat(translation): add Polish translation

---------

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

* fix: project cycle translations

* fix: build error

* feat: Add zh-TW Traditional Chinese locale (#6764)

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

* chore: zh-TW core translation updated

---------

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>
Co-authored-by: Ján Regeš <jan.reges@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Peter Dave Hello <hsu@peterdavehello.org>
2025-03-18 13:35:46 +05:30
Palanikannan M 38d8d3ea9b fix: dividing server code 2025-03-18 01:36:14 +05:30
Palanikannan M 3710b182d3 fix: tsup hot reloading 2025-03-18 00:16:30 +05:30
Palanikannan M 6897575a62 fix: logger and added working global error handling with sentry 2025-03-17 23:24:03 +05:30
Palanikannan M 388151b70b Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-17 15:52:49 +05:30
Vamsi KrishnaandGitHub d4991b9a48 chore: issue filters refactor (#6742)
* chore: issue filters refactor

* chore: update helper funciton implementation

* chore: removed redundant components
2025-03-17 15:45:34 +05:30
Prateek ShouryaandGitHub 1bf683e044 improvement: command palette search results (#6761) 2025-03-17 15:45:03 +05:30
Akshita GoyalandGitHub 807148671f fix: build (#6760) 2025-03-17 14:38:46 +05:30
d2b81ad2da fix: favorite mutation on quick actions (#6741)
* fix: fav mutation on quick actions

* fix: user favorite fetch

* fix: exist validation

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-17 14:14:45 +05:30
sriram veeraghanta 3d14c9d9fe fix: build test pull request changes 2025-03-17 00:38:51 +05:30
sriram veeraghanta d7f40cf578 fix: remove files changed step from branch build workflow 2025-03-17 00:26:10 +05:30
Aaryan KhandelwalandGitHub b370ef72ee [RANTS-46] fix: modules list sidebar position (#6754) 2025-03-16 23:50:04 +05:30
sriram veeraghanta 0341205666 fix: live server dev port to 3100 2025-03-13 15:42:43 +05:30
Palanikannan M 3d61604569 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-02-08 20:32:50 +05:30
Palanikannan M 1b29f65664 fix: removed .js imports 2024-12-23 18:16:53 +05:30
Palanikannan M a229508611 Merge branch 'preview' into devin/1734544044-refactor-live-server 2024-12-23 17:23:59 +05:30
Devin AIandsriram@plane.so <sriram@plane.so> d5bd4ef63a chore: switch from esbuild to tsup for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:49:27 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 146332fff3 chore: replace babel with esbuild for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:42:19 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 5802858772 fix: resolve typescript errors in server and decorators
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:05:01 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> b39ce9c18a fix: add eslint config and fix websocket router interface
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:03:29 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> a7ab5ae680 fix: resolve lint errors in decorators and collaboration controller
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:02:48 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> f2a08853e2 feat: add start.ts entry point for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:01:12 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 23eeb45713 feat: implement collaboration controller with websocket support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:54:15 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 6c83a0df09 feat: implement health check controller with decorator support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:52:40 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> dbee7488e1 feat: add decorator system and controller registration for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:50:52 +00:00
420 changed files with 35954 additions and 7753 deletions
-50
View File
@@ -47,12 +47,6 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
@@ -123,46 +117,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- nginx/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -185,7 +140,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -208,7 +162,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -231,7 +184,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -254,7 +206,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -277,7 +228,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
+4 -47
View File
@@ -6,49 +6,9 @@ on:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
lint-apiserver:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files_yaml: |
apiserver:
- apiserver/**
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Python
@@ -63,8 +23,7 @@ jobs:
run: ruff check --fix apiserver
lint-admin:
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -76,8 +35,7 @@ jobs:
- run: yarn lint --filter=admin
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -89,8 +47,7 @@ jobs:
- run: yarn lint --filter=space
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+7
View File
@@ -78,10 +78,17 @@ pnpm-workspace.yaml
.npmrc
.secrets
tmp/
## packages
dist
.temp/
deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-editor
# Redis
*.rdb
*.rdb.gz
-3
View File
@@ -43,9 +43,6 @@ NGINX_PORT=80
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
+5 -5
View File
@@ -16,10 +16,10 @@
</p>
<p align="center">
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
<a href="https://git.new/releases"><b>Releases</b></a> •
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
<p>
@@ -39,7 +39,7 @@
</a>
</p>
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
+15
View File
@@ -43,6 +43,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
},
});
@@ -93,6 +94,19 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
{
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: (
<>
The organization github ID.
</>
),
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
},
];
const GITHUB_SERVICE_FIELD: TCopyField[] = [
@@ -150,6 +164,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
});
})
.catch((err) => console.error(err));
+13
View File
@@ -9,6 +9,19 @@ const nextConfig = {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
transpilePackages: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
};
module.exports = nextConfig;
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.25.2",
"version": "0.25.3",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -21,7 +21,6 @@
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@sentry/nextjs": "^8.54.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -30,7 +29,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.20",
"next": "^14.2.25",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
-3
View File
@@ -145,11 +145,8 @@ RUN chmod +x /app/pg-setup.sh
# APPLICATION ENVIRONMENT SETTINGS
# *****************************************************************************
ENV APP_DOMAIN=localhost
ENV WEB_URL=http://${APP_DOMAIN}
ENV DEBUG=0
ENV SENTRY_DSN=
ENV SENTRY_ENVIRONMENT=production
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
# Secret Key
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
-4
View File
@@ -3,10 +3,6 @@
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.25.2",
"version": "0.25.3",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+9
View File
@@ -1,4 +1,5 @@
# Third party imports
import pytz
from rest_framework import serializers
# Module imports
@@ -18,6 +19,14 @@ class CycleSerializer(BaseSerializer):
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
project = self.context.get("project")
if project and project.timezone:
project_timezone = pytz.timezone(project.timezone)
self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone
def validate(self, data):
if (
data.get("start_date", None) is not None
+20 -12
View File
@@ -39,7 +39,7 @@ from plane.db.models import (
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -137,10 +137,12 @@ class CycleAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset, fields=self.fields, expand=self.expand
queryset, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -152,7 +154,8 @@ class CycleAPIEndpoint(BaseAPIView):
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset, many=True, fields=self.fields, expand=self.expand
queryset, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
@@ -163,7 +166,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -174,7 +178,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -185,7 +190,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -198,14 +204,16 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
return self.paginate(
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -251,7 +259,7 @@ class CycleAPIEndpoint(BaseAPIView):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -323,7 +331,7 @@ class CycleAPIEndpoint(BaseAPIView):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -694,7 +702,7 @@ class CycleIssueAPIEndpoint(BaseAPIView):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# Return all Cycle Issues
return Response(
@@ -1168,7 +1176,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+2 -2
View File
@@ -18,7 +18,7 @@ from plane.api.serializers import IntakeIssueSerializer, IssueSerializer
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
@@ -297,7 +297,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
+3 -2
View File
@@ -56,6 +56,7 @@ from plane.db.models import (
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
class WorkspaceIssueAPIEndpoint(BaseAPIView):
@@ -1048,7 +1049,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# Get the storage metadata
@@ -1108,7 +1109,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# Update the attachment
+5 -2
View File
@@ -33,6 +33,7 @@ from plane.db.models import (
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host
class ModuleAPIEndpoint(BaseAPIView):
@@ -174,7 +175,7 @@ class ModuleAPIEndpoint(BaseAPIView):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
@@ -226,7 +227,7 @@ class ModuleAPIEndpoint(BaseAPIView):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -280,6 +281,7 @@ class ModuleAPIEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=json.dumps({"module_name": str(module.name)}),
epoch=int(timezone.now().timestamp()),
origin=base_host(request=request, is_app=True),
)
module.delete()
# Delete the module issues
@@ -449,6 +451,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
}
),
epoch=int(timezone.now().timestamp()),
origin=base_host(request=request, is_app=True),
)
return Response(
+4 -4
View File
@@ -30,7 +30,7 @@ from plane.db.models import (
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
from plane.utils.host import base_host
class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
@@ -228,7 +228,7 @@ class ProjectAPIEndpoint(BaseAPIView):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
serializer = ProjectSerializer(project)
@@ -297,7 +297,7 @@ class ProjectAPIEndpoint(BaseAPIView):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
serializer = ProjectSerializer(project)
@@ -334,7 +334,7 @@ class ProjectAPIEndpoint(BaseAPIView):
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=request.META.get("HTTP_ORIGIN"),
current_site=base_host(request=request, is_app=True),
event_id=project.id,
old_identifier=None,
new_identifier=None,
+14
View File
@@ -268,6 +268,20 @@ class IssueActivitySerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
source_data = serializers.SerializerMethodField()
def get_source_data(self, obj):
if (
hasattr(obj, "issue")
and hasattr(obj.issue, "source_data")
and obj.issue.source_data
):
return {
"source": obj.issue.source_data[0].source,
"source_email": obj.issue.source_data[0].source_email,
"extra": obj.issue.source_data[0].extra,
}
return None
class Meta:
model = IssueActivity
+20 -12
View File
@@ -51,8 +51,7 @@ from plane.db.models import (
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from plane.utils.host import base_host
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
@@ -268,7 +267,7 @@ class CycleViewSet(BaseViewSet):
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
data, datetime_fields, project_timezone
)
return Response(data, status=status.HTTP_200_OK)
@@ -318,9 +317,13 @@ class CycleViewSet(BaseViewSet):
.first()
)
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
cycle, datetime_fields, project_timezone
)
# Send the model activity
@@ -331,7 +334,7 @@ class CycleViewSet(BaseViewSet):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(cycle, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -407,9 +410,13 @@ class CycleViewSet(BaseViewSet):
"created_by",
).first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
cycle, datetime_fields, project_timezone
)
# Send the model activity
@@ -420,7 +427,7 @@ class CycleViewSet(BaseViewSet):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(cycle, status=status.HTTP_200_OK)
@@ -480,10 +487,11 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, project_timezone)
recent_visited_task.delay(
slug=slug,
@@ -532,7 +540,7 @@ class CycleViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# TODO: Soft delete the cycle break the onetoone relationship with cycle issue
cycle.delete()
@@ -1071,7 +1079,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+3 -3
View File
@@ -27,7 +27,7 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -291,7 +291,7 @@ class CycleIssueViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@@ -317,7 +317,7 @@ class CycleIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+4 -3
View File
@@ -37,6 +37,7 @@ from plane.app.serializers import (
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IntakeViewSet(BaseViewSet):
@@ -283,7 +284,7 @@ class IntakeIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
intake_issue = (
@@ -407,7 +408,7 @@ class IntakeIssueViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
issue_serializer.save()
@@ -467,7 +468,7 @@ class IntakeIssueViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
intake=(intake_issue.id),
)
+12 -3
View File
@@ -14,7 +14,7 @@ from rest_framework import status
from .. import BaseAPIView
from plane.app.serializers import IssueActivitySerializer, IssueCommentSerializer
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.db.models import IssueActivity, IssueComment, CommentReaction
from plane.db.models import IssueActivity, IssueComment, CommentReaction, IntakeIssue
class IssueActivityEndpoint(BaseAPIView):
@@ -57,13 +57,22 @@ class IssueActivityEndpoint(BaseAPIView):
)
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
if request.GET.get("activity_type", None) == "issue-property":
issue_activities = issue_activities.prefetch_related(
Prefetch(
"issue__issue_intake",
queryset=IntakeIssue.objects.only(
"source_email", "source", "extra"
),
to_attr="source_data",
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
return Response(issue_activities, status=status.HTTP_200_OK)
if request.GET.get("activity_type", None) == "issue-comment":
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
return Response(issue_comments, status=status.HTTP_200_OK)
result_list = sorted(
+4 -4
View File
@@ -37,7 +37,7 @@ from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES
from plane.utils.host import base_host
# Module imports
from .. import BaseViewSet, BaseAPIView
@@ -259,7 +259,7 @@ class IssueArchiveViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue.archived_at = timezone.now().date()
issue.save()
@@ -287,7 +287,7 @@ class IssueArchiveViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue.archived_at = None
issue.save()
@@ -333,7 +333,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue.archived_at = timezone.now().date()
bulk_archive_issues.append(issue)
@@ -21,7 +21,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
@@ -48,7 +48,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -67,7 +67,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -155,7 +155,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -213,7 +213,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# Update the attachment
+15 -6
View File
@@ -45,6 +45,7 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
UserRecentVisit,
ModuleIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -60,7 +61,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.utils.host import base_host
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@@ -378,7 +379,7 @@ class IssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue = (
issue_queryset_grouper(
@@ -428,7 +429,7 @@ class IssueViewSet(BaseViewSet):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
@@ -649,7 +650,7 @@ class IssueViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
@@ -658,7 +659,7 @@ class IssueViewSet(BaseViewSet):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
@@ -690,7 +691,8 @@ class IssueViewSet(BaseViewSet):
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
subscriber=False,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -738,6 +740,13 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
total_issues = len(issues)
# First, delete all related cycle issues
CycleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Then, delete all related module issues
ModuleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Finally, delete the issues themselves
issues.delete()
return Response(
+13 -7
View File
@@ -17,7 +17,7 @@ from plane.app.serializers import IssueCommentSerializer, CommentReactionSeriali
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
@@ -87,7 +87,7 @@ class IssueCommentViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -105,7 +105,13 @@ class IssueCommentViewSet(BaseViewSet):
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
if (
"comment_html" in request.data
and request.data["comment_html"] != issue_comment.comment_html
):
serializer.save(edited_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
@@ -115,7 +121,7 @@ class IssueCommentViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -138,7 +144,7 @@ class IssueCommentViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -182,7 +188,7 @@ class CommentReactionViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -216,7 +222,7 @@ class CommentReactionViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+4 -4
View File
@@ -15,7 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IssueLinkViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission]
@@ -52,7 +52,7 @@ class IssueLinkViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -77,7 +77,7 @@ class IssueLinkViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -98,7 +98,7 @@ class IssueLinkViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+3 -3
View File
@@ -15,7 +15,7 @@ from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
@@ -53,7 +53,7 @@ class IssueReactionViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -78,7 +78,7 @@ class IssueReactionViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+3 -3
View File
@@ -27,7 +27,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.utils.host import base_host
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
@@ -253,7 +253,7 @@ class IssueRelationViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
if relation_type in ["blocking", "start_after", "finish_after"]:
@@ -290,6 +290,6 @@ class IssueRelationViewSet(BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+2 -2
View File
@@ -22,7 +22,7 @@ from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
from plane.utils.host import base_host
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
@@ -176,7 +176,7 @@ class SubIssuesEndpoint(BaseAPIView):
current_instance=json.dumps({"parent": str(sub_issue_id)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
for sub_issue_id in sub_issue_ids
]
+4 -4
View File
@@ -61,7 +61,7 @@ from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
class ModuleViewSet(BaseViewSet):
model = Module
@@ -376,7 +376,7 @@ class ModuleViewSet(BaseViewSet):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
@@ -768,7 +768,7 @@ class ModuleViewSet(BaseViewSet):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
datetime_fields = ["created_at", "updated_at"]
@@ -795,7 +795,7 @@ class ModuleViewSet(BaseViewSet):
current_instance=json.dumps({"module_name": str(module.name)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
for issue in module_issues
]
+5 -5
View File
@@ -34,7 +34,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
# Module imports
from .. import BaseViewSet
from plane.utils.host import base_host
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
@@ -221,7 +221,7 @@ class ModuleIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
for issue in issues
]
@@ -261,7 +261,7 @@ class ModuleIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
for module in modules
]
@@ -284,7 +284,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
module_issue.delete()
@@ -309,7 +309,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -550,6 +550,7 @@ class PagesDescriptionViewSet(BaseViewSet):
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.name = request.data.get("name", page.name)
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
+6 -4
View File
@@ -39,7 +39,7 @@ from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
@@ -177,7 +177,9 @@ class ProjectViewSet(BaseViewSet):
"module_view",
"page_view",
"inbox_view",
"guest_view_all_features",
"project_lead",
"network",
"created_at",
"updated_at",
"created_by",
@@ -329,7 +331,7 @@ class ProjectViewSet(BaseViewSet):
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
@@ -407,7 +409,7 @@ class ProjectViewSet(BaseViewSet):
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -452,7 +454,7 @@ class ProjectViewSet(BaseViewSet):
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=request.META.get("HTTP_ORIGIN"),
current_site=base_host(request=request, is_app=True),
event_id=project.id,
old_identifier=None,
new_identifier=None,
+19 -9
View File
@@ -16,18 +16,18 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import ProjectMemberInviteSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
ProjectMember,
Workspace,
ProjectMemberInvite,
User,
WorkspaceMember,
Project,
IssueUserProperty,
)
from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host
class ProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer
@@ -99,7 +99,7 @@ class ProjectInvitationsViewset(BaseViewSet):
project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get("HTTP_ORIGIN")
current_site = base_host(request=request, is_app=True)
# Send invitations
for invitation in project_invitations:
@@ -128,6 +128,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
.select_related("workspace", "workspace__owner", "project")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug):
project_ids = request.data.get("project_ids", [])
@@ -136,11 +137,20 @@ class UserProjectInvitationsViewset(BaseViewSet):
member=request.user, workspace__slug=slug, is_active=True
)
if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
return Response(
{"error": "You do not have permission to join the project"},
status=status.HTTP_403_FORBIDDEN,
)
# Get all the projects
projects = Project.objects.filter(
id__in=project_ids, workspace__slug=slug
).only("id", "network")
# Check if user has permission to join each project
for project in projects:
if (
project.network == ProjectNetwork.SECRET.value
and workspace_member.role != ROLE.ADMIN.value
):
return Response(
{"error": "Only workspace admins can join private project"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_role = workspace_member.role
workspace = workspace_member.workspace
+12 -18
View File
@@ -10,11 +10,7 @@ from plane.app.serializers import (
ProjectMemberRoleSerializer,
)
from plane.app.permissions import (
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
@@ -26,14 +22,6 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
def get_permissions(self):
if self.action == "leave":
self.permission_classes = [ProjectLitePermission]
else:
self.permission_classes = [ProjectMemberPermission]
return super(ProjectMemberViewSet, self).get_permissions()
search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self):
@@ -187,12 +175,20 @@ class ProjectMemberViewSet(BaseViewSet):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True
)
if request.user.id == project_member.member_id:
# Fetch the workspace role of the project member
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
is_workspace_admin = workspace_role == ROLE.ADMIN.value
# Check if the user is not editing their own role if they are not an admin
if request.user.id == project_member.member_id and not is_workspace_admin:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
@@ -205,9 +201,6 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
)
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
if workspace_role in [5] and int(
request.data.get("role", project_member.role)
) in [15, 20]:
@@ -222,6 +215,7 @@ class ProjectMemberViewSet(BaseViewSet):
"role" in request.data
and int(request.data.get("role", project_member.role))
> requested_project_member.role
and not is_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
+145 -205
View File
@@ -21,221 +21,161 @@ class TimezoneEndpoint(APIView):
throttle_classes = [AuthenticationThrottle]
@method_decorator(cache_page(60 * 60 * 24))
@method_decorator(cache_page(60 * 60 * 2))
def get(self, request):
timezone_mapping = {
"-1100": [
("Midway Island", "Pacific/Midway"),
("American Samoa", "Pacific/Pago_Pago"),
],
"-1000": [
("Hawaii", "Pacific/Honolulu"),
("Aleutian Islands", "America/Adak"),
],
"-0930": [("Marquesas Islands", "Pacific/Marquesas")],
"-0900": [
("Alaska", "America/Anchorage"),
("Gambier Islands", "Pacific/Gambier"),
],
"-0800": [
("Pacific Time (US and Canada)", "America/Los_Angeles"),
("Baja California", "America/Tijuana"),
],
"-0700": [
("Mountain Time (US and Canada)", "America/Denver"),
("Arizona", "America/Phoenix"),
("Chihuahua, Mazatlan", "America/Chihuahua"),
],
"-0600": [
("Central Time (US and Canada)", "America/Chicago"),
("Saskatchewan", "America/Regina"),
("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
("Tegucigalpa, Honduras", "America/Tegucigalpa"),
("Costa Rica", "America/Costa_Rica"),
],
"-0500": [
("Eastern Time (US and Canada)", "America/New_York"),
("Lima", "America/Lima"),
("Bogota", "America/Bogota"),
("Quito", "America/Guayaquil"),
("Chetumal", "America/Cancun"),
],
"-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
"-0400": [
("Atlantic Time (Canada)", "America/Halifax"),
("Caracas", "America/Caracas"),
("Santiago", "America/Santiago"),
("La Paz", "America/La_Paz"),
("Manaus", "America/Manaus"),
("Georgetown", "America/Guyana"),
("Bermuda", "Atlantic/Bermuda"),
],
"-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
"-0300": [
("Buenos Aires", "America/Argentina/Buenos_Aires"),
("Brasilia", "America/Sao_Paulo"),
("Greenland", "America/Godthab"),
("Montevideo", "America/Montevideo"),
("Falkland Islands", "Atlantic/Stanley"),
],
"-0200": [
(
"South Georgia and the South Sandwich Islands",
"Atlantic/South_Georgia",
)
],
"-0100": [
("Azores", "Atlantic/Azores"),
("Cape Verde Islands", "Atlantic/Cape_Verde"),
],
"+0000": [
("Dublin", "Europe/Dublin"),
("Reykjavik", "Atlantic/Reykjavik"),
("Lisbon", "Europe/Lisbon"),
("Monrovia", "Africa/Monrovia"),
("Casablanca", "Africa/Casablanca"),
],
"+0100": [
("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
("West Central Africa", "Africa/Lagos"),
("Algiers", "Africa/Algiers"),
("Lagos", "Africa/Lagos"),
("Tunis", "Africa/Tunis"),
],
"+0200": [
("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
("Athens", "Europe/Athens"),
("Jerusalem", "Asia/Jerusalem"),
("Johannesburg", "Africa/Johannesburg"),
("Harare, Pretoria", "Africa/Harare"),
],
"+0300": [
("Moscow Time", "Europe/Moscow"),
("Baghdad", "Asia/Baghdad"),
("Nairobi", "Africa/Nairobi"),
("Kuwait, Riyadh", "Asia/Riyadh"),
],
"+0330": [("Tehran", "Asia/Tehran")],
"+0400": [
("Abu Dhabi", "Asia/Dubai"),
("Baku", "Asia/Baku"),
("Yerevan", "Asia/Yerevan"),
("Astrakhan", "Europe/Astrakhan"),
("Tbilisi", "Asia/Tbilisi"),
("Mauritius", "Indian/Mauritius"),
],
"+0500": [
("Islamabad", "Asia/Karachi"),
("Karachi", "Asia/Karachi"),
("Tashkent", "Asia/Tashkent"),
("Yekaterinburg", "Asia/Yekaterinburg"),
("Maldives", "Indian/Maldives"),
("Chagos", "Indian/Chagos"),
],
"+0530": [
("Chennai", "Asia/Kolkata"),
("Kolkata", "Asia/Kolkata"),
("Mumbai", "Asia/Kolkata"),
("New Delhi", "Asia/Kolkata"),
("Sri Jayawardenepura", "Asia/Colombo"),
],
"+0545": [("Kathmandu", "Asia/Kathmandu")],
"+0600": [
("Dhaka", "Asia/Dhaka"),
("Almaty", "Asia/Almaty"),
("Bishkek", "Asia/Bishkek"),
("Thimphu", "Asia/Thimphu"),
],
"+0630": [
("Yangon (Rangoon)", "Asia/Yangon"),
("Cocos Islands", "Indian/Cocos"),
],
"+0700": [
("Bangkok", "Asia/Bangkok"),
("Hanoi", "Asia/Ho_Chi_Minh"),
("Jakarta", "Asia/Jakarta"),
("Novosibirsk", "Asia/Novosibirsk"),
("Krasnoyarsk", "Asia/Krasnoyarsk"),
],
"+0800": [
("Beijing", "Asia/Shanghai"),
("Singapore", "Asia/Singapore"),
("Perth", "Australia/Perth"),
("Hong Kong", "Asia/Hong_Kong"),
("Ulaanbaatar", "Asia/Ulaanbaatar"),
("Palau", "Pacific/Palau"),
],
"+0845": [("Eucla", "Australia/Eucla")],
"+0900": [
("Tokyo", "Asia/Tokyo"),
("Seoul", "Asia/Seoul"),
("Yakutsk", "Asia/Yakutsk"),
],
"+0930": [
("Adelaide", "Australia/Adelaide"),
("Darwin", "Australia/Darwin"),
],
"+1000": [
("Sydney", "Australia/Sydney"),
("Brisbane", "Australia/Brisbane"),
("Guam", "Pacific/Guam"),
("Vladivostok", "Asia/Vladivostok"),
("Tahiti", "Pacific/Tahiti"),
],
"+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
"+1100": [
("Solomon Islands", "Pacific/Guadalcanal"),
("Magadan", "Asia/Magadan"),
("Norfolk Island", "Pacific/Norfolk"),
("Bougainville Island", "Pacific/Bougainville"),
("Chokurdakh", "Asia/Srednekolymsk"),
],
"+1200": [
("Auckland", "Pacific/Auckland"),
("Wellington", "Pacific/Auckland"),
("Fiji Islands", "Pacific/Fiji"),
("Anadyr", "Asia/Anadyr"),
],
"+1245": [("Chatham Islands", "Pacific/Chatham")],
"+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
"+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
}
timezone_locations = [
('Midway Island', 'Pacific/Midway'), # UTC-11:00
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00)
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00)
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00)
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
('Arizona', 'America/Phoenix'), # UTC-07:00
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
('Saskatchewan', 'America/Regina'), # UTC-06:00
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
('Lima', 'America/Lima'), # UTC-05:00
('Bogota', 'America/Bogota'), # UTC-05:00
('Quito', 'America/Guayaquil'), # UTC-05:00
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
('Caracas', 'America/Caracas'), # UTC-04:00
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
('La Paz', 'America/La_Paz'), # UTC-04:00
('Manaus', 'America/Manaus'), # UTC-04:00
('Georgetown', 'America/Guyana'), # UTC-04:00
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
('Montevideo', 'America/Montevideo'), # UTC-03:00
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
('Algiers', 'Africa/Algiers'), # UTC+01:00
('Lagos', 'Africa/Lagos'), # UTC+01:00
('Tunis', 'Africa/Tunis'), # UTC+01:00
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
('Islamabad', 'Asia/Karachi'), # UTC+05:00
('Karachi', 'Asia/Karachi'), # UTC+05:00
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
('Maldives', 'Indian/Maldives'), # UTC+05:00
('Chagos', 'Indian/Chagos'), # UTC+05:00
('Chennai', 'Asia/Kolkata'), # UTC+05:30
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
('Almaty', 'Asia/Almaty'), # UTC+06:00
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
('Beijing', 'Asia/Shanghai'), # UTC+08:00
('Singapore', 'Asia/Singapore'), # UTC+08:00
('Perth', 'Australia/Perth'), # UTC+08:00
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00
('Palau', 'Pacific/Palau'), # UTC+08:00
('Eucla', 'Australia/Eucla'), # UTC+08:45
('Tokyo', 'Asia/Tokyo'), # UTC+09:00
('Seoul', 'Asia/Seoul'), # UTC+09:00
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30)
('Darwin', 'Australia/Darwin'), # UTC+09:30
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00)
('Brisbane', 'Australia/Brisbane'), # UTC+10:00
('Guam', 'Pacific/Guam'), # UTC+10:00
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00)
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00
('Magadan', 'Asia/Magadan'), # UTC+11:00
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00)
('Anadyr', 'Asia/Anadyr'), # UTC+12:00
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45)
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00)
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00
]
timezone_list = []
now = datetime.now()
# Process timezone mapping
for offset, locations in timezone_mapping.items():
sign = "-" if offset.startswith("-") else "+"
hours = offset[1:3]
minutes = offset[3:] if len(offset) > 3 else "00"
for friendly_name, tz_identifier in timezone_locations:
for friendly_name, tz_identifier in locations:
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
gmt_offset = (
f"GMT{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
offset = (
f"{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{sign}{hours}:{minutes}",
"gmt_offset": gmt_offset,
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{offset}",
"gmt_offset": f"GMT{offset}",
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
# Sort by offset and then by label
timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
+1 -1
View File
@@ -117,7 +117,7 @@ class WorkspaceViewViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView
allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=IssueView
)
def destroy(self, request, slug, pk):
workspace_view = IssueView.objects.get(pk=pk, workspace__slug=slug)
+4 -4
View File
@@ -36,7 +36,7 @@ from plane.db.models import (
from .. import BaseViewSet
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.host import base_host
class WorkspaceDraftIssueViewSet(BaseViewSet):
model = DraftIssue
@@ -241,7 +241,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
if request.data.get("cycle_id", None):
@@ -270,7 +270,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
if request.data.get("module_ids", []):
@@ -300,7 +300,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
origin=base_host(request=request, is_app=True),
)
for module in request.data.get("module_ids", [])
]
@@ -34,6 +34,22 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
# If the favorite exists return
if request.data.get("entity_identifier"):
user_favorites = UserFavorite.objects.filter(
workspace=workspace,
user_id=request.user.id,
entity_type=request.data.get("entity_type"),
entity_identifier=request.data.get("entity_identifier"),
).first()
# If the favorite exists return
if user_favorites:
serializer = UserFavoriteSerializer(user_favorites)
return Response(serializer.data, status=status.HTTP_200_OK)
# else create a new favorite
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -7,7 +7,6 @@ import jwt
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Count
from django.utils import timezone
# Third party modules
@@ -26,7 +25,8 @@ from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
from .. import BaseViewSet
@@ -122,7 +122,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
workspace_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get("HTTP_ORIGIN")
current_site = base_host(request=request, is_app=True)
# Send invitations
for invitation in workspace_invitations:
@@ -213,7 +213,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
user=user.id if user is not None else None,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
ip=get_client_ip(request=request),
event_name="MEMBER_ACCEPTED",
accepted_from="EMAIL",
)
@@ -68,10 +68,11 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member.role > int(request.data.get("role")):
_ = ProjectMember.objects.filter(
# If a user is moved to a guest role he can't have any other role in projects
if "role" in request.data and int(request.data.get("role")) == 5:
ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id
).update(role=int(request.data.get("role")))
).update(role=5)
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
@@ -15,8 +15,8 @@ from plane.db.models import Profile, User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
from plane.bgtasks.user_activation_email_task import user_activation_email
from plane.authentication.utils.host import base_host
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
class Adapter:
"""Common interface for all auth providers"""
@@ -108,7 +108,7 @@ class Adapter:
user.last_login_medium = self.provider
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
user.last_login_ip = get_client_ip(request=self.request)
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
# If user is not active, send the activation email and set the user as active
@@ -36,10 +36,12 @@ AUTHENTICATION_ERROR_CODES = {
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -18,11 +18,16 @@ from plane.authentication.adapter.error import (
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
org_membership_url = f"https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
@@ -32,6 +37,10 @@ class GitHubOAuthProvider(OauthAdapter):
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
]
)
@@ -43,6 +52,10 @@ class GitHubOAuthProvider(OauthAdapter):
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
self.organization_id = GITHUB_ORGANIZATION_ID
if self.organization_id:
self.scope += f" {self.organization_scope}"
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = {
@@ -113,12 +126,26 @@ class GitHubOAuthProvider(OauthAdapter):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def is_user_in_organization(self, github_username):
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
return response.status_code == 200 # 200 means the user is a member
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
if self.organization_id:
if not self.is_user_in_organization(user_info_response.get("login")):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
super().set_user_data(
{
+9 -11
View File
@@ -1,18 +1,16 @@
# Python imports
from urllib.parse import urlsplit
# Django imports
from django.conf import settings
from django.http import HttpRequest
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
def base_host(request, is_admin=False, is_space=False, is_app=False):
def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str:
"""Utility function to return host / origin from the request"""
# Calculate the base origin from request
base_origin = str(
request.META.get("HTTP_ORIGIN")
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}"""
)
base_origin = settings.WEB_URL or settings.APP_BASE_URL
# Admin redirections
if is_admin:
@@ -38,5 +36,5 @@ def base_host(request, is_admin=False, is_space=False, is_app=False):
return base_origin
def user_ip(request):
return str(request.META.get("REMOTE_ADDR"))
def user_ip(request: Request | HttpRequest) -> str:
return get_client_ip(request=request)
@@ -3,8 +3,8 @@ from django.contrib.auth import login
from django.conf import settings
# Module imports
from plane.authentication.utils.host import base_host
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
def user_login(request, user, is_app=False, is_admin=False, is_space=False):
login(request=request, user=user)
@@ -15,7 +15,7 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False):
device_info = {
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"ip_address": request.META.get("REMOTE_ADDR", ""),
"ip_address": get_client_ip(request=request),
"domain": base_host(
request=request, is_app=is_app, is_admin=is_admin, is_space=is_space
),
@@ -19,7 +19,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class SignInAuthEndpoint(View):
def post(self, request):
@@ -34,7 +34,7 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
# Base URL join
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
@@ -58,7 +58,7 @@ class SignInAuthEndpoint(View):
params = exc.get_error_dict()
# Next path
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -76,7 +76,7 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -92,7 +92,7 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -111,7 +111,7 @@ class SignInAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(next_path)
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
@@ -121,7 +121,7 @@ class SignInAuthEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -141,7 +141,7 @@ class SignUpAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -161,7 +161,7 @@ class SignUpAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -179,7 +179,7 @@ class SignUpAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -197,7 +197,7 @@ class SignUpAuthEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -216,7 +216,7 @@ class SignUpAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
@@ -225,7 +225,7 @@ class SignUpAuthEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
@@ -35,7 +35,7 @@ class GitHubOauthInitiateEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -49,7 +49,7 @@ class GitHubOauthInitiateEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -70,7 +70,7 @@ class GitHubCallbackEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -81,7 +81,7 @@ class GitHubCallbackEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -94,7 +94,7 @@ class GitHubCallbackEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
@@ -103,6 +103,6 @@ class GitHubCallbackEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateEndpoint(View):
def get(self, request):
@@ -24,7 +24,7 @@ class GitLabOauthInitiateEndpoint(View):
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
request.session["next_path"] = str(validate_next_path(next_path))
# Check instance configuration
instance = Instance.objects.first()
@@ -35,7 +35,7 @@ class GitLabOauthInitiateEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -49,7 +49,7 @@ class GitLabOauthInitiateEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -81,7 +81,7 @@ class GitLabCallbackEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -94,7 +94,7 @@ class GitLabCallbackEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = next_path
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
@@ -103,6 +103,6 @@ class GitLabCallbackEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -18,7 +18,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateEndpoint(View):
def get(self, request):
@@ -36,7 +36,7 @@ class GoogleOauthInitiateEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -51,7 +51,7 @@ class GoogleOauthInitiateEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -72,7 +72,7 @@ class GoogleCallbackEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
@@ -82,7 +82,7 @@ class GoogleCallbackEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
@@ -95,11 +95,11 @@ class GoogleCallbackEndpoint(View):
# Get the redirection path
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, str(next_path) if next_path else path)
url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -26,6 +26,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.utils.path_validator import validate_next_path
class MagicGenerateEndpoint(APIView):
@@ -43,14 +44,13 @@ class MagicGenerateEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = request.META.get("HTTP_ORIGIN", "/")
email = request.data.get("email", "").strip().lower()
try:
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
magic_link.delay(email, key, token)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
except AuthenticationException as e:
params = e.get_error_dict()
@@ -73,7 +73,7 @@ class MagicSignInEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -89,7 +89,7 @@ class MagicSignInEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -122,7 +122,7 @@ class MagicSignInEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
)
@@ -145,7 +145,7 @@ class MagicSignUpEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -159,7 +159,7 @@ class MagicSignUpEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -177,7 +177,7 @@ class MagicSignUpEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(next_path)
path = str(validate_next_path(next_path))
else:
path = get_redirection_path(user=user)
# redirect to referer path
@@ -187,7 +187,7 @@ class MagicSignUpEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
)
@@ -80,7 +80,7 @@ class ForgotPasswordEndpoint(APIView):
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
current_site = base_host(request=request, is_app=True)
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
+16 -3
View File
@@ -44,10 +44,21 @@ class ChangePasswordEndpoint(APIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
old_password = request.data.get("old_password", False)
# If the user password is not autoset then we need to check the old passwords
if not user.is_password_autoset:
old_password = request.data.get("old_password", False)
if not old_password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
error_message="MISSING_PASSWORD",
payload={"error": "Old password is missing"},
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Get the new password
new_password = request.data.get("new_password", False)
if not old_password or not new_password:
if not new_password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
error_message="MISSING_PASSWORD",
@@ -55,7 +66,9 @@ class ChangePasswordEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
if not user.check_password(old_password):
# If the user password is not autoset then we need to check the old passwords
if not user.is_password_autoset and not user.check_password(old_password):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INCORRECT_OLD_PASSWORD"],
error_message="INCORRECT_OLD_PASSWORD",
@@ -17,6 +17,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
class SignInAuthSpaceEndpoint(View):
@@ -32,7 +33,7 @@ class SignInAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -51,7 +52,7 @@ class SignInAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -67,7 +68,7 @@ class SignInAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -82,7 +83,7 @@ class SignInAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -99,7 +100,7 @@ class SignInAuthSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -117,7 +118,7 @@ class SignUpAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -135,7 +136,7 @@ class SignUpAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Validate the email
@@ -151,7 +152,7 @@ class SignUpAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -166,7 +167,7 @@ class SignUpAuthSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -183,6 +184,6 @@ class SignUpAuthSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -15,6 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateSpaceEndpoint(View):
@@ -34,7 +35,7 @@ class GitHubOauthInitiateSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -66,7 +67,7 @@ class GitHubCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -77,7 +78,7 @@ class GitHubCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -93,6 +94,6 @@ class GitHubCallbackSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -15,6 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateSpaceEndpoint(View):
@@ -34,7 +35,7 @@ class GitLabOauthInitiateSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -66,7 +67,7 @@ class GitLabCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -77,7 +78,7 @@ class GitLabCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -93,6 +94,6 @@ class GitLabCallbackSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -15,6 +15,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateSpaceEndpoint(View):
@@ -33,7 +34,7 @@ class GoogleOauthInitiateSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -46,7 +47,7 @@ class GoogleOauthInitiateSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -65,7 +66,7 @@ class GoogleCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
if not code:
@@ -75,7 +76,7 @@ class GoogleCallbackSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = next_path
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
try:
@@ -89,6 +90,6 @@ class GoogleCallbackSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -23,7 +23,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class MagicGenerateSpaceEndpoint(APIView):
permission_classes = [AllowAny]
@@ -38,14 +38,14 @@ class MagicGenerateSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
origin = base_host(request=request, is_space=True)
email = request.data.get("email", "").strip().lower()
try:
validate_email(email)
adapter = MagicCodeProvider(request=request, key=email)
key, token = adapter.initiate()
# If the smtp is configured send through here
magic_link.delay(email, key, token, origin)
magic_link.delay(email, key, token)
return Response({"key": str(key)}, status=status.HTTP_200_OK)
except AuthenticationException as e:
return Response(e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
@@ -67,7 +67,7 @@ class MagicSignInSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -80,7 +80,7 @@ class MagicSignInSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -121,7 +121,7 @@ class MagicSignUpSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
# Existing User
@@ -134,7 +134,7 @@ class MagicSignUpSpaceEndpoint(View):
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -152,6 +152,6 @@ class MagicSignUpSpaceEndpoint(View):
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
@@ -90,7 +90,7 @@ class ForgotPasswordSpaceEndpoint(APIView):
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
current_site = base_host(request=request, is_space=True)
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
@@ -7,6 +7,7 @@ from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
from plane.utils.path_validator import validate_next_path
class SignOutAuthSpaceEndpoint(View):
@@ -21,8 +22,8 @@ class SignOutAuthSpaceEndpoint(View):
user.save()
# Log the user out
logout(request)
url = f"{base_host(request=request, is_space=True)}{next_path}"
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
return HttpResponseRedirect(url)
except Exception:
url = f"{base_host(request=request, is_space=True)}{next_path}"
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
return HttpResponseRedirect(url)
@@ -16,7 +16,7 @@ from plane.utils.exception_logger import log_exception
@shared_task
def magic_link(email, key, token, current_site):
def magic_link(email, key, token):
try:
(
EMAIL_HOST,
+24 -23
View File
@@ -15,34 +15,35 @@ app = Celery("plane")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.conf.beat_schedule = {
# Executes every day at 12 AM
"check-every-day-to-archive-and-close": {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete-file-asset": {
"task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset",
"schedule": crontab(hour=0, minute=0),
},
# Intra day recurring jobs
"check-every-five-minutes-to-send-email-notifications": {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"),
},
"check-every-day-to-delete-hard-delete": {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=0, minute=0),
"schedule": crontab(minute="*/5"), # Every 5 minutes
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6", minute=0),
"schedule": crontab(hour="*/6", minute=0), # Every 6 hours
},
# Occurs once every day
"check-every-day-to-delete-hard-delete": {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0), # UTC 00:00
},
"check-every-day-to-archive-and-close": {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=1, minute=0), # UTC 01:00
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=1, minute=30), # UTC 01:30
},
"check-every-day-to-delete-file-asset": {
"task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset",
"schedule": crontab(hour=2, minute=0), # UTC 02:00
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=2, minute=30), # UTC 02:30
},
}
@@ -0,0 +1,78 @@
import time
from django.core.management.base import BaseCommand
from django.db import transaction
from plane.db.models import Workspace
class Command(BaseCommand):
help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
def add_arguments(self, parser):
parser.add_argument(
"slug",
type=str,
help="The slug of the workspace to update",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Run the command without making any changes",
)
def handle(self, *args, **options):
slug = options["slug"]
dry_run = options["dry_run"]
# Get the workspace with the specified slug
try:
workspace = Workspace.all_objects.get(slug=slug)
except Workspace.DoesNotExist:
self.stdout.write(
self.style.ERROR(f"Workspace with slug '{slug}' not found.")
)
return
# Check if the workspace is soft-deleted
if workspace.deleted_at is None:
self.stdout.write(
self.style.WARNING(
f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted."
)
)
return
# Check if the slug already has a timestamp appended
if "__" in workspace.slug and workspace.slug.split("__")[-1].isdigit():
self.stdout.write(
self.style.WARNING(
f"Workspace '{workspace.name}' (slug: {workspace.slug}) already has a timestamp appended."
)
)
return
# Get the deletion timestamp
deletion_timestamp = int(workspace.deleted_at.timestamp())
# Create the new slug with the deletion timestamp
new_slug = f"{workspace.slug}__{deletion_timestamp}"
if dry_run:
self.stdout.write(
f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'"
)
else:
try:
with transaction.atomic():
workspace.slug = new_slug
workspace.save(update_fields=["slug"])
self.stdout.write(
self.style.SUCCESS(
f"Updated workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Error updating workspace '{workspace.name}': {str(e)}"
)
)
@@ -3,7 +3,6 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from sentry_sdk import capture_exception
import uuid
@@ -29,7 +28,6 @@ def create_issue_relation(apps, schema_editor):
)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
+10
View File
@@ -1,6 +1,7 @@
# Python imports
import pytz
from uuid import uuid4
from enum import Enum
# Django imports
from django.conf import settings
@@ -17,6 +18,15 @@ from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
class ProjectNetwork(Enum):
SECRET = 0
PUBLIC = 2
@classmethod
def choices(cls):
return [(0, "Secret"), (2, "Public")]
def get_default_props():
return {
"filters": {
+32 -1
View File
@@ -1,6 +1,9 @@
# Python imports
from django.db.models.functions import Ln
import pytz
import time
from django.utils import timezone
from typing import Optional, Any, Tuple, Dict
# Django imports
from django.conf import settings
@@ -149,6 +152,34 @@ class Workspace(BaseModel):
return self.logo
return None
def delete(
self,
using: Optional[str] = None,
soft: bool = True,
*args: Any,
**kwargs: Any
):
"""
Override the delete method to append epoch timestamp to the slug when soft deleting.
Args:
using: The database alias to use for the deletion.
soft: Whether to perform a soft delete (True) or hard delete (False).
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
"""
# Call the parent class's delete method first
result = super().delete(using=using, soft=soft, *args, **kwargs)
# If it's a soft delete and the model still exists (not hard deleted)
if soft and hasattr(self, 'deleted_at') and self.deleted_at:
# Use the deleted_at timestamp to update the slug
deletion_timestamp: int = int(self.deleted_at.timestamp())
self.slug = f"{self.slug}__{deletion_timestamp}"
self.save(update_fields=["slug"])
return result
class Meta:
verbose_name = "Workspace"
verbose_name_plural = "Workspaces"
@@ -391,7 +422,7 @@ class WorkspaceHomePreference(BaseModel):
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
class UserPreferenceKeys(models.TextChoices):
VIEWS = "views", "Views"
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
ANALYTICS = "analytics", "Analytics"
+3 -2
View File
@@ -33,6 +33,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.ip_address import get_client_ip
class InstanceAdminEndpoint(BaseAPIView):
@@ -217,7 +218,7 @@ class InstanceAdminSignUpEndpoint(View):
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_ip = get_client_ip(request=request)
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
@@ -344,7 +345,7 @@ class InstanceAdminSignInEndpoint(View):
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_ip = get_client_ip(request=request)
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
@@ -71,6 +71,12 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
@@ -1,5 +1,6 @@
# Module imports
from plane.db.models import APIActivityLog
from plane.utils.ip_address import get_client_ip
class APITokenLogMiddleware:
def __init__(self, get_response):
@@ -28,7 +29,7 @@ class APITokenLogMiddleware:
response.content.decode("utf-8") if response.content else None
),
response_code=response.status_code,
ip_address=request.META.get("REMOTE_ADDR", None),
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
+2 -25
View File
@@ -7,13 +7,9 @@ from urllib.parse import urlparse
# Third party imports
import dj_database_url
import sentry_sdk
# Django imports
from django.core.management.utils import get_random_secret_key
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
@@ -26,7 +22,7 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
DEBUG = int(os.environ.get("DEBUG", "0"))
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
# Application definition
INSTALLED_APPS = [
@@ -267,25 +263,6 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
# Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
"SENTRY_DSN"
).startswith("https://"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[
DjangoIntegration(),
RedisIntegration(),
CeleryIntegration(monitor_beat_tasks=True),
],
traces_sample_rate=1,
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)),
)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
@@ -337,7 +314,7 @@ 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")
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
WEB_URL = os.environ.get("WEB_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
@@ -5,9 +5,6 @@ import traceback
# Django imports
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
def log_exception(e):
# Log the error
@@ -18,6 +15,4 @@ def log_exception(e):
# Print the traceback if in debug mode
print(traceback.format_exc())
# Capture in sentry if configured
capture_exception(e)
return
+14 -11
View File
@@ -1,18 +1,21 @@
# Python imports
from urllib.parse import urlsplit
# Django imports
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest
# Third party imports
from rest_framework.request import Request
def base_host(request, is_admin=False, is_space=False, is_app=False):
# Module imports
from plane.utils.ip_address import get_client_ip
def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str:
"""Utility function to return host / origin from the request"""
# Calculate the base origin from request
base_origin = str(
request.META.get("HTTP_ORIGIN")
or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}"
or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}"""
)
base_origin = settings.WEB_URL or settings.APP_BASE_URL
if not base_origin:
raise ImproperlyConfigured("APP_BASE_URL or WEB_URL is not set")
# Admin redirections
if is_admin:
@@ -38,5 +41,5 @@ def base_host(request, is_admin=False, is_space=False, is_app=False):
return base_origin
def user_ip(request):
return str(request.META.get("REMOTE_ADDR"))
def user_ip(request: Request | HttpRequest) -> str:
return get_client_ip(request=request)
+21
View File
@@ -0,0 +1,21 @@
# Python imports
from urllib.parse import urlparse
def validate_next_path(next_path: str) -> str:
"""Validates that next_path is a valid path and extracts only the path component."""
parsed_url = urlparse(next_path)
# Ensure next_path is not an absolute URL
if parsed_url.scheme or parsed_url.netloc:
next_path = parsed_url.path # Extract only the path component
# Ensure it starts with a forward slash (indicating a valid relative path)
if not next_path.startswith("/"):
return ""
# Ensure it does not contain dangerous path traversal sequences
if ".." in next_path:
return ""
return next_path
-2
View File
@@ -26,8 +26,6 @@ faker==25.0.0
django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.8.0
# storage
django-storages==1.14.2
# user management
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
# server
gunicorn==22.0.0
gunicorn==23.0.0
+3 -15
View File
@@ -6,16 +6,8 @@
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"keywords": ["plane", "project management", "django", "next"],
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
@@ -61,10 +53,6 @@
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
@@ -82,4 +70,4 @@
"value": ""
}
}
}
}
+1 -2
View File
@@ -42,8 +42,6 @@ x-live-env: &live-env
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
@@ -53,6 +51,7 @@ x-app-env: &app-env
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
services:
web:
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
-2
View File
@@ -12,8 +12,6 @@ LIVE_REPLICAS=1
NGINX_PORT=80
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
+17 -16
View File
@@ -1,15 +1,17 @@
{
"name": "live",
"version": "0.25.2",
"version": "0.25.3",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
@@ -20,17 +22,19 @@
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@hocuspocus/transformer": "^2.15.2",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
"@plane/logger": "*",
"@plane/types": "*",
"@sentry/node": "^9.0.1",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -39,27 +43,24 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.20",
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "^8.4.0",
"tsup": "8.3.0",
"typescript": "5.3.3"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./register";
@@ -0,0 +1,107 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
import { logger } from "@plane/logger";
import { HocusPocusServerContext } from "@/core/types/common";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async ({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async ({
context,
pageId,
state: updatedDescription,
title,
}: {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
}) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
export const fetchProjectPageTitle = async ({
context,
pageId,
}: {
context: HocusPocusServerContext;
pageId: string;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
return pageDetails.name;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
export const updateProjectPageTitle = async ({
context,
pageId,
title,
abortSignal,
}: {
context: HocusPocusServerContext;
pageId: string;
title: string;
abortSignal?: AbortSignal;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {
name: title,
};
await pageService.updateTitle(workspaceSlug, projectId, pageId, payload, cookie, abortSignal);
};
@@ -0,0 +1,3 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -0,0 +1,47 @@
import {
DocumentHandler,
DocumentFetchParams,
DocumentStoreParams,
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import {
fetchPageDescriptionBinary,
updatePageDescription,
fetchProjectPageTitle,
updateProjectPageTitle,
} from "./handlers";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: fetchPageDescriptionBinary,
/**
* Store project page description
*/
store: updatePageDescription,
/**
* Fetch project page title
*/
fetchTitle: fetchProjectPageTitle,
/**
* Store project page title
*/
updateTitle: updateProjectPageTitle,
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page",
handler: projectPageHandler,
priority: 10, // Standard priority
};
// Register the handler directly from CE
export function registerProjectPageHandler() {
handlerFactory.register(projectPageHandlerDefinition);
}
@@ -0,0 +1,26 @@
import { PageService } from "@/core/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import logger from "@plane/logger";
const pageService = new PageService();
/**
* Transforms HTML description to binary format
*/
export const transformHTMLToBinary = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
+5
View File
@@ -0,0 +1,5 @@
import { registerProjectPageHandler } from "./project-page";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
}
+18 -7
View File
@@ -1,14 +1,25 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
const { cookie, documentType, pageId, params } = args;
if (!documentType) {
throw new AppError("Document type is required");
}
if (!pageId) {
throw new AppError("Page ID is required");
}
return null
};
+1 -1
View File
@@ -1,5 +1,5 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
+1 -1
View File
@@ -1 +1 @@
export type TAdditionalDocumentTypes = {};
export type TAdditionalDocumentTypes = string;
+66
View File
@@ -0,0 +1,66 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
-19
View File
@@ -1,19 +0,0 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
+21
View File
@@ -0,0 +1,21 @@
import { HealthController } from "@/core/controllers/health.controller";
import { DocumentController } from "@/core/controllers/document.controller";
import { CollaborationController } from "@/core/controllers/collaboration.controller";
/**
* Controller registry exports
* Simple grouped arrays of controller classes for better organization
*/
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
@@ -0,0 +1,96 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,128 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
private metrics = {
conversions: 0,
errors: 0,
};
@Post("/")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId,
};
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Log validated data
logger.info("Validated document conversion request", {
...clientInfo,
variant,
contentLength: description_html.length,
});
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Update metrics
this.metrics.conversions++;
// Log successful conversion
logger.info("Document conversion successful", {
...clientInfo,
variant,
outputLength: description_html.length,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
// Update error metrics
this.metrics.errors++;
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: {
...clientInfo,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: clientInfo,
});
}
// Log the error
logger.error("Document conversion failed", {
error: appError,
status: appError.status,
context: appError.context,
});
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
getMetrics() {
return {
conversions: this.metrics.conversions,
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,16 @@
import { CatchErrors } from "@/lib/decorators";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
@Controller("/health")
export class HealthController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
}
}
+4
View File
@@ -0,0 +1,4 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
+114
View File
@@ -0,0 +1,114 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getDocumentHandler } from "../handlers/document-handlers";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore,
});
};
const handleFetch = async ({
context,
documentName: pageId,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
}) => {
const { documentType } = context;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "fetch",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(context);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
});
if (!fetchedData) {
handleError(null, {
errorType: "not-found",
message: `Failed to fetch document: ${pageId}`,
component: "database-extension",
operation: "fetch",
extraContext: { documentType, pageId },
});
}
return fetchedData;
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "fetch" },
}
)();
return fetchedData;
};
const handleStore = async ({
context,
state,
documentName: pageId,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
}) => {
catchAsync(
async () => {
if (!state) {
handleError(null, {
errorType: "bad-request",
message: "Loaded binary state is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(context);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
});
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "store" },
}
)();
};
+13 -128
View File
@@ -1,143 +1,28 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
// hocuspocus extensions and core
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
// types
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
import { setupRedisExtension } from "@/core/extensions/redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions: () => Promise<Extension[]> = async () => {
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
logger.info(message);
},
}),
createDatabaseExtension(),
];
const redisUrl = getRedisUrl();
const titleSyncExtension = new TitleSyncExtension();
extensions.push(titleSyncExtension);
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
extensions.push(...redisExtensions);
return extensions;
};
+41
View File
@@ -0,0 +1,41 @@
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
// core helpers and utilities
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async (): Promise<Extension[]> => {
const extensions: Extension[] = [];
const redisManager = RedisManager.getInstance();
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
extensions.push(
new HocusPocusRedis({
redis: redisClient,
})
);
logger.info("HocusPocus Redis extension configured ✅");
} else {
logger.warn(
"Redis connection failed, continuing without Redis extension (you won't be able to sync data between multiple plane live servers)"
);
}
return extensions;
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};

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