Compare commits

..

72 Commits

Author SHA1 Message Date
NarayanBavisetti 827a00c1d3 chore: instance admin passowrd change 2023-11-23 13:07:42 +05:30
Anmol Singh Bhatia 784be47e91 [FED-888] fix: parent issue select modal improvement (#2837)
This PR include improvement for parent issue select modal.
2023-11-22 16:16:52 +05:30
Anmol Singh Bhatia 0fdd9c28bf fix: project setting ui consistency (#2835) 2023-11-22 15:36:34 +05:30
Anmol Singh Bhatia 644b06749b fix: profile setting overflow (#2834) 2023-11-22 15:35:51 +05:30
Anmol Singh Bhatia dd8c7a7487 fix: cycle and module create/update modal fix (#2833) 2023-11-22 15:35:24 +05:30
Anmol Singh Bhatia e6a1f34713 fix: module and cycle sidebar loading state (#2831) 2023-11-22 15:34:39 +05:30
sabith-tu 1dff6b63f8 style: new empty project screen (#2832) 2023-11-22 15:34:06 +05:30
Anmol Singh Bhatia 59dbbb29cd fix: custom analytics project dropdown fix (#2828) 2023-11-22 14:55:18 +05:30
Anmol Singh Bhatia 6cb3939835 style: project card improvement (#2827) 2023-11-22 14:54:52 +05:30
Anmol Singh Bhatia 021c0675b7 fix: module sidebar link section (#2830) 2023-11-22 14:36:29 +05:30
Anmol Singh Bhatia 67000892e5 chore: dashboard redirection fix (#2826) 2023-11-22 13:47:59 +05:30
sriram veeraghanta 3df4794e77 fix: AI Assistance hide/unhide depending on the configuration (#2825)
* fix: gpt error handlijng

* fix: enabling ai assistance only when it is configured.
2023-11-22 13:20:59 +05:30
Prateek Shourya 42ccd1de58 Style: UI improvements (#2824)
* style: update notification Read status toast alert description.

* style: update issue subscribe button design.

* fix: remove group_by `none` display filter from the kanban view in profile and draft issues.

* style: design improvement in members settings.
* style: add display name for all user role.
* style: remove email for user roles other than admin.
* style: fix border color as per designs.
2023-11-22 12:58:55 +05:30
Aaryan Khandelwal c8c89007c0 style: revamped page details UI (#2823)
* style: revamp page details UI

* chore: updated the info popover date format

* fix: page actions mutation

* style: made the page content responsive
2023-11-22 12:32:49 +05:30
Bavisetti Narayan 4cf3e69e22 chore: file asset update (#2816)
* chore: endpoint to update file asset

* chore: aws storage endpoint change
2023-11-21 17:52:19 +05:30
Lakhan Baheti fb1f65c2c1 fix: sidebar project section hover (#2818)
* fix: sidebar project section hover

* fix: icons alignment
2023-11-21 17:37:17 +05:30
Lakhan Baheti d91b4e6fa1 fix: bug fixes & UI improvements (#2819)
* fix: profile setting fields border

* fix: webhooks empty state UI

* fix: cycle delete redirection from cycle detail

* fix: integration access restriction
2023-11-21 17:35:29 +05:30
Aaryan Khandelwal 561223ea71 chore: update join project endpoint (#2821) 2023-11-21 17:35:15 +05:30
Aaryan Khandelwal 982eba0bd1 fix: complete pages editor not clickable, recent pages calculation logic (#2820)
* fix: whole editor not clickable

* fix: recent pages calculation

* chore: update older pages calculation logic in recent pages list

* fix: archived pages computed function

* chore: add type for older pages
2023-11-21 15:47:34 +05:30
Aaryan Khandelwal 7aaf840fb1 refactor: command k modal (#2803)
* refactor: command palette file structure

* fix: identifier search
2023-11-21 15:46:41 +05:30
Nikhil 15927c9cae dev: change url for the license engine instance registration (#2810) 2023-11-20 21:32:45 +05:30
Bavisetti Narayan d46d70fcd5 chore: removed DOCKERIZED value and changed REDIS_SSL (#2813)
* chore: removed DOCKERIZED value

* chore: changed redis ssl
2023-11-20 21:32:00 +05:30
Henit Chobisa de581102e3 feat: New Pages with Enhanced Document Editor Packages made over Editor Core 📝 (#2784)
* fix: page transaction model

* fix: page transaction model

* feat: updated ui for page route

* chore: initailized `document-editor` package for plane

* fix: format persistence while pasting markdown in editor

* feat: Inititalized Document-Editor and Editor with Ref

* feat: added tooltip component and slash command for editor

* feat: added `document-editor` extensions

* feat: added custom search component for embedding labels

* feat: added top bar menu component

* feat: created document-editor exposed components

* feat: integrated `document-editor` in `pages` route

* chore: updated dependencies

* feat: merge conflict resolution

* chore: modified configuration for document editor

* feat: added content browser menu for document editor summary

* feat: added fixed menu and editor instances

* feat: added document edittor instances and summary table

* feat: implemented document-editor in PageDetail

* chore: css and export fixes

* fix: migration and optimisation

* fix: added `on_create` hook in the core editor

* feat: added conditional menu bar action in document-editor

* feat: added menu actions from single page view

* feat: added services for archiving, unarchiving and retriving archived pages

* feat: added services for page archives

* feat: implemented page archives in page list view

* feat: implemented page archives in document-editor

* feat: added editor marking hook

* chore: seperated editor header from the main content

* chore: seperated editor summary utilities from the main editor

* chore: refactored necessary components from the document editor

* chore: removed summary sidebar component from the main content editor

* chore: removed scrollSummaryDependency from Header and Sidebar

* feat: seperated page renderer as a seperate component

* chore: seperated page_renderer and sidebar as component from index

* feat: added locked property to IPage type

* feat: added lock/unlock services in page service

* chore: seperated DocumentDetails as exported interface from index

* feat: seperated document editor configs as seperate interfaces

* chore: seperated menu options from the editor header component

* fix: fixed page_lock performing lock/unlock operation on queryset instead of single instance

* fix: css positioning changes

* feat: added archive/lock alert labels

* feat: added boolean props in menu-actions/options

* feat: added lock/unlock & archive/unarchive services

* feat: added on update mutations for archived pages in page-view

* feat: added archive/lock on_update mutations in single page vieq

* feat: exported readonly editor for locked pages

* chore: seperated kanban menu props and saved over passing redundant data

* fix: readonly editor not generating markings on first render

* fix: cheveron overflowing from editor-header

* chore: removed unused utility actions

* fix: enabled sidebar view by default

* feat: removed locking on pages in archived state

* feat: added indentation in heading component

* fix: button classnames in vertical dropdowns

* feat: added `last_archived_at` and `last_edited_at` details in editor-header

* feat: changed types for archived updates and document last updates

* feat: updated editor and header props

* feat: updated queryset according to new page query format

* feat: added parameters in page view for shared / private pages

* feat: updated other-page-view to shared page view && same with private pages

* feat: added page-view as shared / private

* fix: replaced deleting to archiving for pages

* feat: handle restoring of page from archived section from list view

* feat: made previledge based option render for pages

* feat: removed layout view for page list view

* feat: linting changes

* fix: adding mobx changes to pages

* fix: removed uneccessary migrations

* fix: mobx store changes

* fix: adding date-fns pacakge

* fix: updating yarn lock

* fix: removing unneccessary method params

* chore: added access specifier to the create/update page modal

* fix: tab view layout changes

* chore: delete endpoint for page

* fix: page actions, including- archive, favorite, access control, delete

* chore: remove archive page modal

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-20 21:31:12 +05:30
Prateek Shourya b903126e5a feat: Instance Admin Panel: Configuration Settings (#2800)
* feat: Instance Admin Panel: Configuration Settings

* refactor: seprate Google and Github form into independent components.

* feat: add admin auth wrapper and access denied page.

* style: design updates.
2023-11-20 20:46:49 +05:30
sabith-tu f44f70168f style: changing profile screen title (#2814) 2023-11-20 20:46:15 +05:30
sriram veeraghanta 3c10f00b04 fix: minor fix (#2815) 2023-11-20 20:24:35 +05:30
Lakhan Baheti f1de05e4de chore: onboarding (#2790)
* style: onboarding light version

* style: dark mode

* fix: onboarding gradient

* refactor: imports

* chore: add use case field in users api

* feat: delete account

* fix: delete modal points alignment

* feat: usecase in profile

* fix: build error

* fix: typos & hardcoded strings

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-11-20 19:31:19 +05:30
Prashant Indurkar 61d4e2e016 Fixed: while creating new Add Labels the field should be auto focus #2437 (#2438)
* bug:fix recent page hiding last item on scroll #1468

* bug:fix recent page hiding last item on scroll #1468 (#2411)

* fixed add label autofocuse

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-20 19:00:55 +05:30
Lakhan Baheti c1eb5055e5 fix: bug fixes & ui improvements. (#2772)
* fix: create project modal member select

* fix: overflow in workspace activity

* fix: memeber selected state
2023-11-20 16:36:50 +05:30
Bavisetti Narayan 8d942e28da chore: ams url name changed (#2808) 2023-11-20 16:34:57 +05:30
Nikhil f7461af3f5 dev: open ai configuration (#2807) 2023-11-20 16:03:31 +05:30
Aaryan Khandelwal 29f3e02adc refactor: project estimates store (#2801)
* refactor: remove estimates from project store

* chore: update all the instances of the old store

* chore: update store declaration structure
2023-11-20 15:58:40 +05:30
Nikhil 9a704458b3 dev: external apis (#2806)
* dev: new proxy api setup

* dev: updated endpoints with serializers and structure

* dev: external apis for cycles, modules and inbox
issue

* dev: order by for all the apis

* dev: enable webhooks for external apis

* dev: fields and expand for the apis

* dev: move authentication to proxy middleware

* dev: fix imports

* dev: api serializer updates and paginator

* dev: renamed api to app

* dev: renamed proxy to api

* dev: validation for project, issues, modules and cycles

* dev: remove favourites from project apis

* dev: states api

* dev: rewrite the url endpoints

* dev: exception handling for the apis

* dev: merge updated structure

* dev: remove attachment apis

* dev: issue activities endpoints
2023-11-20 15:58:17 +05:30
Aaryan Khandelwal 668dfd2e38 chore: update exception detected screen action button (#2805) 2023-11-20 15:00:36 +05:30
Bavisetti Narayan 3b3f94ed03 fix: file asset delete (#2804) 2023-11-20 14:53:06 +05:30
onFire(Abhi) e945aa9b71 fix: newly added cycle doesnt appear unlelss the page is manually reloaded (#2673)
* fix: newly added cycle doesnt appear unlelss the page is manually reloaded

* Delete \

* Delete web/layouts/profile-layout/profile-sidebar.tsx

* Update cycles.store.ts

* fix: remove duplicate type declaration

---------

Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
2023-11-20 14:36:46 +05:30
sriram veeraghanta 6595a387d0 feat: event tracking using posthog and created application provider to render multiple wrappers (#2757)
* fix: event tracker changes

* fix: App provider implementation using wrappers

* fix: updating packages

* fix: handling warning

* fix: wrapper fixes and minor optimization changes

* fix: chore app-provider clearnup

* fix: cleanup

* fix: removing jitsu tracking

* fix: minor updates

* fix: adding event to posthog event tracker (#2802)

* dev: posthog event tracker update intitiate

* fix: adding events for posthog integration

* fix: event payload

---------

Co-authored-by: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com>
2023-11-20 13:29:54 +05:30
Dakshesh Jain 8839e42dc0 fix: archive issue bugs (#2712)
* fix: blur on side/modal peek view

* fix: delete archive not working on list layout with group by is none

* fix: show empty group has no effect

* fix: filter/display options same as production

* fix: disabling full-screen peek-overview for archive issues

* fix: truncate in calendar view
2023-11-20 12:48:30 +05:30
Nikhil 9db6312081 fix: self hosted instance (#2795)
* dev: update create bucket script

* dev: update patch endpoint for instance configuration

* dev: add google client secret and default values for ADMIN_EMAIL and LICENSE_ENGINE_BASE_URL
2023-11-20 12:36:48 +05:30
Dakshesh Jain 779ef2a4aa fix: delete issues in spreadsheet doesn't work (#2718)
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
2023-11-20 12:22:43 +05:30
Bavisetti Narayan 51e17643a2 fix: file structuring (#2797)
* fix: file structure changes

* fix: pages update

* fix: license imports changed
2023-11-20 11:59:20 +05:30
Nikhil 4c2074b6ff dev: environment settings (#2794)
* dev: update environment configuration

* dev: update the takeoff script for instance registration
2023-11-19 01:48:05 +05:30
Lakhan Baheti c9ffc9465f fix: Labels delete & reordering (#2729)
* fix: Labels reordering inconsistency

* fix: Delete child labels

* feat: multi-select while grouping labels

* refactor: label sorting in mobx computed function

* feat: drag & drop label grouping, un-grouping

* chore: removed label select modal

* fix: moving labels from project store to project label store

* fix: typo changes and build tree function added

* labels feature

* disable dropping group into a group

* fix build errors

* fix more issues

* chore: added combining state UI, fixed scroll issue for label groups

* chore: group icon for label groups

* fix: group cannot be dropped in another group

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-19 01:46:11 +05:30
Bavisetti Narayan 2b6c489513 feat: v3 endpoint for module and cycle (#2786)
* feat: v3 endpoint for module and cycle

* fix: removed the str
2023-11-18 16:30:35 +05:30
M. Palanikannan 0c63f21718 fix: Task List Behaviour in Editor (#2789)
* better variable names and comments

* drag drop migrated

* custom horizontal rule created

* init transaction hijack

* fixed code block with better contrast, keyboard tripple enter press disabled and syntax highlighting

* fixed link selector closing on open behaviour

* added better keymaps and syntax highlights

* made drag and drop working for code blocks

* fixed drag drop for code blocks

* moved drag drop only to rich text editor

* fixed drag and drop only for description

* enabled drag handles for peek overview and main issues

* got images to old state

* fixed task lists to be smaller

* removed validate image functions and uncessary imports

* table icons svg attributes fixed

* custom list keymap extension added

* more uncessary imports of validate image removed

* removed console logs

* fixed drag-handle styles

* space styles updated for the editor

* removed showing quotes from blockquotes

* removed validateImage for now

* added better comments and improved redundant renders

* removed uncessary console logs

* created util for creating the drag handle element

* fixed file names
2023-11-18 16:20:35 +05:30
Nikhil a987df38f4 chore: user onboarding workflow (#2791) 2023-11-18 16:18:06 +05:30
sriram veeraghanta 878707f444 feat: Instance Registration and Configuration (#2793)
* dev: remove default user

* dev: initiate licensing

* dev: remove migration file 0046

* feat: self hosted licensing initialize

* dev: instance licenses

* dev: change license response structure

* dev: add default properties and issue mention migration

* dev: reset migrations

* dev: instance configuration

* dev: instance configuration migration

* dev: update instance configuration model to take null and empty values

* dev: instance configuration variables

* dev: set default values

* dev: update instance configuration load

* dev: email configuration settings moved to database

* dev: instance configuration on instance bootup

* dev: auto instance registration script

* dev: instance admin

* dev: enable instance configuration and instance admin roles

* dev: instance owner fix

* dev: instance configuration values

* dev: fix instance permissions and serializer

* dev: fix email senders

* dev: remove deprecated variables

* dev: fix current site domain registration

* dev: update cors setup and local settings

* dev: migrate instance registration and configuration to manage commands

* dev: check email validity

* dev: update script to use manage command

* dev: default bucket creation script

* dev: instance admin routes and initial set of screens

* dev: admin api to check if the current user is admin

* dev: instance admin unique constraints

* dev: check magic link login

* dev: fix email sending for ssl

* dev: create instance activation route if the instance is not activated during startup

* dev: removed DJANGO_SETTINGS_MODULE from environment files and deleted auto bucket create script

* dev: environment configuration for backend

* dev: fix access token variable error

* feat: Instance Admin Panel: General Settings (#2792)

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2023-11-18 16:17:01 +05:30
M. Palanikannan 9369ee5008 [feat]: Drag and Drop Handles for all Data Structures (#2745)
* better variable names and comments

* drag drop migrated

* custom horizontal rule created

* init transaction hijack

* fixed code block with better contrast, keyboard tripple enter press disabled and syntax highlighting

* fixed link selector closing on open behaviour

* added better keymaps and syntax highlights

* made drag and drop working for code blocks

* fixed drag drop for code blocks

* moved drag drop only to rich text editor

* fixed drag and drop only for description

* enabled drag handles for peek overview and main issues

* got images to old state
2023-11-17 12:29:30 +05:30
Manish Gupta 0a88db975a dev: Self Hosting with private repo fixes (#2787)
* fixes to self hosting

* self hosting fixes

* removed .temp

* wip

* wip

* self install private repo

* folder change

* fix

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-17 11:51:54 +05:30
Manish Gupta dd60dec887 Dev/mg selfhosting fix (#2782)
* fixes to self hosting

* self hosting fixes

* removed .temp

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-16 14:38:55 +05:30
Bavisetti Narayan 0c1097592e fix: pages revamping (#2760)
* fix: page transaction model

* fix: page transaction model

* fix: migration and optimisation

* fix: back migration of page blocks

* fix: added issue embed

* fix: migration fixes

* fix: resolved changes
2023-11-16 14:38:12 +05:30
Anmol Singh Bhatia bed66235f2 style: workspace sidebar dropdown improvement (#2783) 2023-11-16 14:11:33 +05:30
Nikhil 26b1e9d5f1 dev: squashed migrations (#2779)
* dev: migration squash

* dev: migrations squashed for apis and webhooks

* dev: packages updated and  move dj-database-url for local settings

* dev: update package changes
2023-11-15 17:15:02 +05:30
Bavisetti Narayan 79347ec62b feat: api webhooks (#2543)
* dev: initiate external apis

* dev: external api

* dev: external public api implementation

* dev: add prefix to all api tokens

* dev: flag to enable disable api token api access

* dev: webhook model create and apis

* dev: webhook settings

* fix: webhook logs

* chore: removed drf spectacular

* dev: remove retry_count and fix api logging for get requests

* dev: refactor webhook logic

* fix: celery retry mechanism

* chore: event and action change

* chore: migrations changes

* dev: proxy setup for apis

* chore: changed retry time and cleanup

* chore: added issue comment and inbox issue api endpoints

* fix: migration files

* fix: added env variables

* fix: removed issue attachment from proxy

* fix: added new migration file

* fix: restricted wehbook access

* chore: changed urls

* chore: fixed porject serializer

* fix: set expire for api token

* fix: retrive endpoint for api token

* feat: Api Token screens & api integration

* dev: webhook endpoint changes

* dev: add fields for webhook updates

* feat: Download Api secret key

* chore: removed BASE API URL

* feat: revoke token access

* dev: migration fixes

* feat: workspace webhooks (#2748)

* feat: workspace webhook store, services integeration and rendered webhook list and create

* chore: handled webhook update and rengenerate token in workspace webhooks

* feat: regenerate key and delete functionality

---------

Co-authored-by: Ramesh Kumar <rameshkumar@rameshs-MacBook-Pro.local>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>

* fix: url validation added

* fix: seperated env for webhook and api

* Web hooks refactoring

* add show option for generated hook key

* Api token restructure

* webhook minor fixes

* fix build errors

* chore: improvements in file structring

* dev: rate limiting the open apis

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: rahulramesha <71900764+rahulramesha@users.noreply.github.com>
Co-authored-by: Ramesh Kumar <rameshkumar@rameshs-MacBook-Pro.local>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2023-11-15 15:56:57 +05:30
Nikhil 7b965179d8 dev: update bucket script to make the bucket public (#2767)
* dev: update bucket script to make the bucket public

* dev: remove auto bucket script from docker compose
2023-11-15 15:56:08 +05:30
Nikhil fc51ffc589 chore: user workflow (#2762)
* dev: workspace member deactivation and leave endpoints and filters

* dev: deactivated for project members

* dev: project members leave

* dev: project member check on workspace deactivation

* dev: project member queryset update and remove leave project endpoint

* dev: rename is_deactivated to is_active and user deactivation apis

* dev: check if the user is already part of workspace then make them active

* dev: workspace and project save

* dev: update project members to make them active

* dev: project invitation

* dev: automatic user workspace and project member create when user sign in/up

* dev: fix member invites

* dev: rename deactivation variable

* dev: update project member invitation

* dev: additional permission layer for workspace

* dev: update the url for  workspace invitations

* dev: remove invitation urls from users

* dev: cleanup workspace invitation workflow

* dev: workspace and project invitation
2023-11-15 15:53:16 +05:30
sabith-tu 96f6e37cc5 fix: Delete estimate popup is not closing automatically (#2777) 2023-11-15 14:08:52 +05:30
Nikhil 29774ce84a dev: API settings (#2594)
* dev: update settings file structure and added extra settings for CORS

* dev: remove WEB_URL variable and add celery integration for sentry

* dev: aws and minio settings

* dev: add cors origins to env

* dev: update settings
2023-11-15 12:31:52 +05:30
Nikhil 8cbe9c26fc enhancement: label sort order (#2763)
* chore: label sort ordering

* dev: ordering

* fix: sort order

* fix: save of labels

* dev: remove ordering by name

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-11-15 12:25:44 +05:30
Prateek Shourya 7f42566207 Fix: Custom menu item not automatically closing, affecting delete popup behavior. (#2771) 2023-11-14 23:05:30 +05:30
Ankush Deshmukh b60237b676 Standarding priority icons across the platform (#2776) 2023-11-14 20:52:43 +05:30
Prateek Shourya 1fe09d369f style: text overflow fix and border color update (#2769)
* style: fix text overflow in:
* Issue activity
* Cycle and Module Select in Create Issue form
* Delete Module modal
* Join Project modal

* style: update assignee select border as per design.
2023-11-14 18:34:51 +05:30
Dakshesh Jain b7757c6b1a fix: bugs (#2761)
* fix: semicolon on estimate settings page

* refactor: project settings automations store implementation

* fix: active cycle stuck on infinite loading

* fix: removed delete project option from sidebar

* fix: discloser not opening when navigating to project

* fix: clear filter not working & filter appearing even if nothing is selected

* refactor: select label store implementation

* refactor: select state store implementation
2023-11-14 18:33:01 +05:30
Anmol Singh Bhatia 1a25bacce1 style: create update view modal consistency (#2775) 2023-11-14 18:30:10 +05:30
Anmol Singh Bhatia 6797df239d chore: no lead option added in lead select dropdown (#2774) 2023-11-14 18:29:39 +05:30
Anmol Singh Bhatia 43e7c10eb7 chore: spreadsheet layout column responsiveness (#2768) 2023-11-14 18:28:49 +05:30
Anmol Singh Bhatia bdc9c9c2a8 chore: create update issue modal improvement (#2765) 2023-11-14 18:28:15 +05:30
Anmol Singh Bhatia f0c72bf249 fix: breadcrumb project icon improvement (#2764) 2023-11-14 18:27:47 +05:30
sabith-tu a8904bfc48 style: ui fixes for pages and views (#2770) 2023-11-14 18:26:50 +05:30
Nikhil b31041726b dev: create bucket through application (#2720) 2023-11-13 15:57:19 +05:30
Prateek Shourya e6f947ad90 style: ui improvements and bug fixes (#2758)
* style: add transition to favorite projects dropdown.

* style: update project integration settings borders.

* style: fix text overflow issue in project views.

* fix: issue with non-functional cancel button in leave project modal.
2023-11-13 14:42:45 +05:30
Dakshesh Jain 7963993171 fix: workspace settings bugs (#2743)
* fix: double layout in exports

* fix: typo in jira email address section

* fix: workspace members not mutating

* fix: removed un-used variable

* fix: workspace members can't be filtered using email

* fix: autocomplete in workspace delete

* fix: autocomplete in project delete modal

* fix: update member function in store

* fix: sidebar link not active when in github/jira

* style: margin top & icon inconsistency

* fix: typo in create workspace

* fix: workspace leave flow

* fix: redirection to delete issue

* fix: autocomplete off in jira api token

* refactor: reduced api call, added optional chaining & removed variable with low scope
2023-11-13 13:34:05 +05:30
1368 changed files with 28062 additions and 50532 deletions
-6
View File
@@ -1,11 +1,5 @@
version = 1
exclude_patterns = [
"bin/**",
"**/node_modules/",
"**/*.min.js"
]
[[analyzers]]
name = "shell"
+2 -13
View File
@@ -2,16 +2,5 @@
*.pyc
.env
venv
node_modules/
**/node_modules/
npm-debug.log
.next/
**/.next/
.turbo/
**/.turbo/
build/
**/build/
out/
**/out/
dist/
**/dist/
node_modules
npm-debug.log
+50 -66
View File
@@ -1,31 +1,62 @@
name: Branch Build
on:
pull_request:
types:
types:
- closed
branches:
branches:
- master
- preview
- release
- qa
- develop
- release-*
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
jobs:
branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
branch_build_and_push:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
string: ${{env.TARGET_BRANCH}}
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: '/'
replace: '-'
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: '.'
replace: ''
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: '_'
replace: ''
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
@@ -46,6 +77,7 @@ jobs:
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
@@ -57,24 +89,12 @@ jobs:
!./deploy
!./web
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [ branch_build_and_push ]
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -94,7 +114,7 @@ jobs:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ env.FRONTEND_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
@@ -103,20 +123,8 @@ jobs:
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [ branch_build_and_push ]
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -136,7 +144,7 @@ jobs:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
tags: ${{ env.SPACE_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
@@ -145,20 +153,8 @@ jobs:
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [ branch_build_and_push ]
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -179,7 +175,7 @@ jobs:
file: ./Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ env.BACKEND_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -187,20 +183,8 @@ jobs:
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
needs: [ branch_build_and_push ]
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -221,7 +205,7 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64
tags: ${{ env.PROXY_TAG }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
-65
View File
@@ -1,65 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ 'develop', 'hot-fix', 'stage-release' ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'develop' ]
schedule:
- cron: '53 19 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'python', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
+42 -9
View File
@@ -1,13 +1,11 @@
name: Create Sync Action
name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
branches:
- preview
- master
types:
- closed
env:
SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}}
jobs:
create_pr:
@@ -18,13 +16,27 @@ jobs:
pull-requests: write
contents: read
steps:
- name: Check SOURCE_REPO
id: check_repo
env:
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
run: |
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
- name: Checkout Code
if: steps.check_repo.outputs.is_correct_repo == 'true'
uses: actions/checkout@v2
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Branch Name
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
- name: Setup GH CLI
if: steps.check_repo.outputs.is_correct_repo == 'true'
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
@@ -33,14 +45,35 @@ jobs:
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo
- name: Create Pull Request
if: steps.check_repo.outputs.is_correct_repo == 'true'
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}"
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}"
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
PR_TITLE="${{ github.event.pull_request.title }}"
PR_BODY="${{ github.event.pull_request.body }}"
# Remove double quotes
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
PR_BODY_CLEANED="${PR_BODY//\"/}"
# Construct PR_BODY_CONTENT using a here-document
PR_BODY_CONTENT=$(cat <<EOF
$PR_BODY_CLEANED
EOF
)
gh pr create \
--base $TARGET_BRANCH \
--head $SOURCE_BRANCH \
--title "[SYNC] $PR_TITLE_CLEANED" \
--body "$PR_BODY_CONTENT" \
--repo $TARGET_REPO
+107
View File
@@ -0,0 +1,107 @@
name: Update Docker Images for Plane on Release
on:
release:
types: [released, prereleased]
jobs:
build_push_backend:
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaBackend
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaSpace
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
tags: |
type=ref,event=tag
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaProxy
uses: docker/metadata-action@v4.3.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy
tags: |
type=ref,event=tag
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ steps.metaFrontend.outputs.tags }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ steps.metaBackend.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Deploy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
push: true
tags: ${{ steps.metaSpace.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.metaProxy.outputs.tags }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
-3
View File
@@ -80,6 +80,3 @@ tmp/
## packages
dist
.temp/
# logs
combined.log
+25 -4
View File
@@ -33,8 +33,8 @@ The backend is a django project which is kept inside apiserver
1. Clone the repo
```bash
git clone https://github.com/makeplane/plane.git [folder-name]
cd [folder-name]
git clone https://github.com/makeplane/plane
cd plane
chmod +x setup.sh
```
@@ -44,12 +44,33 @@ chmod +x setup.sh
./setup.sh
```
3. Start the containers
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
```bash
docker compose -f docker-compose-local.yml up
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
```
```bash
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
```
4. Run Docker compose up
```bash
docker compose up -d
```
5. Install dependencies
```bash
yarn install
```
6. Run the web app in development mode
```bash
yarn dev
```
## Missing a Feature?
+1
View File
@@ -79,6 +79,7 @@ COPY apiserver/manage.py manage.py
COPY apiserver/plane plane/
COPY apiserver/templates templates/
COPY apiserver/gunicorn.config.py ./
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
+15
View File
@@ -49,10 +49,24 @@ NGINX_PORT=80
```
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/spaces/.env.example
```
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0
```
## {PROJECT_FOLDER}/apiserver/.env
@@ -62,6 +76,7 @@ NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # deprecated
# Error logs
SENTRY_DSN=""
+6 -2
View File
@@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose).
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
## ⚡️ Contributors Quick Start
@@ -57,13 +57,17 @@ Setting up local environment is extremely easy and straight forward. Follow the
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
```bash
./setup.sh
```
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
Thats it!
## 🍙 Self Hosting
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting/docker-compose) documentation page
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting) documentation page
## 🚀 Features
+1 -5
View File
@@ -14,11 +14,6 @@ PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
@@ -55,6 +50,7 @@ NGINX_PORT=80
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
+1
View File
@@ -44,6 +44,7 @@ COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
COPY gunicorn.config.py ./
USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
+2 -3
View File
@@ -27,8 +27,7 @@ WORKDIR /code
COPY requirements.txt ./requirements.txt
ADD requirements ./requirements
# Install the local development settings
RUN pip install -r requirements/local.txt --compile --no-cache-dir
RUN pip install -r requirements.txt --compile --no-cache-dir
RUN addgroup -S plane && \
adduser -S captain -G plane
@@ -49,5 +48,5 @@ USER captain
# Expose container port and run entry point script
EXPOSE 8000
CMD [ "./bin/takeoff.local" ]
# CMD [ "./bin/takeoff" ]
+1 -1
View File
@@ -1,3 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -l info -Q internal_tasks,external_tasks
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO
+10 -20
View File
@@ -3,28 +3,18 @@ set -e
python manage.py wait_for_db
python manage.py migrate
# Create the default bucket
#!/bin/bash
# Set default value for ENABLE_REGISTRATION
ENABLE_REGISTRATION=${ENABLE_REGISTRATION:-1}
# Collect system information
HOSTNAME=$(hostname)
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
CPU_INFO=$(cat /proc/cpuinfo)
MEMORY_INFO=$(free -h)
DISK_INFO=$(df -h)
# Concatenate information and compute SHA-256 hash
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
# Export the variables
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
# Load the configuration variable
python manage.py configure_instance
# Check if ENABLE_REGISTRATION is not set to '0'
if [ "$ENABLE_REGISTRATION" != "0" ]; then
# Register instance
python manage.py register_instance
# Load the configuration variable
python manage.py configure_instance
fi
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Create the default bucket
#!/bin/bash
# Collect system information
HOSTNAME=$(hostname)
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
CPU_INFO=$(cat /proc/cpuinfo)
MEMORY_INFO=$(free -h)
DISK_INFO=$(df -h)
# Concatenate information and compute SHA-256 hash
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
# Export the variables
export MACHINE_SIGNATURE=$SIGNATURE
# Register instance
python manage.py register_instance $MACHINE_SIGNATURE
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
View File
+6
View File
@@ -0,0 +1,6 @@
from psycogreen.gevent import patch_psycopg
def post_fork(server, worker):
patch_psycopg()
worker.log.info("Made Psycopg2 Green")
+3 -3
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.14.0"
}
"name": "plane-api",
"version": "0.13.2"
}
+6 -2
View File
@@ -1,8 +1,9 @@
from django.utils import timezone
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
rate = '60/minute'
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
@@ -14,10 +15,13 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
return f'{self.scope}:{api_key}'
def allow_request(self, request, view):
# Calculate the current time as a Unix timestamp
now = timezone.now().timestamp()
# Use the parent class's method to check if the request is allowed
allowed = super().allow_request(request, view)
if allowed:
now = self.timer()
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
+2 -3
View File
@@ -9,9 +9,8 @@ from .issue import (
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer
from .module import ModuleSerializer, ModuleIssueSerializer
from .inbox import InboxIssueSerializer
+1 -13
View File
@@ -30,11 +30,6 @@ class CycleSerializer(BaseSerializer):
model = Cycle
fields = "__all__"
read_only_fields = [
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"workspace",
"project",
"owned_by",
@@ -51,11 +46,4 @@ class CycleIssueSerializer(BaseSerializer):
"workspace",
"project",
"cycle",
]
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
]
+1 -7
View File
@@ -8,12 +8,6 @@ class InboxIssueSerializer(BaseSerializer):
model = InboxIssue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
]
+26 -100
View File
@@ -1,6 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
@@ -22,10 +19,7 @@ from plane.db.models import (
ProjectMember,
)
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@@ -46,8 +40,8 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -55,10 +49,6 @@ class IssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"description",
"description_stripped",
]
def validate(self, data):
if (
@@ -67,21 +57,12 @@ class IssueSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
try:
if(data.get("description_html", None) is not None):
parsed = html.fromstring(data["description_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["description_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
# Validate assignees are from project
if data.get("assignees", []):
print(data.get("assignees"))
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@@ -107,7 +88,7 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspace_id=self.context.get("workspace_id"), pk=data.get("parent")
workspce_id=self.context.get("workspace_id"), pk=data.get("parent")
).exists()
):
raise serializers.ValidationError(
@@ -250,13 +231,8 @@ class LabelSerializer(BaseSerializer):
model = Label
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
@@ -265,14 +241,13 @@ class IssueLinkSerializer(BaseSerializer):
model = IssueLink
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"issue",
]
# Validation if url already exists
@@ -291,14 +266,13 @@ class IssueAttachmentSerializer(BaseSerializer):
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
@@ -307,8 +281,8 @@ class IssueCommentSerializer(BaseSerializer):
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
@@ -317,77 +291,29 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"comment_stripped",
"comment_json",
]
def validate(self, data):
try:
if(data.get("comment_html", None) is not None):
parsed = html.fromstring(data["comment_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["comment_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
return data
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
class IssueActivitySerializer(BaseSerializer):
class Meta:
model = IssueActivity
fields = "__all__"
exclude = [
"created_by",
"updated_by",
]
class CycleIssueSerializer(BaseSerializer):
cycle = CycleSerializer(read_only=True)
class Meta:
fields = [
"cycle",
]
class ModuleIssueSerializer(BaseSerializer):
module = ModuleSerializer(read_only=True)
class Meta:
fields = [
"module",
]
class LabelLiteSerializer(BaseSerializer):
class Meta:
model = Label
fields = [
"id",
"name",
"color",
]
class IssueExpandSerializer(BaseSerializer):
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
labels = LabelLiteSerializer(read_only=True, many=True)
assignees = UserLiteSerializer(read_only=True, many=True)
state = StateLiteSerializer(read_only=True)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"udpated_by",
]
+3 -9
View File
@@ -21,6 +21,7 @@ class ModuleSerializer(BaseSerializer):
write_only=True,
required=False,
)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
@@ -32,7 +33,6 @@ class ModuleSerializer(BaseSerializer):
model = Module
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -55,6 +55,7 @@ class ModuleSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
if data.get("members", []):
print(data.get("members"))
data["members"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
member_id__in=data["members"],
@@ -151,11 +152,4 @@ class ModuleLinkSerializer(BaseSerializer):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
class ModuleLiteSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"
return ModuleLink.objects.create(**validated_data)
+1 -6
View File
@@ -20,13 +20,8 @@ class ProjectSerializer(BaseSerializer):
model = Project
fields = "__all__"
read_only_fields = [
"id",
'emoji',
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
"id",
]
def validate(self, data):
-5
View File
@@ -16,11 +16,6 @@ class StateSerializer(BaseSerializer):
model = State
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
]
+5 -1
View File
@@ -11,6 +11,10 @@ class UserLiteSerializer(BaseSerializer):
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
read_only_fields = fields
read_only_fields = [
"id",
"is_bot",
]
+2 -2
View File
@@ -13,7 +13,7 @@ urlpatterns = [
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
@@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
+2 -2
View File
@@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
+7 -7
View File
@@ -15,27 +15,27 @@ urlpatterns = [
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueAPIEndpoint.as_view(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
@@ -50,12 +50,12 @@ urlpatterns = [
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
+1 -1
View File
@@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
+1 -1
View File
@@ -9,7 +9,7 @@ urlpatterns = [
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/",
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectAPIEndpoint.as_view(),
name="project",
),
-5
View File
@@ -8,9 +8,4 @@ urlpatterns = [
StateAPIEndpoint.as_view(),
name="states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(),
name="states",
),
]
+8 -12
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.views import APIView
@@ -35,33 +36,28 @@ class TimezoneMixin:
else:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
@@ -143,13 +139,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
response = super().finalize_response(request, response, *args, **kwargs)
# Add custom headers if they exist in the request META
ratelimit_remaining = request.META.get("X-RateLimit-Remaining")
ratelimit_remaining = request.META.get('X-RateLimit-Remaining')
if ratelimit_remaining is not None:
response["X-RateLimit-Remaining"] = ratelimit_remaining
response['X-RateLimit-Remaining'] = ratelimit_remaining
ratelimit_reset = request.META.get("X-RateLimit-Reset")
ratelimit_reset = request.META.get('X-RateLimit-Reset')
if ratelimit_reset is not None:
response["X-RateLimit-Reset"] = ratelimit_reset
response['X-RateLimit-Reset'] = ratelimit_reset
return response
@@ -173,4 +169,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
return expand if expand else None
return expand if expand else None
+43 -46
View File
@@ -17,6 +17,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -141,6 +142,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
if cycle_view == "current":
@@ -281,22 +283,20 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(pk),
"cycle_name": str(cycle.name),
"issues": [str(issue_id) for issue_id in cycle_issues],
}),
'actor_id': str(request.user.id),
'issue_id': None,
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Delete the cycle
cycle.delete()
@@ -305,15 +305,14 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
webhook_event = "cycle"
permission_classes = [
ProjectEntityPermission,
]
@@ -456,21 +455,21 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
# Capture Issue Activity
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.created",
'requested_data': json.dumps({"cycles_list": str(issues)}),
'actor_id': str(self.request.user.id),
'issue_id': None,
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': json.dumps({
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize("json", record_to_create),
}),
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
"created_cycle_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
)
# Return all Cycle Issues
@@ -479,27 +478,25 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id, issue_id):
def delete(self, request, slug, project_id, cycle_id, pk):
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("cycle_id")),
"issues": [str(issue_id)],
}),
'actor_id': str(self.request.user.id),
'issue_id': str(issue_id),
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+44 -129
View File
@@ -14,7 +14,7 @@ from rest_framework.response import Response
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox
from plane.db.models import InboxIssue, Issue, State, ProjectMember
from plane.bgtasks.issue_activites_task import issue_activity
@@ -37,39 +37,29 @@ class InboxIssueAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
inbox = Inbox.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if inbox is None and not project.inbox_view:
return InboxIssue.objects.none()
return (
InboxIssue.objects.filter(
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=inbox.id,
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id, issue_id=None):
if issue_id:
inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id)
inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset,
def get(self, request, slug, project_id, inbox_id, pk=None):
if pk:
issue_queryset = self.get_queryset().get(pk=pk)
issues_data = InboxIssueSerializer(
issue_queryset,
fields=self.fields,
expand=self.expand,
).data
return Response(
inbox_issue_data,
issues_data,
status=status.HTTP_200_OK,
)
issue_queryset = self.get_queryset()
@@ -84,30 +74,12 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).data,
)
def post(self, request, slug, project_id):
def post(self, request, slug, project_id, inbox_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
@@ -142,58 +114,30 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Create an Issue Activity
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
"type": "issue.activity.created",
"requested_data": json.dumps(request.data, cls=DjangoJSONEncoder),
"actor_id": str(request.user.id),
"issue_id": str(issue.id),
"project_id": str(project_id),
"current_instance": None,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = InboxIssueSerializer(inbox_issue)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, issue_id):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
def patch(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -201,7 +145,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
member=request.user,
is_active=True,
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
@@ -216,7 +159,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if bool(issue_data):
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
@@ -236,21 +179,17 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.apply_async(
args=[],
kwargs={
"type": "issue.activity.updated",
"requested_data": requested_data,
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
)
issue_serializer.save()
else:
@@ -269,7 +208,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=issue_id,
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
@@ -283,7 +222,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=issue_id,
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
@@ -305,33 +244,10 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
)
def delete(self, request, slug, project_id, issue_id):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
def delete(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -340,7 +256,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
is_active=True,
)
# Check the inbox issue created
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
):
@@ -353,8 +268,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_204_NO_CONTENT)
+112 -155
View File
@@ -22,6 +22,7 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .base import BaseAPIView, WebhookMixin
@@ -40,12 +41,14 @@ from plane.db.models import (
IssueComment,
IssueActivity,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
)
@@ -100,6 +103,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
@@ -108,6 +112,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
@@ -207,18 +212,14 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer.save()
# Track the issue
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "issue.activity.created",
'requested_data': json.dumps(self.request.data, cls=DjangoJSONEncoder),
'actor_id': str(request.user.id),
'issue_id': str(serializer.data.get("id", None)),
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -232,18 +233,14 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = IssueSerializer(issue, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[],
kwargs={
'type': "issue.activity.updated",
'requested_data': requested_data,
'actor_id': str(request.user.id),
'issue_id': str(pk),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -254,19 +251,14 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.apply_async(
args=[],
kwargs={
'type': "issue.activity.deleted",
'requested_data': json.dumps({"issue_id": str(pk)}),
'actor_id': str(request.user.id),
'issue_id': str(pk),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='your_routing_key',
queue='your_queue_name'
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -286,7 +278,7 @@ class LabelAPIEndpoint(BaseAPIView):
def get_queryset(self):
return (
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
@@ -310,32 +302,29 @@ class LabelAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
if pk:
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
@@ -367,31 +356,25 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk is None:
issue_links = self.get_queryset()
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_links,
label,
fields=self.fields,
expand=self.expand,
)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_link,
fields=self.fields,
expand=self.expand,
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data)
@@ -400,18 +383,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
project_id=project_id,
issue_id=issue_id,
)
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.created",
'requested_data': json.dumps(serializer.data, cls=DjangoJSONEncoder),
'actor_id': str(self.request.user.id),
'issue_id': str(self.kwargs.get("issue_id")),
'project_id': str(self.kwargs.get("project_id")),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -428,18 +407,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.updated",
'requested_data': requested_data,
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -452,18 +427,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
IssueLinkSerializer(issue_link).data,
cls=DjangoJSONEncoder,
)
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.deleted",
'requested_data': json.dumps({"link_id": str(pk)}),
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.deleted",
requested_data=json.dumps({"link_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -478,7 +449,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
webhook_event = "issue-comment"
permission_classes = [
ProjectLitePermission,
]
@@ -535,20 +506,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
issue_id=issue_id,
actor=request.user,
)
issue_activity.apply_async(
args=[],
kwargs={
"type": "comment.activity.created",
"requested_data": json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("issue_id")),
"project_id": str(self.kwargs.get("project_id")),
"current_instance": None,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -567,18 +532,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[],
kwargs={
"type": "comment.activity.updated",
"requested_data": requested_data,
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": current_instance,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -592,18 +553,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
cls=DjangoJSONEncoder,
)
issue_comment.delete()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
"type": "comment.activity.deleted",
"requested_data": json.dumps({"comment_id": str(pk)}),
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": current_instance,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -624,13 +581,13 @@ class IssueActivityAPIEndpoint(BaseAPIView):
)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
if pk:
issue_activities = issue_activities.get(pk=pk)
serializer = IssueActivitySerializer(issue_activities)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
+39 -52
View File
@@ -129,14 +129,6 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
@@ -166,22 +158,20 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
)
issue_activity.apply_async(
args=[],
kwargs={
'type': "module.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}),
'actor_id': str(request.user.id),
'issue_id': None,
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -196,8 +186,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
webhook_event = "module"
permission_classes = [
ProjectEntityPermission,
@@ -332,21 +321,21 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
# Capture Issue Activity
issue_activity.apply_async(
args=[],
kwargs={
'type': "module.activity.created",
'requested_data': json.dumps({"modules_list": str(issues)}),
'actor_id': str(self.request.user.id),
'issue_id': None,
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': json.dumps({
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize("json", record_to_create),
}),
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
)
return Response(
@@ -354,25 +343,23 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id, issue_id):
def delete(self, request, slug, project_id, module_id, pk):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "module.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(module_id),
"issues": [str(module_issue.issue_id)],
}),
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+12 -15
View File
@@ -94,8 +94,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id=None):
if project_id is None:
def get(self, request, slug, pk=None):
if pk is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
@@ -114,7 +114,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
).select_related("member"),
)
)
.order_by(request.GET.get("order_by", "sort_order"))
.order_by("sort_order", "name")
)
return self.paginate(
request=request,
@@ -123,13 +123,15 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
projects, many=True, fields=self.fields, expand=self.expand,
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
@@ -234,10 +236,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, project_id=None):
def patch(self, request, slug, pk=None):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
project = Project.objects.get(pk=pk)
serializer = ProjectSerializer(
project,
@@ -258,7 +260,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
project_id=pk,
color="#ff7700",
)
@@ -280,9 +282,4 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
)
+13 -11
View File
@@ -23,8 +23,10 @@ class StateAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
@@ -40,9 +42,9 @@ class StateAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
def get(self, request, slug, project_id, pk=None):
if pk:
serializer = StateSerializer(self.get_queryset().get(pk=pk))
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@@ -55,19 +57,19 @@ class StateAPIEndpoint(BaseAPIView):
).data,
)
def delete(self, request, slug, project_id, state_id):
def delete(self, request, slug, project_id, pk):
state = State.objects.get(
~Q(name="Triage"),
pk=state_id,
pk=pk,
project_id=project_id,
workspace__slug=slug,
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(
@@ -78,8 +80,8 @@ class StateAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
def patch(self, request, slug, project_id, pk=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -99,6 +99,7 @@ class WorkspaceViewerPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__gte=10,
is_active=True,
).exists()
+45
View File
@@ -0,0 +1,45 @@
from django.utils import timezone
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get('X-Api-Key')
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f'{self.scope}:{api_key}'
def allow_request(self, request, view):
# Calculate the current time as a Unix timestamp
now = timezone.now().timestamp()
# Use the parent class's method to check if the request is allowed
allowed = super().allow_request(request, view)
if allowed:
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META['X-RateLimit-Remaining'] = max(0, available)
request.META['X-RateLimit-Reset'] = reset_time
return allowed
-1
View File
@@ -225,7 +225,6 @@ class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueActivity
+10 -13
View File
@@ -103,19 +103,16 @@ class ProjectListSerializer(DynamicBaseSerializer):
members = serializers.SerializerMethodField()
def get_members(self, obj):
project_members = getattr(obj, "members_list", None)
if project_members is not None:
# Filter members by the project ID
return [
{
"id": member.id,
"member_id": member.member_id,
"member__display_name": member.member.display_name,
"member__avatar": member.member.avatar,
}
for member in project_members
]
return []
project_members = ProjectMember.objects.filter(
project_id=obj.id,
is_active=True,
).values(
"id",
"member_id",
"member__display_name",
"member__avatar",
)
return list(project_members)
class Meta:
model = Project
+8 -34
View File
@@ -26,8 +26,6 @@ class UserSerializer(BaseSerializer):
"token_updated_at",
"is_onboarded",
"is_bot",
"is_password_autoset",
"is_email_verified",
]
extra_kwargs = {"password": {"write_only": True}}
@@ -62,8 +60,6 @@ class UserMeSerializer(BaseSerializer):
"theme",
"last_workspace_id",
"use_case",
"is_password_autoset",
"is_email_verified",
]
read_only_fields = fields
@@ -84,18 +80,9 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=obj.email
).count()
if (
obj.last_workspace_id is not None
and Workspace.objects.filter(
pk=obj.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
).exists()
):
if obj.last_workspace_id is not None:
workspace = Workspace.objects.filter(
pk=obj.last_workspace_id,
workspace_member__member=obj.id,
workspace_member__is_active=True,
pk=obj.last_workspace_id, workspace_member__member=obj.id
).first()
return {
"last_workspace_id": obj.last_workspace_id,
@@ -108,9 +95,7 @@ class UserMeSettingsSerializer(BaseSerializer):
}
else:
fallback_workspace = (
Workspace.objects.filter(
workspace_member__member_id=obj.id, workspace_member__is_active=True
)
Workspace.objects.filter(workspace_member__member_id=obj.id)
.order_by("created_at")
.first()
)
@@ -169,25 +154,14 @@ class ChangePasswordSerializer(serializers.Serializer):
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True, min_length=8)
confirm_password = serializers.CharField(required=True, min_length=8)
def validate(self, data):
if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError(
{"error": "New password cannot be same as old password."}
)
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError(
{"error": "Confirm password should be same as the new password."}
)
return data
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True, min_length=8)
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
@@ -1,9 +1,3 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
@@ -14,76 +8,6 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def create(self, validated_data):
url = validated_data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return Webhook.objects.create(**validated_data)
def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return super().update(instance, validated_data)
class Meta:
model = Webhook
@@ -21,23 +21,6 @@ class WorkSpaceSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
def validated(self, data):
if data.get("slug") in [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
"password",
]:
raise serializers.ValidationError({"slug": "Slug is not valid"})
class Meta:
model = Workspace
fields = "__all__"
@@ -95,16 +78,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
class Meta:
model = WorkspaceMemberInvite
fields = "__all__"
read_only_fields = [
"id",
"email",
"token",
"workspace",
"message",
"responded_at",
"created_at",
"updated_at",
]
class TeamSerializer(BaseSerializer):
-10
View File
@@ -4,7 +4,6 @@ from django.urls import path
from plane.app.views import (
FileAssetEndpoint,
UserAssetsEndpoint,
FileAssetViewSet,
)
@@ -29,13 +28,4 @@ urlpatterns = [
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view(
{
"post": "restore",
}
),
name="file-assets-restore",
),
]
+16 -5
View File
@@ -5,16 +5,18 @@ from rest_framework_simplejwt.views import TokenRefreshView
from plane.app.views import (
# Authentication
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignInGenerateEndpoint,
OauthEndpoint,
EmailCheckEndpoint,
## End Authentication
# Auth Extended
ForgotPasswordEndpoint,
VerifyEmailEndpoint,
ResetPasswordEndpoint,
RequestEmailVerificationEndpoint,
ChangePasswordEndpoint,
## End Auth Extender
# API Tokens
@@ -25,15 +27,24 @@ from plane.app.views import (
urlpatterns = [
# Social Auth
path("email-check/", EmailCheckEndpoint.as_view(), name="email"),
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
# Magic Sign In/Up
path(
"magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate"
),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Email verification
path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"),
path(
"request-email-verify/",
RequestEmailVerificationEndpoint.as_view(),
name="request-reset-email",
),
# Password Manipulation
path(
"users/me/change-password/",
+6
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
@@ -43,6 +44,11 @@ urlpatterns = [
),
name="project-issue-cycle",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueGroupedEndpoint.as_view(),
name="project-issue-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueViewSet.as_view(
+12
View File
@@ -3,6 +3,8 @@ from django.urls import path
from plane.app.views import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
BulkDeleteIssuesEndpoint,
@@ -35,6 +37,16 @@ urlpatterns = [
),
name="project-issue",
),
path(
"v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListGroupedEndpoint.as_view(),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
+6
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
ModuleLinkViewSet,
ModuleFavoriteViewSet,
BulkImportModulesEndpoint,
ModuleIssueGroupedEndpoint,
)
@@ -43,6 +44,11 @@ urlpatterns = [
),
name="project-module-issues",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
ModuleIssueGroupedEndpoint.as_view(),
name="project-issue-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
ModuleIssueViewSet.as_view(
+2 -8
View File
@@ -13,7 +13,6 @@ from plane.app.views import (
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
)
@@ -66,7 +65,7 @@ urlpatterns = [
name="project-member-invite",
),
path(
"users/me/workspaces/<str:slug>/projects/invitations/",
"users/me/invitations/projects/",
UserProjectInvitationsViewset.as_view(
{
"get": "list",
@@ -76,12 +75,7 @@ urlpatterns = [
name="user-project-invitations",
),
path(
"users/me/workspaces/<str:slug>/project-roles/",
UserProjectRolesEndpoint.as_view(),
name="user-project-roles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/join/<uuid:pk>/",
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
name="project-join",
),
+1 -7
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
UpdateUserTourCompletedEndpoint,
UserActivityEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
## End User
## Workspaces
UserWorkSpacesEndpoint,
@@ -64,7 +63,7 @@ urlpatterns = [
name="user-tour",
),
path(
"users/me/activities/",
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
@@ -90,10 +89,5 @@ urlpatterns = [
UserWorkspaceDashboardEndpoint.as_view(),
name="user-workspace-dashboard",
),
path(
"users/me/set-password/",
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
]
+1 -1
View File
@@ -5,7 +5,7 @@ from plane.app.views import (
IssueViewViewSet,
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewFavoriteViewSet,
IssueViewFavoriteViewSet,
)
-1
View File
@@ -65,7 +65,6 @@ urlpatterns = [
{
"delete": "destroy",
"get": "retrieve",
"patch": "partial_update",
}
),
name="workspace-invitations",
+11 -10
View File
@@ -11,7 +11,6 @@ from .project import (
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint,
)
from .user import (
UserEndpoint,
@@ -59,10 +58,13 @@ from .cycle import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
@@ -83,19 +85,20 @@ from .issue import (
)
from .auth_extended import (
VerifyEmailEndpoint,
RequestEmailVerificationEndpoint,
ForgotPasswordEndpoint,
ResetPasswordEndpoint,
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
EmailCheckEndpoint,
MagicGenerateEndpoint,
)
from .authentication import (
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
MagicSignInGenerateEndpoint,
)
from .module import (
@@ -103,6 +106,7 @@ from .module import (
ModuleIssueViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleIssueGroupedEndpoint,
)
from .api import ApiTokenEndpoint
@@ -131,6 +135,7 @@ from .page import (
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
CreateIssueFromBlockEndpoint,
)
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
@@ -163,8 +168,4 @@ from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
from .webhook import (
WebhookEndpoint,
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .webhook import WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint
+7 -15
View File
@@ -4,7 +4,7 @@ from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseAPIView, BaseViewSet
from .base import BaseAPIView
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
@@ -34,20 +34,10 @@ class FileAssetEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, workspace_id, asset_key):
def patch(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class FileAssetViewSet(BaseViewSet):
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = False
file_asset.is_deleted = request.data.get("is_deleted", file_asset.is_deleted)
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -73,6 +63,8 @@ class UserAssetsEndpoint(BaseAPIView):
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
file_asset.is_deleted = True
file_asset.save()
# Delete the file from storage
file_asset.asset.delete(save=False)
# Delete the file object
file_asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+64 -380
View File
@@ -1,9 +1,5 @@
## Python imports
import uuid
import os
import json
import random
import string
import jwt
## Django imports
from django.contrib.auth.tokens import PasswordResetTokenGenerator
@@ -12,117 +8,80 @@ from django.utils.encoding import (
smart_bytes,
DjangoUnicodeDecodeError,
)
from django.contrib.auth.hashers import make_password
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
## Third Party Imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import permissions
from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.app.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
UserSerializer,
)
from plane.db.models import User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from plane.db.models import User
from plane.bgtasks.email_verification_task import email_verification
from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
str(refresh.access_token),
str(refresh),
)
class RequestEmailVerificationEndpoint(BaseAPIView):
def get(self, request):
token = RefreshToken.for_user(request.user).access_token
current_site = request.META.get('HTTP_ORIGIN')
email_verification.delay(
request.user.first_name, request.user.email, token, current_site
)
return Response(
{"message": "Email sent successfully"}, status=status.HTTP_200_OK
)
def generate_magic_token(email):
key = "magic_" + str(email)
class VerifyEmailEndpoint(BaseAPIView):
def get(self, request):
token = request.GET.get("token")
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256")
user = User.objects.get(id=payload["user_id"])
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
# Initialize the redis instance
ri = redis_instance()
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return key, token, False
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
return key, token, True
def generate_password_token(user):
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
return uidb64, token
if not user.is_email_verified:
user.is_email_verified = True
user.save()
return Response(
{"email": "Successfully activated"}, status=status.HTTP_200_OK
)
except jwt.ExpiredSignatureError as _indentifier:
return Response(
{"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST
)
except jwt.exceptions.DecodeError as _indentifier:
return Response(
{"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST
)
class ForgotPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
permission_classes = [permissions.AllowAny]
def post(self, request):
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
uidb64 = urlsafe_base64_encode(smart_bytes(user.id))
token = PasswordResetTokenGenerator().make_token(user)
current_site = request.META.get('HTTP_ORIGIN')
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
)
return Response(
{"message": "Check your email to reset your password"},
status=status.HTTP_200_OK,
@@ -133,40 +92,30 @@ class ForgotPasswordEndpoint(BaseAPIView):
class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
permission_classes = [permissions.AllowAny]
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
return Response(
{"error": "Token is invalid"},
{"error": "token is not valid, please check the new one"},
status=status.HTTP_401_UNAUTHORIZED,
)
# Reset the password
serializer = ResetPasswordSerializer(data=request.data)
if serializer.is_valid():
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
# Log the user in
# Generate access token for the user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
response = {
"status": "success",
"code": status.HTTP_200_OK,
"message": "Password updated successfully",
}
return Response(data, status=status.HTTP_200_OK)
return Response(response)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except DjangoUnicodeDecodeError as indentifier:
@@ -179,289 +128,24 @@ class ResetPasswordEndpoint(BaseAPIView):
class ChangePasswordEndpoint(BaseAPIView):
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id)
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
# Check old password
if not user.object.check_password(serializer.data.get("old_password")):
return Response(
{"error": "Old password is not correct"},
{"old_password": ["Wrong password."]},
status=status.HTTP_400_BAD_REQUEST,
)
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.is_password_autoset = False
user.save()
return Response(
{"message": "Password updated successfully"}, status=status.HTTP_200_OK
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SetUserPasswordEndpoint(BaseAPIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
return Response(
{
"error": "Your password is already set please change your password from profile"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password and len(str(password)) < 8:
return Response(
{"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Set the user password
user.set_password(password)
user.is_password_autoset = False
user.save()
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
class MagicGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up the email
email = email.strip().lower()
validate_email(email)
# check if the email exists not
if not User.objects.filter(email=email).exists():
# Create a user
_ = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
self.object.set_password(serializer.data.get("new_password"))
self.object.save()
response = {
"status": "success",
"code": status.HTTP_200_OK,
"message": "Password updated successfully",
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
return Response(response)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# If the smtp is configured send through here
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class EmailCheckEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get configuration values
ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
},
]
)
email = request.data.get("email", False)
if not email:
return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
)
# validate the email
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Check if the user exists
user = User.objects.filter(email=email).first()
current_site = request.META.get("HTTP_ORIGIN")
# If new user
if user is None:
# Create the user
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the user with default values
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
if not bool(
ENABLE_MAGIC_LINK_LOGIN,
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response(
{"is_password_autoset": user.is_password_autoset, "is_existing": False},
status=status.HTTP_200_OK,
)
# Existing user
else:
if user.is_password_autoset:
## Generate a random token
if not bool(ENABLE_MAGIC_LINK_LOGIN):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
)
# Generate magic token
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, key, token, current_site)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
)
# User should enter password to login
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+314 -165
View File
@@ -1,7 +1,10 @@
# Python imports
import os
import uuid
import random
import string
import json
import requests
from requests.exceptions import RequestException
# Django imports
from django.utils import timezone
@@ -15,7 +18,8 @@ from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_message
from sentry_sdk import capture_exception, capture_message
# Module imports
from . import BaseAPIView
@@ -27,9 +31,7 @@ from plane.db.models import (
ProjectMember,
)
from plane.settings.redis import redis_instance
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
from plane.bgtasks.magic_link_code_task import magic_link
def get_tokens_for_user(user):
@@ -44,16 +46,17 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
if not settings.ENABLE_SIGNUP:
return Response(
{"error": "Instance is not configured"},
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
@@ -61,8 +64,8 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@@ -71,31 +74,6 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# get configuration values
# Get configuration values
ENABLE_SIGNUP, = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# If the sign up is not enabled and the user does not have invite disallow him from creating the account
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user already exists
if User.objects.filter(email=email).exists():
return Response(
@@ -107,103 +85,6 @@ class SignUpEndpoint(BaseAPIView):
user.set_password(password)
# settings last actives for the user
user.is_password_autoset = False
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
{"error": "Both email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Create the user
else:
ENABLE_SIGNUP, = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP"),
},
]
)
# Create the user
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -259,25 +140,186 @@ class SignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
{"error": "Both email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
return Response(
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.filter(email=email).first()
if user is None:
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Sign up Process
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last active for the user
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=workspace_member_invite.workspace_id,
member=user,
role=workspace_member_invite.role,
)
for workspace_member_invite in workspace_member_invites
],
ignore_conflicts=True,
)
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
)
# Add user to workspace
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Now add the users to project
ProjectMember.objects.bulk_create(
[
ProjectMember(
workspace_id=project_member_invite.workspace_id,
role=project_member_invite.role
if project_member_invite.role in [5, 10, 15]
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False,
)
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
data = {
@@ -310,20 +352,77 @@ class SignOutEndpoint(BaseAPIView):
return Response({"message": "success"}, status=status.HTTP_200_OK)
class MagicSignInGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get('HTTP_ORIGIN')
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class MagicSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False).strip().lower()
@@ -342,20 +441,71 @@ class MagicSignInEndpoint(BaseAPIView):
email = data["email"]
if str(token) == str(user_token):
user = User.objects.get(email=email)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False,
)
if User.objects.filter(email=email).exists():
user = User.objects.get(email=email)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
else:
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
user.is_active = True
user.is_email_verified = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -411,8 +561,7 @@ class MagicSignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
+1 -6
View File
@@ -43,25 +43,20 @@ class TimezoneMixin:
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
+69 -85
View File
@@ -11,6 +11,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.models import Instance, InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@@ -20,101 +21,84 @@ class ConfigurationEndpoint(BaseAPIView):
]
def get(self, request):
# Get all the configuration
(
GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID,
GITHUB_APP_NAME,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
SLACK_CLIENT_ID,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", None),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID", None),
},
{
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", None),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", None),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", None),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", "1"),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", "1"),
},
]
)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
data = {}
# Authentication
data["google_client_id"] = GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != "\"\"" else None
data["github_client_id"] = GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != "\"\"" else None
data["github_app_name"] = GITHUB_APP_NAME
data["google_client_id"] = get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID", None),
)
data["github_client_id"] = get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID", None),
)
data["github_app_name"] = get_configuration_value(
instance_configuration,
"GITHUB_APP_NAME",
os.environ.get("GITHUB_APP_NAME", None),
)
data["magic_login"] = (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
) and ENABLE_MAGIC_LINK_LOGIN == "1"
data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("GITHUB_APP_NAME", None),
),
)
and bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("GITHUB_APP_NAME", None),
)
)
) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0"
) == "1"
data["email_password_login"] = (
get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "0"
)
== "1"
)
# Slack client
data["slack_client_id"] = SLACK_CLIENT_ID
data["slack_client_id"] = get_configuration_value(
instance_configuration,
"SLACK_CLIENT_ID",
os.environ.get("SLACK_CLIENT_ID", None),
)
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
data["posthog_api_key"] = get_configuration_value(
instance_configuration,
"POSTHOG_API_KEY",
os.environ.get("POSTHOG_API_KEY", None),
)
data["posthog_host"] = get_configuration_value(
instance_configuration,
"POSTHOG_HOST",
os.environ.get("POSTHOG_HOST", None),
)
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
data["has_unsplash_configured"] = bool(
get_configuration_value(
instance_configuration,
"UNSPLASH_ACCESS_KEY",
os.environ.get("UNSPLASH_ACCESS_KEY", None),
)
)
# Open AI settings
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# is self managed
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
data["has_openai_configured"] = bool(
get_configuration_value(
instance_configuration,
"OPENAI_API_KEY",
os.environ.get("OPENAI_API_KEY", None),
)
)
return Response(data, status=status.HTTP_200_OK)
+73 -10
View File
@@ -502,10 +502,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
webhook_event = "cycle"
permission_classes = [
ProjectEntityPermission,
]
@@ -539,8 +536,9 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -575,9 +573,24 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
@@ -675,6 +688,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@@ -684,15 +698,64 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(self.request.user.id),
issue_id=str(cycle_issue.issue_id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, cycle_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_cycle__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.filter(**filters)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
+15 -32
View File
@@ -1,6 +1,5 @@
# Python imports
import requests
import os
# Third party imports
from openai import OpenAI
@@ -16,31 +15,23 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", None),
},
{
"key": "GPT_ENGINE",
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
},
]
)
# Get the configuration value
instance_configuration = InstanceConfiguration.objects.values("key", "value")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
# Check the keys
if not OPENAI_API_KEY or not GPT_ENGINE:
if not api_key or not gpt_engine:
return Response(
{"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -56,12 +47,16 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
instance_configuration = InstanceConfiguration.objects.values("key", "value")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
client = OpenAI(
api_key=OPENAI_API_KEY,
api_key=api_key,
)
response = client.chat.completions.create(
model=GPT_ENGINE,
model=gpt_engine,
messages=[{"role": "user", "content": final_text}],
)
@@ -88,28 +83,16 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
UNSPLASH_ACCESS_KEY, = get_configuration_value(
[
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY"),
}
]
)
# Check unsplash access key
if not UNSPLASH_ACCESS_KEY:
return Response([], status=status.HTTP_200_OK)
# Query parameters
def get(self, request):
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
+112 -147
View File
@@ -1,7 +1,5 @@
# Python imports
import uuid
import json
import requests
# Third party imports
from rest_framework import status
@@ -9,7 +7,7 @@ from rest_framework.response import Response
# Django imports
from django.db.models import Max, Q
from django.conf import settings
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import (
@@ -36,15 +34,20 @@ from plane.app.serializers import (
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
from plane.bgtasks.importer_task import service_importer
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
@@ -55,10 +58,11 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
integration__provider="github", workspace__slug=slug
)
installtion_id = workspace_integration.config.get("installation_id", False)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
# Check for the installation id
if not installtion_id:
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
@@ -66,33 +70,18 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Request segway for the required information
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"owner": owner,
"repo": repo,
"installationId": installtion_id,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
res.json(),
status=status.HTTP_200_OK,
)
return Response(
{"error": "Inetgration service is not available please try later"},
status=status.HTTP_400_BAD_REQUEST,
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
# Check for all the keys
params = {
@@ -113,35 +102,16 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"project_key": project_key,
"api_token": api_token,
"email": email,
"cloud_hostname": cloud_hostname,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/jira",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
res.json(),
status=status.HTTP_200_OK,
)
return Response(
{"error": "Inetgration service is not available please try later"},
status=status.HTTP_400_BAD_REQUEST,
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
response,
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
@@ -152,21 +122,7 @@ class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
if service not in ["github", "jira"]:
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
if service == "github":
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
installation_id = workspace_integration.config.get("installation_id", False)
project_id = request.data.get("project_id", False)
if not project_id:
@@ -174,84 +130,87 @@ class ImportServiceEndpoint(BaseAPIView):
{"error": "Project ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
# Validate the data
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
# Update config
if config and service == "github":
config.update({"installation_id": installation_id})
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# Get the api token -- # derecated
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
# Create an import
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"metadata": metadata,
"data": data,
"config": config,
"workspace_id": str(workspace.id),
"project_id": str(project_id),
"created_by": str(request.user.id),
"importer_id": str(importer.id),
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/import",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
importer.status = "failed"
importer.reason = str(res.json())
importer.save()
else:
importer.status = "processing"
importer.save(update_fields=["status"])
else:
importer.status = "failed"
importer.reason = "Segway base url is not present"
importer.save(update_fields=["status", "reason"])
# return the response
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
imports = (
Importer.objects.filter(workspace__slug=slug)
@@ -262,7 +221,9 @@ class ImportServiceEndpoint(BaseAPIView):
return Response(serializer.data)
def delete(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
if importer.imported_data is not None:
# Delete all imported Issues
@@ -280,7 +241,9 @@ class ImportServiceEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(importer, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -516,7 +479,9 @@ class BulkImportModulesEndpoint(BaseAPIView):
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
url=module_data.get("link", {}).get(
"url", "https://plane.so"
),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
+11 -25
View File
@@ -1,11 +1,8 @@
# Python improts
import uuid
import requests
import json
# Django imports
from django.contrib.auth.hashers import make_password
from django.conf import settings
# Third party imports
from rest_framework.response import Response
@@ -30,7 +27,6 @@ from plane.utils.integrations.github import (
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
@@ -50,7 +46,9 @@ class IntegrationViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(integration, data=request.data, partial=True)
serializer = IntegrationSerializer(
integration, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
@@ -96,30 +94,14 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {"installationId": installation_id}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/metadata",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
metadata = res.json()
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
slack_response = slack_oauth(code=code)
@@ -141,7 +123,9 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=integration.avatar_url if integration.avatar_url is not None else "",
avatar=integration.avatar_url
if integration.avatar_url is not None
else "",
)
# Create an API Token for the bot user
@@ -177,7 +161,9 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get("installation_id", False)
installation_id = workspace_integration.config.get(
"installation_id", False
)
if installation_id:
delete_github_installation(installation_id=installation_id)
@@ -1,15 +1,8 @@
# Python imports
import json
import requests
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Django imports
from django.conf import settings
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
@@ -42,32 +35,19 @@ class GithubRepositoriesEndpoint(BaseAPIView):
workspace__slug=slug, pk=workspace_integration_id
)
installation_id = workspace_integration.config.get("installation_id")
if not installation_id:
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"installationId": installation_id,
"page": page,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/repos",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(res.json(), status=status.HTTP_200_OK)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
class GithubRepositorySyncViewSet(BaseViewSet):
+138 -14
View File
@@ -4,7 +4,6 @@ import random
from itertools import chain
# Django imports
from django.db import models
from django.utils import timezone
from django.db.models import (
Prefetch,
@@ -133,7 +132,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
@@ -217,9 +215,25 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -297,6 +311,104 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
serializer = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueListGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
@@ -484,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
webhook_event = "issue-comment"
permission_classes = [
ProjectLitePermission,
]
@@ -971,7 +1083,6 @@ class IssueArchiveViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@@ -1062,9 +1173,14 @@ class IssueArchiveViewSet(BaseViewSet):
else issue_queryset.filter(parent__isnull=True)
)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
@@ -1464,7 +1580,6 @@ class IssueDraftViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
@@ -1547,9 +1662,18 @@ class IssueDraftViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
+72 -10
View File
@@ -283,12 +283,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
@@ -324,8 +321,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
order_by = request.GET.get("order_by", "created_at")
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
@@ -359,9 +357,24 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
@@ -448,6 +461,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
@@ -457,15 +471,63 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(request.user.id),
issue_id=str(module_issue.issue_id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_module__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.filter(**filters)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class ModuleLinkViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
+63 -91
View File
@@ -2,6 +2,7 @@
import uuid
import requests
import os
from requests.exceptions import RequestException
# Django imports
from django.utils import timezone
@@ -28,10 +29,7 @@ from plane.db.models import (
ProjectMemberInvite,
ProjectMember,
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import Instance
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user):
@@ -86,14 +84,7 @@ def get_access_token(request_token: str, client_id: str) -> str:
if not request_token:
raise ValueError("The request token has to be supplied!")
(CLIENT_SECRET,) = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET", None),
},
]
)
CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET")
url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}"
headers = {"accept": "application/json"}
@@ -142,31 +133,10 @@ class OauthEndpoint(BaseAPIView):
def post(self, request):
try:
# Check if instance is registered or not
instance = Instance.objects.first()
if instance is None and not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
medium = request.data.get("medium", False)
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID"),
},
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID"),
},
]
)
if not medium or not id_token:
return Response(
{
@@ -176,19 +146,9 @@ class OauthEndpoint(BaseAPIView):
)
if medium == "google":
if not GOOGLE_CLIENT_ID:
return Response(
{"error": "Google login is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
data = validate_google_token(id_token, client_id)
if medium == "github":
if not GITHUB_CLIENT_ID:
return Response(
{"error": "Github login is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
access_token = get_access_token(id_token, client_id)
data = get_user_data(access_token)
@@ -214,7 +174,15 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
user.is_active = True
## Login Case
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -271,8 +239,7 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -289,17 +256,29 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False,
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
@@ -310,26 +289,7 @@ class OauthEndpoint(BaseAPIView):
return Response(data, status=status.HTTP_200_OK)
except User.DoesNotExist:
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
}
]
)
if (
ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
## Signup Case
username = uuid.uuid4().hex
@@ -345,7 +305,7 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
user = User(
username=username,
email=email,
mobile_number=mobile_number,
@@ -356,6 +316,7 @@ class OauthEndpoint(BaseAPIView):
)
user.set_password(uuid.uuid4().hex)
user.is_password_autoset = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -412,8 +373,7 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -421,16 +381,29 @@ class OauthEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True,
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
SocialLoginConnection.objects.update_or_create(
medium=medium,
@@ -447,5 +420,4 @@ class OauthEndpoint(BaseAPIView):
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_201_CREATED)
return Response(data, status=status.HTTP_201_CREATED)
+48 -30
View File
@@ -22,7 +22,6 @@ from plane.db.models import (
IssueAssignee,
IssueActivity,
PageLog,
ProjectMember,
)
from plane.app.serializers import (
PageSerializer,
@@ -141,6 +140,12 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can lock the page
if request.user.id != page.owned_by_id:
return Response(
{"error": "Only the page owner can lock the page"},
)
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -150,6 +155,12 @@ class PageViewSet(BaseViewSet):
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can unlock the page
if request.user.id != page.owned_by_id:
return Response(
{"error": "Only the page owner can unlock the page"},
status=status.HTTP_400_BAD_REQUEST,
)
page.is_locked = False
page.save()
@@ -164,16 +175,10 @@ class PageViewSet(BaseViewSet):
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
# only the owner and admin can archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
if page.owned_by_id != request.user.id:
return Response(
{"error": "Only the owner and admin can archive the page"},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Only the owner of the page can archive a page"},
status=status.HTTP_204_NO_CONTENT,
)
unarchive_archive_page_and_descendants(page_id, datetime.now())
@@ -183,15 +188,9 @@ class PageViewSet(BaseViewSet):
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
# only the owner and admin can un archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
if page.owned_by_id != request.user.id:
return Response(
{"error": "Only the owner and admin can un archive the page"},
{"error": "Only the owner of the page can unarchive a page"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -217,18 +216,6 @@ class PageViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
# only the owner and admin can delete the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
).exists()
or request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can delete the page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
@@ -240,6 +227,7 @@ class PageViewSet(BaseViewSet):
parent_id=pk, project_id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -322,6 +310,36 @@ class PageLogEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class CreateIssueFromBlockEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id, page_id):
page = Page.objects.get(
workspace__slug=slug,
project_id=project_id,
pk=page_id,
)
issue = Issue.objects.create(
name=request.data.get("name"),
project_id=project_id,
)
_ = IssueAssignee.objects.create(
issue=issue, assignee=request.user, project_id=project_id
)
_ = IssueActivity.objects.create(
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"created the issue from {page.name} block",
verb="created",
)
return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK)
class SubPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
+20 -95
View File
@@ -39,10 +39,8 @@ from plane.app.serializers import (
)
from plane.app.permissions import (
WorkspaceUserPermission,
ProjectBasePermission,
ProjectMemberPermission,
ProjectLitePermission,
)
from plane.db.models import (
@@ -145,16 +143,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
)
)
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).select_related("member"),
to_attr="members_list",
)
)
.distinct()
)
@@ -170,6 +158,15 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
projects = (
self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=slug,
is_active=True,
).select_related("member"),
)
)
.order_by("sort_order", "name")
)
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
@@ -391,7 +388,7 @@ class ProjectInvitationsViewset(BaseViewSet):
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
project_invitations = []
@@ -427,7 +424,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 = request.META.get('HTTP_ORIGIN')
# Send invitations
for invitation in project_invitations:
@@ -472,13 +469,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace_role = workspace_member.role
workspace = workspace_member.workspace
# If the user was already part of workspace
_ = ProjectMember.objects.filter(
workspace__slug=slug,
project_id__in=project_ids,
member=request.user,
).update(is_active=True)
ProjectMember.objects.bulk_create(
[
ProjectMember(
@@ -603,18 +593,6 @@ class ProjectMemberViewSet(BaseViewSet):
ProjectMemberPermission,
]
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",
@@ -679,25 +657,6 @@ class ProjectMemberViewSet(BaseViewSet):
)
)
# Check if the user is already a member of the project and is inactive
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
).exists():
member_detail = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
)
# Check if the user has not deactivated the account
user = User.objects.filter(pk=member.get("member_id")).first()
if user.is_active:
member_detail.is_active = True
member_detail.save(update_fields=["is_active"])
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
@@ -1010,18 +969,11 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
def get(self, request):
files = []
s3_client_params = {
"service_name": "s3",
"aws_access_key_id": settings.AWS_ACCESS_KEY_ID,
"aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY,
}
# Use AWS_S3_ENDPOINT_URL if it is present in the settings
if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL:
s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL
s3 = boto3.client(**s3_client_params)
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
@@ -1034,19 +986,9 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
if (
hasattr(settings, "AWS_S3_CUSTOM_DOMAIN")
and settings.AWS_S3_CUSTOM_DOMAIN
and hasattr(settings, "AWS_S3_URL_PROTOCOL")
and settings.AWS_S3_URL_PROTOCOL
):
files.append(
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}"
)
else:
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
@@ -1098,21 +1040,4 @@ class ProjectDeployBoardViewSet(BaseViewSet):
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class UserProjectRolesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceUserPermission,
]
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"] for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
+12 -83
View File
@@ -12,14 +12,11 @@ from plane.app.serializers import (
)
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
from plane.db.models import User, IssueActivity, WorkspaceMember
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, IntegerField
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User
@@ -48,83 +45,16 @@ class UserEndpoint(BaseViewSet):
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
# Instance admin check
if InstanceAdmin.objects.filter(user=user).exists():
return Response({"error": "You cannot deactivate your account since you are an instance admin"}, status=status.HTTP_400_BAD_REQUEST)
projects_to_deactivate = []
workspaces_to_deactivate = []
projects = ProjectMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for project in projects:
if project.other_admin_exists > 0 or (project.total_members == 1):
project.is_active = False
projects_to_deactivate.append(project)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some projects."
},
status=status.HTTP_400_BAD_REQUEST,
)
workspaces = WorkspaceMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for workspace in workspaces:
if workspace.other_admin_exists > 0 or (workspace.total_members == 1):
workspace.is_active = False
workspaces_to_deactivate.append(workspace)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some workspaces."
},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.bulk_update(
projects_to_deactivate, ["is_active"], batch_size=100
)
WorkspaceMember.objects.bulk_update(
workspaces_to_deactivate, ["is_active"], batch_size=100
)
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
return Response(
{
"error": "User cannot deactivate account as user is active in some workspaces"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Deactivate the user
user.is_active = False
user.last_workspace_id = None
user.is_tour_completed = False
user.is_onboarded = False
user.onboarding_step = {
"workspace_join": False,
"profile_complete": False,
"workspace_create": False,
"workspace_invite": False,
}
user.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -146,10 +76,10 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace", "issue", "project"
)
def get(self, request, slug):
queryset = IssueActivity.objects.filter(
actor=request.user, workspace__slug=slug
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
request=request,
@@ -158,4 +88,3 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)
+19 -8
View File
@@ -20,7 +20,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView
from . import BaseViewSet
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
@@ -95,7 +95,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
@@ -178,13 +177,25 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
+2 -4
View File
@@ -20,10 +20,9 @@ class WebhookEndpoint(BaseAPIView):
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
serializer = WebhookSerializer(
data=request.data, context={"request": request}
)
serializer = WebhookSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -80,7 +79,6 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
partial=True,
fields=(
"id",
+28 -53
View File
@@ -70,11 +70,11 @@ from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
WorkspaceViewerPermission,
WorkspaceUserPermission,
)
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event
from plane.utils.grouper import group_results
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
@@ -113,10 +113,7 @@ class WorkSpaceViewSet(BaseViewSet):
return (
self.filter_queryset(super().get_queryset().select_related("owner"))
.order_by("name")
.filter(
workspace_member__member=self.request.user,
workspace_member__is_active=True,
)
.filter(workspace_member__member=self.request.user)
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.select_related("owner")
@@ -192,21 +189,17 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
workspace = (
Workspace.objects.prefetch_related(
Prefetch(
"workspace_member",
queryset=WorkspaceMember.objects.filter(
member=request.user, is_active=True
),
(
Workspace.objects.prefetch_related(
Prefetch("workspace_member", queryset=WorkspaceMember.objects.all())
)
.filter(
workspace_member__member=request.user,
)
.select_related("owner")
)
.select_related("owner")
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.filter(
workspace_member__member=request.user, workspace_member__is_active=True
)
.distinct()
)
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
@@ -326,7 +319,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
workspace_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get("HTTP_ORIGIN")
current_site = request.META.get('HTTP_ORIGIN')
# Send invitations
for invitation in workspace_invitations:
@@ -407,16 +400,6 @@ class WorkspaceJoinEndpoint(BaseAPIView):
# Delete the invitation
workspace_invite.delete()
# Send event
workspace_invite_event.delay(
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"),
event_name="MEMBER_ACCEPTED",
accepted_from="EMAIL",
)
return Response(
{"message": "Workspace Invitation Accepted"},
@@ -435,9 +418,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
)
def get(self, request, slug, pk):
workspace_invitation = WorkspaceMemberInvite.objects.get(
workspace__slug=slug, pk=pk
)
workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk)
serializer = WorkSpaceMemberInviteSerializer(workspace_invitation)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -496,18 +477,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
WorkspaceEntityPermission,
]
def get_permissions(self):
if self.action == "leave":
self.permission_classes = [
WorkspaceUserPermission,
]
else:
self.permission_classes = [
WorkspaceEntityPermission,
]
return super(WorkSpaceMemberViewSet, self).get_permissions()
search_fields = [
"member__display_name",
"member__first_name",
@@ -621,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
member_with_role=Count(
"project_projectmember",
filter=Q(
project_projectmember__member_id=workspace_member.id,
project_projectmember__member_id=request.user.id,
project_projectmember__role=20,
),
),
@@ -631,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin."
"error": "User is part of some projects where they are the only admin you should leave that project first"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -666,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin."
"error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -687,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin."
"error": "User is part of some projects where they are the only admin you should leave that project first"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1229,7 +1198,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
]
def get(self, request, slug, user_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
@@ -1331,11 +1299,18 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceLabelsEndpoint(BaseAPIView):
-1
View File
@@ -1 +0,0 @@
from .issue_sync_task import issue_sync
+15 -32
View File
@@ -1,8 +1,6 @@
# Python imports
import csv
import io
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -18,7 +16,8 @@ from sentry_sdk import capture_exception
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
row_mapping = {
"state__name": "State",
@@ -33,7 +32,7 @@ row_mapping = {
"priority": "Priority",
"estimate": "Estimate",
"issue_cycle__cycle_id": "Cycle",
"issue_module__module_id": "Module",
"issue_module__module_id": "Module"
}
ASSIGNEE_ID = "assignees__id"
@@ -43,7 +42,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
MODULE_ID = "issue_module__module_id"
def send_export_email(email, slug, csv_buffer, rows):
def send_export_email(email, slug, csv_buffer):
"""Helper function to send export email."""
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
@@ -51,33 +50,20 @@ def send_export_email(email, slug, csv_buffer, rows):
csv_buffer.seek(0)
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)
return
def get_assignee_details(slug, filters):
@@ -373,7 +359,7 @@ def generate_non_segmented_rows(
return [tuple(row_zero)] + rows
@shared_task(queue='internal_tasks')
@shared_task
def analytic_export_task(email, data, slug):
try:
filters = issue_filters(data, "POST")
@@ -445,11 +431,8 @@ def analytic_export_task(email, data, slug):
)
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows)
return
send_export_email(email, slug, csv_buffer)
except Exception as e:
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -0,0 +1,57 @@
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Verify your Email!"
context = {
"first_name": first_name,
"verification_url": abs_url,
}
html_content = render_to_string("emails/auth/email_verification.html", context)
text_content = strip_tags(html_content)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1,78 +0,0 @@
import uuid
import os
# third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from posthog import Posthog
# module imports
from plane.license.utils.instance_value import get_configuration_value
def posthogConfiguration():
POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value(
[
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", None),
},
]
)
if POSTHOG_API_KEY and POSTHOG_HOST:
return POSTHOG_API_KEY, POSTHOG_HOST
else:
return None, None
@shared_task(queue='internal_tasks')
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"medium": medium,
"first_time": first_time
}
)
except Exception as e:
capture_exception(e)
@shared_task(queue='internal_tasks')
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
if POSTHOG_API_KEY and POSTHOG_HOST:
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"accepted_from": accepted_from
}
)
except Exception as e:
capture_exception(e)
+1 -1
View File
@@ -259,7 +259,7 @@ def generate_xlsx(header, project_id, issues, files):
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task(queue='internal_tasks')
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
@@ -15,7 +15,7 @@ from botocore.client import Config
from plane.db.models import ExporterHistory
@shared_task(queue='internal_tasks')
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
+1 -1
View File
@@ -12,7 +12,7 @@ from celery import shared_task
from plane.db.models import FileAsset
@shared_task(queue='internal_tasks')
@shared_task
def delete_file_asset():
# file assets to delete
+16 -35
View File
@@ -1,8 +1,3 @@
# Python import
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -14,53 +9,39 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task(queue='internal_tasks')
@shared_task
def forgot_password(first_name, email, uidb64, token, current_site):
try:
relative_link = (
f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}"
)
abs_url = str(current_site) + relative_link
realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
from_email_string = settings.EMAIL_FROM
subject = "A new password to your Plane account has been requested"
subject = "Reset Your Password - Plane"
context = {
"first_name": first_name,
"forgot_password_url": abs_url,
"email": email,
}
html_content = render_to_string("emails/auth/forgot_password.html", context)
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
+159 -426
View File
@@ -1,467 +1,200 @@
# Python imports
import json
import requests
import uuid
# Django imports
from django.db.models import Q, Max
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.hashers import make_password
# Third Party imports
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
from sentry_sdk import capture_exception
# Module imports
from plane.app.serializers import ImporterSerializer
from plane.db.models import (
Importer,
WorkspaceMember,
GithubRepositorySync,
GithubRepository,
ProjectMember,
WorkspaceIntegration,
Label,
User,
IssueProperty,
IssueAssignee,
IssueLabel,
IssueSequence,
IssueActivity,
IssueComment,
IssueLink,
ModuleIssue,
State,
Module,
Issue,
Cycle,
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
@shared_task(queue="internal_tasks")
@shared_task
def service_importer(service, importer_id):
pass
## Utility functions
def get_label_id(name, data):
try:
existing_label = (
Label.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name__iexact=name,
)
.values("id")
.first()
)
return existing_label
except Label.DoesNotExist:
return None
importer = Importer.objects.get(pk=importer_id)
importer.status = "processing"
importer.save()
users = importer.data.get("users", [])
def get_state_id(name, data):
try:
existing_state = (
State.objects.filter(
name__iexact=name,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
.values("id")
.first()
)
return existing_state
except State.DoesNotExist:
return None
def get_user_id(name):
try:
existing_user = User.objects.filter(email=name).values("id").first()
return existing_user
except User.DoesNotExist:
return None
def update_imported_items(importer_id, entity, entity_id):
importer = Importer.objects.get(pk=importer_id)
if importer.imported_data:
importer.imported_data.setdefault(str(entity), []).append(str(entity_id))
else:
importer.imported_data = {
str(entity): [str(entity_id)]
}
importer.save()
## Sync functions
def members_sync(data):
try:
user = User.objects.get(email=data.get("email"))
_ = WorkspaceMember.objects.get_or_create(
member_id=user.id, workspace_id=data.get("workspace_id")
)
_ = ProjectMember.objects.get_or_create(
member_id=user.id,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
_ = IssueProperty.objects.get_or_create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
user_id=user.id,
created_by_id=data.get("created_by"),
)
except User.DoesNotExist:
# For all invited users create the users
new_user = User.objects.create(
email=data.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
service = data.get("external_source")
WorkspaceMember.objects.create(
member_id=new_user.id,
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
)
ProjectMember.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
member_id=new_user.id,
created_by_id=data.get("created_by"),
)
IssueProperty.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
user_id=new_user.id,
created_by_id=data.get("created_by"),
)
if data.get("source", False) == "slack":
send_welcome_slack.delay(
str(new_user.id),
True,
f"{new_user.email} was imported to Plane from {service}",
# Check if we need to import users as well
if len(users):
# For all invited users create the users
new_users = User.objects.bulk_create(
[
User(
email=user.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
for user in users
if user.get("import", False) == "invite"
],
batch_size=10,
ignore_conflicts=True,
)
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
def label_sync(data):
existing_label = Label.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name__iexact=data.get("name"),
external_id=data.get("external_id", None),
external_source=data.get("external_source"),
)
if not existing_label.exists() and data.get("name"):
label = Label.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name=data.get("name"),
color=data.get("color"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id", None),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "labels", label.id)
def state_sync(data):
try:
state = State.objects.get(
external_id=data.get("external_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except State.DoesNotExist:
existing_states = State.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
group=data.get("state_group"),
name__iexact=data.get("state_name"),
)
if existing_states.exists():
existing_state = existing_states.first()
existing_state.external_id = data.get("external_id")
existing_state.external_source = data.get("external_source")
existing_state.save()
else:
state = State.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name=data.get("state_name"),
group=data.get("state_group"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
workspace_users = User.objects.filter(
email__in=[
user.get("email").strip().lower()
for user in users
if user.get("import", False) == "invite"
or user.get("import", False) == "map"
]
)
update_imported_items(data.get("importer_id"), "states", state.id)
# Check if any of the users are already member of workspace
_ = WorkspaceMember.objects.filter(
member__in=[user for user in workspace_users],
workspace_id=importer.workspace_id,
).update(is_active=True)
def issue_sync(data):
try:
issue = Issue.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Issue.DoesNotExist:
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=data.get("project_id"), default=True
).first()
# Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
member=user,
workspace_id=importer.workspace_id,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=data.get("project_id")
ProjectMember.objects.bulk_create(
[
ProjectMember(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
user=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
url = importer.metadata.get("url", False)
config = importer.metadata.get("config", {})
owner = importer.metadata.get("owner", False)
repository_id = importer.metadata.get("repository_id", False)
workspace_integration = WorkspaceIntegration.objects.get(
workspace_id=importer.workspace_id, integration__provider="github"
)
# Delete the old repository object
GithubRepositorySync.objects.filter(project_id=importer.project_id).delete()
GithubRepository.objects.filter(project_id=importer.project_id).delete()
# Create a Label for github
label = Label.objects.filter(
name="GitHub", project_id=importer.project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project_id=data.get("project_id")
).aggregate(largest=Max("sequence"))["largest"]
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=importer.project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=importer.project_id,
)
last_id = 1 if last_id is None else last_id + 1
# Create repo sync
_ = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=importer.data.get("credentials", {}),
project_id=importer.project_id,
label=label,
)
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=data.get("project_id"), state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=importer.project_id,
)
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
parent_id = None
if data.get("parent_id", False):
parent_id = Issue.objects.filter(
external_id=data.get("parent_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
).values("id")
if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"}
import_data_json = json.dumps(
ImporterSerializer(importer).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
json=import_data_json,
headers=headers,
)
# Issues
issue = Issue.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
state_id=get_state_id(data.get("state"), data).get("id")
if get_state_id(data.get("state"), data)
else default_state.id,
name=data.get("name", "Issue Created through Importer")[:255],
description_html=data.get("description_html", "<p></p>"),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=data.get("start_date", None),
target_date=data.get("target_date", None),
priority=data.get("priority", "none"),
created_by_id=data.get("created_by_id"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
parent_id=parent_id,
)
# Sequences
_ = IssueSequence.objects.create(
issue=issue,
sequence=issue.sequence_id,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
# Attach Links
_ = IssueLink.objects.create(
issue=issue,
url=data.get("link", {}).get("url", "https://github.com"),
title=data.get("link", {}).get("title", "Original Issue"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
# Track the issue activities
_ = IssueActivity.objects.create(
issue=issue,
actor_id=data.get("created_by_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
comment=f"imported the issue from {data.get('external_source')}",
verb="created",
created_by_id=data.get("created_by_id"),
)
update_imported_items(data.get("importer_id"), "issues", issue.id)
def issue_label_sync(data):
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
if get_label_id(data.get("name"), data):
IssueLabel.objects.create(
issue=issue,
label_id=get_label_id(data.get("name"), data).get("id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
def issue_assignee_sync(data):
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
user = User.objects.filter(email=data.get("email")).values("id")
IssueAssignee.objects.create(
issue=issue,
assignee_id=user,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
def issue_comment_sync(data):
# Create Comments
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
IssueComment.objects.create(
issue=issue,
comment_html=data.get("comment_html", "<p></p>"),
actor_id=data.get("created_by_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=get_user_id(data.get("created_by_id")).get("id")
if get_user_id(data.get("created_by_id"))
else data.get("created_by_id"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
def cycles_sync(data):
try:
_ = Cycle.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Cycle.DoesNotExist:
cycle = Cycle.objects.create(
name=data.get("name"),
description_html=data.get("description_html", "<p></p>"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "cycles", cycle.id)
def module_sync(data):
try:
_ = Module.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Module.DoesNotExist:
module = Module.objects.create(
name=data.get("name"),
description_html=data.get("description_html", "<p></p>"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "modules", module.id)
def modules_issue_sync(data):
module = Module.objects.get(
external_id=data.get("module_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
external_source=data.get("external_source"),
)
issue = Issue.objects.get(
external_id=data.get("issue_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
_ = ModuleIssue.objects.create(
module=module,
issue=issue,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
)
def import_sync(data):
importer = Importer.objects.get(pk=data.get("importer_id"))
importer.status = data.get("status")
importer.save(update_fields=["status"])
@shared_task(bind=True, queue="segway_task", max_retries=5)
def import_task(self, data):
type = data.get("type")
if type is None:
return
TYPE_MAPPER = {
"member.sync": members_sync,
"label.sync": label_sync,
"state.sync": state_sync,
"issue.sync": issue_sync,
"issue.label.sync": issue_label_sync,
"issue.assignee.sync": issue_assignee_sync,
"issue.comment.sync": issue_comment_sync,
"cycle.sync": cycles_sync,
"module.sync": module_sync,
"module.issue.sync": modules_issue_sync,
"import.sync": import_sync,
}
try:
func = TYPE_MAPPER.get(type)
if func is None:
return
# Call the function
func(data)
return
except Exception as e:
try:
# Retry with exponential backoff
self.retry(exc=e, countdown=50, backoff=2)
except MaxRetriesExceededError:
# For max retries reached items fail the import
importer = Importer.objects.get(pk=data.get("importer_id"))
importer.status = "failed"
importer.reason = e
importer.save()
importer = Importer.objects.get(pk=importer_id)
importer.status = "failed"
importer.save()
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1460,7 +1460,7 @@ def delete_draft_issue_activity(
# Receive message from room group
@shared_task(queue='internal_tasks')
@shared_task
def issue_activity(
type,
requested_data,
@@ -16,7 +16,7 @@ from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
@shared_task(queue='internal_tasks')
@shared_task
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
@@ -1,5 +0,0 @@
from celery import shared_task
@shared_task(queue="segway_tasks")
def issue_sync(data):
print(f"Received data from Segway: {data}")
+17 -32
View File
@@ -1,8 +1,3 @@
# Python imports
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -14,48 +9,38 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task(queue='internal_tasks')
@shared_task
def magic_link(email, key, token, current_site):
try:
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = current_site + realtivelink
# Send the mail
subject = f"Your unique Plane login code is {token}"
context = {"code": token, "email": email}
subject = "Login for Plane"
context = {"magic_url": abs_url, "code": token}
html_content = render_to_string("emails/auth/magic_signin.html", context)
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
print(e)
capture_exception(e)
# Print logs if in DEBUG mode
if settings.DEBUG:
+1 -2
View File
@@ -183,14 +183,13 @@ def createMentionNotification(project, notification_comment, issue, actor_id, me
)
@shared_task(queue='internal_tasks')
@shared_task
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
issue_activities_created = (
json.loads(
issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
@@ -1,6 +1,3 @@
# Python import
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -13,9 +10,10 @@ from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task(queue='internal_tasks')
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
@@ -27,6 +25,8 @@ def project_invitation(email, project_id, token, current_site, invitor):
relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}"
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM
subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
context = {
@@ -46,31 +46,16 @@ def project_invitation(email, project_id, token, current_site, invitor):
project_member_invite.save()
# Configure email connection from the database
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
+1 -1
View File
@@ -11,7 +11,7 @@ from slack_sdk.errors import SlackApiError
from plane.db.models import User
@shared_task(queue='internal_tasks')
@shared_task
def send_welcome_slack(user_id, created, message):
try:
instance = User.objects.get(pk=user_id)
+24 -108
View File
@@ -2,67 +2,15 @@ import requests
import uuid
import hashlib
import json
import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import (
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
)
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueExpandSerializer,
"cycle": CycleSerializer,
"module": ModuleSerializer,
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
}
MODEL_MAPPER = {
"project": Project,
"issue": Issue,
"cycle": Cycle,
"module": Module,
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
}
def get_model_data(event, event_id, many=False):
model = MODEL_MAPPER.get(event)
if many:
queryset = model.objects.filter(pk__in=event_id)
else:
queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event)
return serializer(queryset, many=many).data
from plane.db.models import Webhook, WebhookLog
@shared_task(
@@ -71,7 +19,6 @@ def get_model_data(event, event_id, many=False):
retry_backoff=600,
max_retries=5,
retry_jitter=True,
queue='internal_tasks'
)
def webhook_task(self, webhook, slug, event, event_data, action):
try:
@@ -84,12 +31,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
# Your secret key
if webhook.secret_key:
# Concatenate the data and the secret key
message = event_data + webhook.secret_key
# Create a SHA-256 hash of the message
sha256 = hashlib.sha256()
sha256.update(message.encode("utf-8"))
signature = sha256.hexdigest()
headers["X-Plane-Signature"] = signature
event_data = json.loads(event_data) if event_data is not None else None
action = {
"POST": "create",
@@ -106,16 +59,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"data": event_data,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(
webhook.url,
@@ -153,6 +96,10 @@ def webhook_task(self, webhook, slug, event, event_data, action):
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
return
raise requests.RequestException()
except Exception as e:
@@ -162,8 +109,8 @@ def webhook_task(self, webhook, slug, event, event_data, action):
return
@shared_task(queue='internal_tasks')
def send_webhook(event, payload, kw, action, slug, bulk):
@shared_task()
def send_webhook(event, event_data, action, slug):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -173,48 +120,17 @@ def send_webhook(event, payload, kw, action, slug, bulk):
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
if event == "module":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
if event == "cycle":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
if event == "issue-comment":
webhooks = webhooks.filter(issue_comment=True)
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
event_data = IssueExpandSerializer(
Issue.objects.filter(
pk__in=[
str(event.get("issue")) for event in payload
]
).prefetch_related("issue_cycle", "issue_module"), many=True
).data
event = "issue"
action = "PATCH"
else:
event_data = [
get_model_data(
event=event,
event_id=payload.get("id") if isinstance(payload, dict) else None,
many=False,
)
]
if action == "DELETE":
event_data = [{"id": kw.get("pk")}]
for webhook in webhooks:
for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
)
for webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action)
except Exception as e:
if settings.DEBUG:
@@ -1,8 +1,3 @@
# Python imports
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -17,10 +12,11 @@ from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.license.utils.instance_value import get_email_configuration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task(queue='internal_tasks')
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
@@ -34,26 +30,19 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
# The complete url including the domain
abs_url = str(current_site) + relative_link
abs_url = current_site + relative_link
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
# The email from
from_email_string = settings.EMAIL_FROM
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane"
context = {
"email": email,
"first_name": user.first_name or user.display_name or user.email,
"first_name": invitor,
"workspace_name": workspace.name,
"abs_url": abs_url,
"invitation_url": abs_url,
}
html_content = render_to_string(
@@ -65,18 +54,27 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
workspace_member_invite.message = text_content
workspace_member_invite.save()
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(
get_configuration_value(instance_configuration, "EMAIL_PORT", "587")
),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(
instance_configuration, "EMAIL_HOST_PASSWORD"
),
use_tls=bool(
get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"),
to=[email],
connection=connection,
)
@@ -96,7 +94,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
print("Workspace or WorkspaceMember Invite Does not exists")
return
except Exception as e:
# Print logs if in DEBUG mode
+2 -2
View File
@@ -8,7 +8,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
ri = redis_instance()
app = Celery('tasks', broker='pyamqp://guest:guest@localhost:5672//')
app = Celery("plane")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
@@ -33,4 +33,4 @@ app.conf.beat_schedule = {
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
@@ -1,54 +0,0 @@
# Python imports
import getpass
# Django imports
from django.core.management import BaseCommand
# Module imports
from plane.db.models import User
class Command(BaseCommand):
help = "Reset password of the user with the given email"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("email", type=str, help="user email")
def handle(self, *args, **options):
# get the user email from console
email = options.get("email", False)
# raise error if email is not present
if not email:
self.stderr.write("Error: Email is required")
return
# filter the user
user = User.objects.filter(email=email).first()
# Raise error if the user is not present
if not user:
self.stderr.write(f"Error: User with {email} does not exists")
return
# get password for the user
password = getpass.getpass("Password: ")
confirm_password = getpass.getpass("Password (again): ")
# If the passwords doesn't match raise error
if password != confirm_password:
self.stderr.write("Error: Your passwords didn't match.")
return
# Blank passwords should not be allowed
if password.strip() == "":
self.stderr.write("Error: Blank passwords aren't allowed.")
return
# Set user password
user.set_password(password)
user.is_password_autoset = False
user.save()
self.stdout.write(self.style.SUCCESS(f"User password updated succesfully"))
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('transaction', models.UUIDField(default=uuid.uuid4)),
('entity_identifier', models.UUIDField(null=True)),
('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('page_mention', 'Page Mention'), ('user_mention', 'User Mention')], max_length=30, verbose_name='Transaction Type')),
('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('mention', 'Mention')], max_length=30, verbose_name='Transaction Type')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),

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