From 92745791ebefa0b7b162687b26e7191e39553d5a Mon Sep 17 00:00:00 2001 From: Sal Tijerina Date: Wed, 6 Nov 2024 14:15:30 -0600 Subject: [PATCH] task/WI-200: User Onboarding and Administration (#1479) * onboarding backend * onboarding backend adjustments; move allocations to users api * use onboarding system_access_v3 * use SESSION_COOKIE_DOMAIN for onboarding project membership setting * add onboarding hooks * add setup complete and staff steps to user state; webpack onboarding * add onboarding types * add onboarding user layout * do not track onboarding index.html * onboarding fix allocations; use onboarding step; client wip * fix immutable query data event state * admin wip * onboarding admin search * fix layout * show searchbar while table is loading * show onboarding admin in account dropdown * onboarding pagination * ensure state resets on all param changes * allow reset always * update admin list state w/ webhooks; individual button state handling * remove unused import * client linting * onboarding test * server side formatting * incorporate ds mydata setup in system access step * fix error * fix onboarding tests * fix tests * disconnect setupevent signal * fix minor onboarding css * fix admin list table height * add orderby dropdown * add redirects for onboarding incomplete * fix redirect * fix dashboard notif message * fix project membership rt settings * fix pagination; fix rt tag * style staffwait actions * update event modal log when events are processed * fix project membership step rt queue * fix links in rt tickets * fix user admin action layout --- .gitignore | 1 + client/modules/_hooks/src/index.ts | 1 + client/modules/_hooks/src/onboarding/index.ts | 2 + client/modules/_hooks/src/onboarding/types.ts | 51 +++ .../_hooks/src/onboarding/useOnboarding.ts | 113 +++++ .../_hooks/src/useAuthenticatedUser.ts | 2 + .../onboarding/onboarding-admin-listing.json | 220 +++++++++ client/modules/_test-fixtures/src/index.ts | 1 + client/modules/onboarding/.babelrc | 12 + client/modules/onboarding/.eslintrc.json | 18 + client/modules/onboarding/README.md | 7 + client/modules/onboarding/project.json | 20 + .../OnboardingActions.module.css | 21 + .../OnboardingActions/OnboardingActions.tsx | 132 ++++++ .../OnboardingAdminSearchbar.module.css | 62 +++ .../OnboardingAdminSearchbar.tsx | 57 +++ .../OnboardingEventLogModal.module.css | 22 + .../OnboardingEventLogModal.tsx | 32 ++ .../OnboardingStatus.module.css | 12 + .../OnboardingStatus.spec.tsx | 99 +++++ .../src/OnboardingStatus/OnboardingStatus.tsx | 68 +++ .../OnboardingStep/OnboardingStep.module.css | 23 + .../src/OnboardingStep/OnboardingStep.tsx | 22 + client/modules/onboarding/src/index.ts | 4 + client/modules/onboarding/src/vitest.setup.ts | 6 + client/modules/onboarding/tsconfig.json | 20 + client/modules/onboarding/tsconfig.lib.json | 23 + client/modules/onboarding/tsconfig.spec.json | 26 ++ client/modules/onboarding/vite.config.ts | 28 ++ client/modules/workspace/src/utils/index.ts | 1 + .../datafiles/layouts/DataFilesBaseLayout.tsx | 5 + client/src/main.tsx | 15 + .../layouts/OnboardingAdminLayout.module.css | 248 +++++++++++ .../layouts/OnboardingAdminLayout.tsx | 418 ++++++++++++++++++ .../layouts/OnboardingBaseLayout.tsx | 26 ++ .../layouts/OnboardingUserLayout.module.css | 10 + .../layouts/OnboardingUserLayout.tsx | 56 +++ .../layouts/OnboardingWebsocketHandler.tsx | 75 ++++ client/src/onboarding/onboardingRouter.tsx | 49 ++ .../workspace/layouts/WorkspaceBaseLayout.tsx | 6 + client/tsconfig.base.json | 1 + .../0022_designsafeprofile_setup_complete.py | 17 + designsafe/apps/accounts/models.py | 3 + designsafe/apps/accounts/views.py | 10 +- .../apps/api/notifications/receivers.py | 29 +- .../operations/project_system_operations.py | 3 - designsafe/apps/api/systems/utils.py | 2 +- designsafe/apps/api/systems/views.py | 2 +- .../users}/tas_to_tacc_resources.json | 7 +- .../{workspace/api => api/users}/tasks.py | 6 +- designsafe/apps/api/users/utils.py | 89 ++++ designsafe/apps/api/users/views.py | 100 +++-- designsafe/apps/auth/backends_unit_test.py | 14 +- designsafe/apps/auth/tasks.py | 104 ----- designsafe/apps/auth/test_tasks.py | 129 ------ designsafe/apps/auth/urls.py | 4 +- designsafe/apps/auth/views.py | 36 +- designsafe/apps/auth/views_unit_test.py | 42 +- .../apps/djangoRT/fixtures/ticket_detail.txt | 3 +- designsafe/apps/djangoRT/fixtures/users.json | 38 -- designsafe/apps/djangoRT/tests.py | 266 +++-------- designsafe/apps/onboarding/__init__.py | 0 designsafe/apps/onboarding/api/__init__.py | 0 designsafe/apps/onboarding/api/urls.py | 12 + designsafe/apps/onboarding/api/views.py | 323 ++++++++++++++ .../apps/onboarding/api/views_unit_test.py | 323 ++++++++++++++ designsafe/apps/onboarding/apps.py | 9 + designsafe/apps/onboarding/conftest.py | 46 ++ designsafe/apps/onboarding/execute.py | 130 ++++++ .../apps/onboarding/execute_unit_test.py | 374 ++++++++++++++++ .../onboarding/migrations/0001_initial.py | 43 ++ .../apps/onboarding/migrations/__init__.py | 0 designsafe/apps/onboarding/models.py | 57 +++ .../apps/onboarding/models_unit_test.py | 39 ++ designsafe/apps/onboarding/state.py | 40 ++ designsafe/apps/onboarding/steps/__init__.py | 0 designsafe/apps/onboarding/steps/abstract.py | 139 ++++++ .../onboarding/steps/abstract_unit_test.py | 88 ++++ designsafe/apps/onboarding/steps/access.py | 54 +++ .../apps/onboarding/steps/access_unit_test.py | 60 +++ .../apps/onboarding/steps/allocation.py | 35 ++ .../onboarding/steps/allocation_unit_test.py | 58 +++ .../onboarding/steps/project_membership.py | 217 +++++++++ .../steps/project_membership_unit_test.py | 196 ++++++++ .../apps/onboarding/steps/system_access.py | 57 +++ .../steps/system_access_unit_test.py | 49 ++ .../apps/onboarding/steps/system_access_v3.py | 179 ++++++++ .../apps/onboarding/steps/test_steps.py | 160 +++++++ .../designsafe/apps/onboarding/index.j2 | 24 + designsafe/apps/onboarding/urls.py | 10 + designsafe/apps/onboarding/views.py | 18 + designsafe/apps/workspace/api/views.py | 91 +--- designsafe/conftest.py | 31 +- .../fixtures/tas/tas_add_user_to_project.json | 68 +++ .../tas/tas_add_user_to_project_error.json | 5 + .../tas/tas_delete_user_from_project.json | 68 +++ .../tas_delete_user_from_project_error.json | 5 + designsafe/fixtures/tas/tas_project.json | 64 +++ .../fixtures/tas/tas_project_users.json | 26 ++ designsafe/fixtures/tas/tas_user.json | 23 + .../tas/tas_user_with_underscore.json | 23 + designsafe/fixtures/user-data.json | 8 + designsafe/settings/celery_settings.py.orig | 48 -- designsafe/settings/common_settings.py | 30 +- designsafe/settings/test_settings.py | 45 +- .../dashboard/dashboard.component.html | 2 +- .../providers/notifications-provider.js | 2 +- designsafe/templates/base.j2 | 4 +- designsafe/templates/includes/header.html | 3 + designsafe/urls.py | 6 + designsafe/utils/system_access.py | 45 -- webpack.config.js | 9 + 112 files changed, 5496 insertions(+), 769 deletions(-) create mode 100644 client/modules/_hooks/src/onboarding/index.ts create mode 100644 client/modules/_hooks/src/onboarding/types.ts create mode 100644 client/modules/_hooks/src/onboarding/useOnboarding.ts create mode 100644 client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json create mode 100644 client/modules/onboarding/.babelrc create mode 100644 client/modules/onboarding/.eslintrc.json create mode 100644 client/modules/onboarding/README.md create mode 100644 client/modules/onboarding/project.json create mode 100644 client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css create mode 100644 client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx create mode 100644 client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css create mode 100644 client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx create mode 100644 client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css create mode 100644 client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx create mode 100644 client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css create mode 100644 client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx create mode 100644 client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx create mode 100644 client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css create mode 100644 client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx create mode 100644 client/modules/onboarding/src/index.ts create mode 100644 client/modules/onboarding/src/vitest.setup.ts create mode 100644 client/modules/onboarding/tsconfig.json create mode 100644 client/modules/onboarding/tsconfig.lib.json create mode 100644 client/modules/onboarding/tsconfig.spec.json create mode 100644 client/modules/onboarding/vite.config.ts create mode 100644 client/src/onboarding/layouts/OnboardingAdminLayout.module.css create mode 100644 client/src/onboarding/layouts/OnboardingAdminLayout.tsx create mode 100644 client/src/onboarding/layouts/OnboardingBaseLayout.tsx create mode 100644 client/src/onboarding/layouts/OnboardingUserLayout.module.css create mode 100644 client/src/onboarding/layouts/OnboardingUserLayout.tsx create mode 100644 client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx create mode 100644 client/src/onboarding/onboardingRouter.tsx create mode 100644 designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py rename designsafe/apps/{workspace/api => api/users}/tas_to_tacc_resources.json (94%) rename designsafe/apps/{workspace/api => api/users}/tasks.py (73%) delete mode 100644 designsafe/apps/djangoRT/fixtures/users.json create mode 100644 designsafe/apps/onboarding/__init__.py create mode 100644 designsafe/apps/onboarding/api/__init__.py create mode 100644 designsafe/apps/onboarding/api/urls.py create mode 100644 designsafe/apps/onboarding/api/views.py create mode 100644 designsafe/apps/onboarding/api/views_unit_test.py create mode 100644 designsafe/apps/onboarding/apps.py create mode 100644 designsafe/apps/onboarding/conftest.py create mode 100644 designsafe/apps/onboarding/execute.py create mode 100644 designsafe/apps/onboarding/execute_unit_test.py create mode 100644 designsafe/apps/onboarding/migrations/0001_initial.py create mode 100644 designsafe/apps/onboarding/migrations/__init__.py create mode 100644 designsafe/apps/onboarding/models.py create mode 100644 designsafe/apps/onboarding/models_unit_test.py create mode 100644 designsafe/apps/onboarding/state.py create mode 100644 designsafe/apps/onboarding/steps/__init__.py create mode 100644 designsafe/apps/onboarding/steps/abstract.py create mode 100644 designsafe/apps/onboarding/steps/abstract_unit_test.py create mode 100644 designsafe/apps/onboarding/steps/access.py create mode 100644 designsafe/apps/onboarding/steps/access_unit_test.py create mode 100644 designsafe/apps/onboarding/steps/allocation.py create mode 100644 designsafe/apps/onboarding/steps/allocation_unit_test.py create mode 100644 designsafe/apps/onboarding/steps/project_membership.py create mode 100644 designsafe/apps/onboarding/steps/project_membership_unit_test.py create mode 100644 designsafe/apps/onboarding/steps/system_access.py create mode 100644 designsafe/apps/onboarding/steps/system_access_unit_test.py create mode 100644 designsafe/apps/onboarding/steps/system_access_v3.py create mode 100644 designsafe/apps/onboarding/steps/test_steps.py create mode 100644 designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.j2 create mode 100644 designsafe/apps/onboarding/urls.py create mode 100644 designsafe/apps/onboarding/views.py create mode 100644 designsafe/fixtures/tas/tas_add_user_to_project.json create mode 100644 designsafe/fixtures/tas/tas_add_user_to_project_error.json create mode 100644 designsafe/fixtures/tas/tas_delete_user_from_project.json create mode 100644 designsafe/fixtures/tas/tas_delete_user_from_project_error.json create mode 100644 designsafe/fixtures/tas/tas_project.json create mode 100644 designsafe/fixtures/tas/tas_project_users.json create mode 100644 designsafe/fixtures/tas/tas_user.json create mode 100644 designsafe/fixtures/tas/tas_user_with_underscore.json delete mode 100644 designsafe/settings/celery_settings.py.orig delete mode 100644 designsafe/utils/system_access.py diff --git a/.gitignore b/.gitignore index bcd4a23c88..8695a3d148 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ designsafe/apps/dashboard/templates/designsafe/apps/dashboard/index.html designsafe/apps/notifications/templates/designsafe/apps/notifications/index.html designsafe/apps/nco/templates/designsafe/apps/nco/nco_index.html designsafe/apps/nco/templates/designsafe/apps/nco/ttc_grants.html +designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.html # local config files conf/elasticsearch/logging.yml diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 9cc3682b42..d67631ac08 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -5,3 +5,4 @@ export * from './workspace'; export * from './datafiles'; export * from './systems'; export * from './notifications'; +export * from './onboarding'; diff --git a/client/modules/_hooks/src/onboarding/index.ts b/client/modules/_hooks/src/onboarding/index.ts new file mode 100644 index 0000000000..1d5717cfd4 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/index.ts @@ -0,0 +1,2 @@ +export * from './useOnboarding'; +export * from './types'; diff --git a/client/modules/_hooks/src/onboarding/types.ts b/client/modules/_hooks/src/onboarding/types.ts new file mode 100644 index 0000000000..ba32bd3199 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/types.ts @@ -0,0 +1,51 @@ +export type TSetupStepEvent = { + step: string; + username: string; + state: string; + time: string; + message: string; + data?: { + setupComplete: boolean; + } | null; +}; + +export type TOnboardingStep = { + step: string; + displayName: string; + description: string; + userConfirm: string; + staffApprove: string; + staffDeny: string; + state?: string | null; + events: TSetupStepEvent[]; + data?: { + userlink?: { + url: string; + text: string; + }; + } | null; + customStatus?: string | null; +}; + +export type TOnboardingUser = { + username: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; + setupComplete: boolean; + steps: TOnboardingStep[]; +}; + +export type TOnboardingAdminList = { + users: TOnboardingUser[]; + total: number; + totalSteps: number; +}; + +export type TOnboardingAdminActions = + | 'staff_approve' + | 'staff_deny' + | 'user_confirm' + | 'complete' + | 'reset'; diff --git a/client/modules/_hooks/src/onboarding/useOnboarding.ts b/client/modules/_hooks/src/onboarding/useOnboarding.ts new file mode 100644 index 0000000000..29373547f8 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/useOnboarding.ts @@ -0,0 +1,113 @@ +import { useQuery, useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import { + TOnboardingUser, + TOnboardingAdminList, + TSetupStepEvent, + TOnboardingAdminActions, +} from './types'; +import apiClient, { type TApiError } from '../apiClient'; + +export type TOnboardingAdminParams = { + showIncompleteOnly?: boolean; + q?: string; + limit?: number; + page?: number; + orderBy?: string; +}; + +type TOnboardingActionBody = { + step: string; + action: TOnboardingAdminActions; +}; + +type TGetOnboardingAdminListResponse = { + response: TOnboardingAdminList; + status: number; +}; + +type TGetOnboardingUserResponse = { + response: TOnboardingUser; + status: number; +}; + +type TSendOnboardingActionResponse = { + response: TSetupStepEvent; + status: number; +}; + +async function getOnboardingAdminList(params: TOnboardingAdminParams) { + const res = await apiClient.get( + `api/onboarding/admin/`, + { + params, + } + ); + return res.data.response; +} + +async function getOnboardingUser(username: string) { + const res = await apiClient.get( + `api/onboarding/user/${username}` + ); + return res.data.response; +} + +async function sendOnboardingAction( + body: TOnboardingActionBody, + username?: string +) { + const res = await apiClient.post( + `api/onboarding/user/${username}/`, + body + ); + return res.data.response; +} + +const getOnboardingAdminListQuery = (queryParams: TOnboardingAdminParams) => ({ + queryKey: ['onboarding', 'adminList', queryParams], + queryFn: () => getOnboardingAdminList(queryParams), +}); +export function useGetOnboardingAdminList() { + const [searchParams] = useSearchParams(); + const q = searchParams.get('q') || undefined; + const showIncompleteOnly = searchParams.get('showIncompleteOnly') || 'false'; + const limit = searchParams.get('limit') || '20'; + const page = searchParams.get('page') || '1'; + const orderBy = searchParams.get('orderBy') || undefined; + return useQuery( + getOnboardingAdminListQuery({ + q, + showIncompleteOnly: showIncompleteOnly === 'true', + limit: +limit, + page: +page, + orderBy, + }) + ); +} + +const getOnboardingUserQuery = (username: string) => ({ + queryKey: ['onboarding', 'user', username], + queryFn: () => getOnboardingUser(username), +}); +export function useGetOnboardingUser(username: string) { + return useQuery(getOnboardingUserQuery(username)); +} +export const useGetOnboardingUserSuspense = (username: string) => { + return useSuspenseQuery(getOnboardingUserQuery(username)); +}; + +export function useSendOnboardingAction() { + return useMutation({ + mutationFn: ({ + body, + username, + }: { + body: TOnboardingActionBody; + username: string; + }) => { + return sendOnboardingAction(body, username); + }, + onError: (err: TApiError) => err, + }); +} diff --git a/client/modules/_hooks/src/useAuthenticatedUser.ts b/client/modules/_hooks/src/useAuthenticatedUser.ts index 794e20176a..88cd0e307d 100644 --- a/client/modules/_hooks/src/useAuthenticatedUser.ts +++ b/client/modules/_hooks/src/useAuthenticatedUser.ts @@ -5,6 +5,8 @@ export type TUser = { email: string; institution: string; homedir: string; + isStaff: boolean; + setupComplete: boolean; }; declare global { diff --git a/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json b/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json new file mode 100644 index 0000000000..de44f662ce --- /dev/null +++ b/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json @@ -0,0 +1,220 @@ +{ + "status": 200, + "response": { + "users": [ + { + "username": "testuser2", + "lastName": "last2", + "firstName": "first2", + "email": "first2last2@university.edu", + "isStaff": false, + "steps": [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "displayName": "Checking Project Membership", + "description": "This confirms if you have access to the project. If not, request access and\n wait for the system administrator’s approval.", + "userConfirm": "Request Project Access", + "staffApprove": "Add to DesignSafe project", + "staffDeny": "Deny Project Access Request", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:52.769105+00:00", + "message": "You have the required project membership to access this portal.", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:51.250723+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:10:27.244633+00:00", + "message": "Awaiting project membership check", + "data": null + } + ], + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "displayName": "Allocations", + "description": "Accessing your allocations. If unsuccessful, verify the PI has added you to the allocations for this project.", + "userConfirm": "Confirm", + "staffApprove": "Approve", + "staffDeny": "Deny", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:45.804477+00:00", + "message": "Allocations retrieved", + "data": { + "hosts": { + "ls6.tacc.utexas.edu": [ + "TACC-ACI", + "APCD-dev", + "PT2050-DataX", + "DesignSafe-Community", + "IBN22007", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "DS-HPC1", + "A2CPS" + ], + "data.tacc.utexas.edu": [ + "NeuroNex-3DEM", + "TACC-ACI", + "DesignSafe-Corral" + ], + "ranch.tacc.utexas.edu": ["DesignSafe-Community"], + "vista.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Corral", + "TACC-ACI-CIC" + ], + "frontera.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Community", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "A2CPS", + "DS-HPC1" + ], + "stampede3.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Community", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "DS-HPC1" + ] + } + } + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:43.466479+00:00", + "message": "Retrieving your allocations", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:43.355484+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:36:43.156603+00:00", + "message": "Awaiting allocation retrieval", + "data": null + } + ], + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "displayName": "System Access", + "description": "Setting up access to TACC storage and execution systems. No action required.", + "userConfirm": "Confirm", + "staffApprove": "Approve", + "staffDeny": "Deny", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:50.968677+00:00", + "message": "User is processed.", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:50.851077+00:00", + "message": "Credentials already created for system: cloud.data", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:47.644630+00:00", + "message": "Successfully granted permissions for system: frontera", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:47.065659+00:00", + "message": "Successfully granted permissions for system: stampede3", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:46.605071+00:00", + "message": "Successfully granted permissions for system: cloud.data", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:45.956481+00:00", + "message": "Processing system access for user sal", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:45.883764+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:10:26.862693+00:00", + "message": "Awaiting TACC systems access.", + "data": null + } + ], + "data": null + } + ], + "setupComplete": true + } + ], + "offset": 0, + "limit": 4, + "total": 400, + "totalSteps": 3 + } +} diff --git a/client/modules/_test-fixtures/src/index.ts b/client/modules/_test-fixtures/src/index.ts index b0fb7efd96..a55a8c75d4 100644 --- a/client/modules/_test-fixtures/src/index.ts +++ b/client/modules/_test-fixtures/src/index.ts @@ -1,3 +1,4 @@ export * from './server'; export * from './render'; export { default as appsListingJson } from './fixtures/workspace/apps-tray-listing.json'; +export { default as onboardingAdminListingJson } from './fixtures/onboarding/onboarding-admin-listing.json'; diff --git a/client/modules/onboarding/.babelrc b/client/modules/onboarding/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/client/modules/onboarding/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/client/modules/onboarding/.eslintrc.json b/client/modules/onboarding/.eslintrc.json new file mode 100644 index 0000000000..3ebb9c6f3a --- /dev/null +++ b/client/modules/onboarding/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/client/modules/onboarding/README.md b/client/modules/onboarding/README.md new file mode 100644 index 0000000000..459dd2f5b2 --- /dev/null +++ b/client/modules/onboarding/README.md @@ -0,0 +1,7 @@ +# onboarding + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test onboarding` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/client/modules/onboarding/project.json b/client/modules/onboarding/project.json new file mode 100644 index 0000000000..261f1ba5a4 --- /dev/null +++ b/client/modules/onboarding/project.json @@ -0,0 +1,20 @@ +{ + "name": "onboarding", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "modules/onboarding/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../coverage/modules/onboarding" + } + } + } +} diff --git a/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css new file mode 100644 index 0000000000..d83946f5ae --- /dev/null +++ b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css @@ -0,0 +1,21 @@ +.root { + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; +} + +.action { + padding-bottom: 0; + padding-top: 0; + font-weight: bold; +} +.onboarding-action__loading { + display: inline-block; + width: auto; +} + +.onboarding-action__loading .inline { + width: 16px; + height: 16px; +} diff --git a/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx new file mode 100644 index 0000000000..dae8bcf51b --- /dev/null +++ b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { Alert, Spin } from 'antd'; +import { SecondaryButton } from '@client/common-components'; +import { + TOnboardingStep, + useAuthenticatedUser, + useSendOnboardingAction, +} from '@client/hooks'; +import styles from './OnboardingActions.module.css'; + +const OnboardingActions = ({ step }: { step: TOnboardingStep }) => { + const { user: authenticatedUser } = useAuthenticatedUser(); + const params = useParams(); + const { + mutate: sendOnboardingAction, + isPending, + error, + } = useSendOnboardingAction(); + + // If the route loaded shows we are viewing a different user + // (such as an admin viewing a user) then pull the username for + // actions from the route. Otherwise, use the username of whomever is logged in + const username = params.username || (authenticatedUser?.username as string); + + if (error) { + return ( + + ); + } + + return ( + <> + {isPending ? : null} + + {authenticatedUser?.isStaff && step.state === 'staffwait' ? ( + + + sendOnboardingAction({ + body: { action: 'staff_approve', step: step.step }, + username, + }) + } + > + {step.staffApprove} + +     + + sendOnboardingAction({ + body: { action: 'staff_deny', step: step.step }, + username, + }) + } + > + {step.staffDeny} + + + ) : null} + {step.state === 'userwait' ? ( + step.data?.userlink ? ( + + {step.data?.userlink?.text} + + ) : ( + + sendOnboardingAction({ + body: { action: 'user_confirm', step: step.step }, + username, + }) + } + > + {step.userConfirm} + + ) + ) : null} + {authenticatedUser?.isStaff ? ( + + + sendOnboardingAction({ + body: { action: 'reset', step: step.step }, + username, + }) + } + > + Admin Reset + +     + + sendOnboardingAction({ + body: { action: 'complete', step: step.step }, + username, + }) + } + > + Admin Skip + + + ) : null} + + + ); +}; + +export default OnboardingActions; diff --git a/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css new file mode 100644 index 0000000000..72a68f9397 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css @@ -0,0 +1,62 @@ +.container { + display: flex; + flex-direction: row; + align-items: center; +} + +/* Children */ + +/* Each direct child except the last */ +.container > *:not(:last-child) { + margin-right: 20px; /* 20px design * 1.2 design-to-app ratio */ +} +.query-fieldset { + width: 560px; /* 70px + 396px design * 1.2 design-to-app ratio */ +} +/* Ensure `.clear-button` text does not wrap at (arbitrary) 1280px laptop width */ +/* WARN: Non-standard un-documented first-party breakpoint */ +@media (max-width: 1700px) { + .query-fieldset { + width: 360px; + } +} + +@media (max-width: 768px) { + .query-fieldset { + width: 260px; + } +} +/* FP-563: Support count in status message */ +.summary-fieldset { + /* No styles necessary, but defining class for consistency */ +} +/* NOTE: Whenever filter and/or status message are restored, this selector must select the rightmost element of those */ +.clear-button { + /* .filter-fieldset { */ + margin-left: auto; /* this is how to "justify-self" on flex children */ +} +.clear-button { + /* composes: c-button--as-link from '../../styles/components/c-button.css'; */ + + /* RFC: This style might be best provided from an external yet-to-be-created class for table-top nav links */ + font-weight: bold; +} + +/* Children (of `-fieldset`) */ + +.input { + /* composes: form-control from '../../styles/components/bootstrap.form.css'; */ +} +.output { + /* … */ +} + +/* Hacks */ + +.container, +.submit-button, +.clear-button, +.input { + /* RFE: This style should be inherited from cascade of global styles */ + font-size: 0.75rem; /* 12px (16px design * 1.2 design-to-app ratio) */ +} diff --git a/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx new file mode 100644 index 0000000000..7f74e7a920 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { SearchOutlined } from '@ant-design/icons'; +import { Form, Input } from 'antd'; +import { useSearchParams } from 'react-router-dom'; +import { SecondaryButton } from '@client/common-components'; +// import styles from './OnboardingAdminSearchbar.module.css'; + +export const OnboardingAdminSearchbar: React.FC<{ disabled: boolean }> = ({ + disabled, +}) => { + const [form] = Form.useForm(); + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(searchParams.get('q')); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + newSearchParams.delete('page'); + + setSearchParams(newSearchParams); + }; + + useEffect(() => {}, [searchParams, query]); + + return ( +
onSubmit(data.query)} + form={form} + name="onboarding_search" + style={{ display: 'inline-flex' }} + disabled={disabled} + > + + + + } + > + { + form.resetFields(); + setQuery(null); + searchParams.set('page', '1'); + searchParams.delete('q'); + setSearchParams(searchParams); + }} + > + Clear Search + +
+ ); +}; diff --git a/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css new file mode 100644 index 0000000000..958f10d5b9 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css @@ -0,0 +1,22 @@ +.event-list > div { + padding: 1em; + margin-left: 1em; + margin-right: 1em; +} + +.log-detail { + margin-left: 1em; +} + +.event-list { + max-height: 30em; + overflow-y: scroll; +} + +.event-list > div:nth-child(even) { + background-color: var(--global-color-primary--x-light); +} + +.event-list > div:not(:last-child) { + border-bottom: 1px solid var(--global-color-primary--dark); +} diff --git a/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx new file mode 100644 index 0000000000..f7d300a673 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Modal } from 'antd'; +import { formatDateTime } from '@client/workspace'; +import { TOnboardingStep, TOnboardingUser } from '@client/hooks'; +import styles from './OnboardingEventLogModal.module.css'; + +export const OnboardingEventLogModal: React.FC<{ + params: { + user: TOnboardingUser; + step: TOnboardingStep; + }; + handleCancel: () => void; +}> = ({ params: { user, step }, handleCancel }) => { + return ( + +
+ {step.events.map((event) => ( +
+
{formatDateTime(new Date(event.time))}
+
{event.message}
+
+ ))} +
+
+ ); +}; diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css new file mode 100644 index 0000000000..2dfd35420c --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css @@ -0,0 +1,12 @@ +.root { + overflow: hidden; +} + +.processing { + display: flex; + align-items: center; +} + +.processing > * { + margin-right: 0.5em; +} diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx new file mode 100644 index 0000000000..22321ac2b1 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx @@ -0,0 +1,99 @@ +import { render } from '@client/test-fixtures'; +import { OnboardingStatus } from './OnboardingStatus'; +import { TOnboardingStep } from '@client/hooks'; +import { describe, it, expect } from 'vitest'; + +describe('OnboardingStatus Component', () => { + const renderComponent = (step: TOnboardingStep) => + render(); + + it('should render Preparing tag for pending state', () => { + const step = { state: 'pending' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Preparing')).toBeTruthy(); + }); + + it('should render Waiting for Staff Approval tag for staffwait state', () => { + const step = { state: 'staffwait' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Waiting for Staff Approval')).toBeTruthy(); + }); + + it('should render Waiting for User tag for userwait state', () => { + const step = { state: 'userwait' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Waiting for User')).toBeTruthy(); + }); + + it('should render Unsuccessful tag for failed state', () => { + const step = { state: 'failed' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unsuccessful')).toBeTruthy(); + }); + + it('should render Unsuccessful tag for error state', () => { + const step = { state: 'error' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unsuccessful')).toBeTruthy(); + }); + + it('should render Unavailable tag for null state', () => { + const step = { state: null } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unavailable')).toBeTruthy(); + }); + + it('should render Completed tag for completed state', () => { + const step = { state: 'completed' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Completed')).toBeTruthy(); + }); + + it('should render Processing tag and Spin for processing state', () => { + const step = { state: 'processing' } as TOnboardingStep; + const { getByText, container } = renderComponent(step); + expect(getByText('Processing')).toBeTruthy(); + expect(container.querySelector('.ant-spin')).toBeTruthy(); + }); + + it('should render custom status if customStatus is present', () => { + const step = { + state: 'pending', + customStatus: 'Custom Status', + } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Custom Status')).toBeTruthy(); + }); + + it('should render default tag for unknown state', () => { + const step = { state: 'unknown' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('unknown')).toBeTruthy(); + }); + + it('should render null if no state is provided', () => { + const step = {} as TOnboardingStep; + const { container } = renderComponent(step); + expect(container.firstChild).toBeNull(); + }); + + it('should render correct color for each state', () => { + const states = [ + { state: 'pending', color: 'blue' }, + { state: 'staffwait', color: 'blue' }, + { state: 'userwait', color: 'gold' }, + { state: 'failed', color: 'red' }, + { state: 'error', color: 'red' }, + { state: null, color: 'volcano' }, + { state: 'completed', color: 'green' }, + { state: 'processing', color: 'blue' }, + ]; + + states.forEach(({ state, color }) => { + const step = { state } as TOnboardingStep; + const { container } = renderComponent(step); + const tag = container.querySelector('.ant-tag'); + expect(Object.values(tag?.classList || {})).toContain(`ant-tag-${color}`); + }); + }); +}); diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx new file mode 100644 index 0000000000..96b5fd0c20 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Tag, Spin } from 'antd'; +import { TOnboardingStep } from '@client/hooks'; +import styles from './OnboardingStatus.module.css'; + +const getContents = (step: TOnboardingStep) => { + let color = ''; + switch (step.state) { + case 'processing': + case 'pending': + color = 'blue'; + break; + case 'failed': + case 'error': + color = 'red'; + break; + case 'staffwait': + case 'userwait': + color = 'gold'; + break; + case 'completed': + color = 'green'; + break; + case null: + color = 'volcano'; + break; + default: + color = 'blue'; + } + if ('customStatus' in step) { + return {step.customStatus}; + } + switch (step.state) { + case 'pending': + return Preparing; + case 'staffwait': + return Waiting for Staff Approval; + case 'userwait': + return Waiting for User; + case 'failed': + case 'error': + return Unsuccessful; + case null: + return Unavailable; + case 'completed': + return Completed; + case 'processing': + return ( + + Processing + + + ); + default: + if (step.state) { + return {step.state}; + } + return null; + } +}; + +export const OnboardingStatus = ({ step }: { step: TOnboardingStep }) => { + const contents = getContents(step); + if (!contents) { + return null; + } + return {getContents(step)}; +}; diff --git a/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css new file mode 100644 index 0000000000..a1d6277863 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css @@ -0,0 +1,23 @@ +.root { + padding-top: 1em; + padding-bottom: 1em; + margin-bottom: 1em; + border-bottom: 1px solid var(--global-color-primary--normal); +} + +.name { + font-weight: bold; +} + +.description { + padding-bottom: 1em; +} + +.status { + display: flex; + align-items: center; +} + +.disabled { + color: var(--global-color-primary--light); +} diff --git a/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx new file mode 100644 index 0000000000..5353ee6e99 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import parse from 'html-react-parser'; +import { OnboardingStatus } from '../OnboardingStatus/OnboardingStatus'; +import OnboardingActions from '../OnboardingActions/OnboardingActions'; +import { TOnboardingStep } from '@client/hooks'; +import styles from './OnboardingStep.module.css'; + +export const OnboardingStep = ({ step }: { step: TOnboardingStep }) => { + const styleName = `${styles.root} ${ + step.state === styles.pending ? 'disabled' : '' + }`; + return ( +
+
{step.displayName}
+
{parse(step.description)}
+
+ + +
+
+ ); +}; diff --git a/client/modules/onboarding/src/index.ts b/client/modules/onboarding/src/index.ts new file mode 100644 index 0000000000..2bfb5de63d --- /dev/null +++ b/client/modules/onboarding/src/index.ts @@ -0,0 +1,4 @@ +export * from './OnboardingStep/OnboardingStep'; +export * from './OnboardingStatus/OnboardingStatus'; +export * from './OnboardingEventLogModal/OnboardingEventLogModal'; +export * from './OnboardingAdminSearchbar/OnboardingAdminSearchbar'; diff --git a/client/modules/onboarding/src/vitest.setup.ts b/client/modules/onboarding/src/vitest.setup.ts new file mode 100644 index 0000000000..5e646e1a35 --- /dev/null +++ b/client/modules/onboarding/src/vitest.setup.ts @@ -0,0 +1,6 @@ +import { beforeAll, afterEach, afterAll } from 'vitest'; +import { server } from '@client/test-fixtures'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/client/modules/onboarding/tsconfig.json b/client/modules/onboarding/tsconfig.json new file mode 100644 index 0000000000..424447ef63 --- /dev/null +++ b/client/modules/onboarding/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.json" +} diff --git a/client/modules/onboarding/tsconfig.lib.json b/client/modules/onboarding/tsconfig.lib.json new file mode 100644 index 0000000000..a6ed0a0c2b --- /dev/null +++ b/client/modules/onboarding/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/client/modules/onboarding/tsconfig.spec.json b/client/modules/onboarding/tsconfig.spec.json new file mode 100644 index 0000000000..3c002c215a --- /dev/null +++ b/client/modules/onboarding/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/client/modules/onboarding/vite.config.ts b/client/modules/onboarding/vite.config.ts new file mode 100644 index 0000000000..9f808f7aa0 --- /dev/null +++ b/client/modules/onboarding/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/modules/onboarding', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + setupFiles: ['./src/vitest.setup.ts'], + coverage: { + reportsDirectory: '../../coverage/modules/onboarding', + provider: 'v8', + }, + }, +}); diff --git a/client/modules/workspace/src/utils/index.ts b/client/modules/workspace/src/utils/index.ts index c882054cfb..f859596cb7 100644 --- a/client/modules/workspace/src/utils/index.ts +++ b/client/modules/workspace/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './systems'; export * from './apps'; export * from './truncateMiddle'; export * from './notifications'; +export * from './timeFormat'; diff --git a/client/src/datafiles/layouts/DataFilesBaseLayout.tsx b/client/src/datafiles/layouts/DataFilesBaseLayout.tsx index e2ba4c4c71..50fac4cb95 100644 --- a/client/src/datafiles/layouts/DataFilesBaseLayout.tsx +++ b/client/src/datafiles/layouts/DataFilesBaseLayout.tsx @@ -12,6 +12,11 @@ const { Sider } = Layout; const DataFilesRoot: React.FC = () => { const { user } = useAuthenticatedUser(); + + if (user && !user.setupComplete) { + window.location.replace(`${window.location.origin}/onboarding/setup`); + } + const defaultPath = user?.username ? '/tapis/designsafe.storage.default' : '/public/designsafe.storage.published'; diff --git a/client/src/main.tsx b/client/src/main.tsx index ecba772410..3aa5fc4f8e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,6 +5,7 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import workspaceRouter from './workspace/workspaceRouter'; import datafilesRouter from './datafiles/datafilesRouter'; +import onboardingRouter from './onboarding/onboardingRouter'; import { ConfigProvider, ThemeConfig } from 'antd'; const queryClient = new QueryClient(); @@ -73,3 +74,17 @@ if (datafilesElement) { ); } + +const onboardingElement = document.getElementById('onboarding-root'); +if (onboardingElement) { + const onboardingRoot = ReactDOM.createRoot(onboardingElement as HTMLElement); + onboardingRoot.render( + + + + + + + + ); +} diff --git a/client/src/onboarding/layouts/OnboardingAdminLayout.module.css b/client/src/onboarding/layouts/OnboardingAdminLayout.module.css new file mode 100644 index 0000000000..e8944ba27a --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingAdminLayout.module.css @@ -0,0 +1,248 @@ +.truncate-with-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Required to constrain table within flex box */ +.root { + position: relative; +} + +.container { + display: flex; + flex-direction: column; + padding: 20px 40px 20px 20px; + position: absolute; + max-height: 100%; + width: 100%; + height: 100%; +} + +.container-header { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid #707070; + padding-bottom: 8px; + margin-bottom: 1.5em; + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + } +} + +.search-checkbox-container { + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: 990px) { + flex-direction: column; + align-items: flex-start; + } +} + +.checkbox-label-container { + display: flex; + align-items: center; + font-size: 14px; + margin: 0 0 0 20px; + + @media (max-width: 768px) { + margin-left: 0; + margin-top: 20px; + } + + @media (max-width: 991px) { + margin-left: 0; + margin-top: 10px; + } +} + +.label { + font-weight: bold; + margin: 0 0 0 10px; +} +.paginator-container { + width: 100%; + display: flex; + justify-content: center; + margin-top: 1em; +} + +.user-container { + flex-grow: 0; + overflow-y: scroll; + position: relative; +} + +.users { + --cell-horizontal-padding: 0.35em; /* horizontal cell padding for inter-column buffer */ + --cell-vertical-padding: 0.35em; + /* TODO: After, FP-103, use `composes:` not `@extend` */ + height: 100%; + align-items: stretch; + width: 100%; + overflow-y: scroll; + font-size: 14px; + + thead { + user-select: none; + color: var(--global-color-primary--x-dark); + border-bottom: 1px solid #707070; + + .-sort-asc, + .sort-desc { + color: var(--global-color-primary--xx-dark); + } + + /* Match horizontal padding of `td` elements in table to align properly */ + th { + padding: var(--cell-vertical-padding) var(--cell-horizontal-padding); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + td { + padding: var(--cell-vertical-padding) var(--cell-horizontal-padding); + } +} + +.user > td { + /* Difference between tall unwrapped cell height and basic text cell height */ + --greatest-height-diff: 4.78px; /* value obtained form live render */ + /* FAQ: We want only the top space, not the top & bottom space combined */ + --vertical-offset: calc(var(--greatest-height-diff) / 2); +} +.user > td:not(.has-wrappable-content) { + /* Tweak `vertical-align`'s `top` to look like `middle` when nothing wraps */ + padding-top: calc(var(--cell-horizontal-padding) + var(--vertical-offset)); + vertical-align: top; +} +.user > td:not(.has-wrappable-content):not(.status) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.user:nth-child(4n), +.user:nth-child(4n-1) > td:not(.staffwait) { + background-color: #f4f4f4; +} + +.username { + display: inline-block; /* needed to make margin-top work with span */ + margin-top: 5px; /* adjust value as needed to create more space between fullname and username */ + font-weight: bold; +} + +/* HACK: This selector has knowledge of sibling component's internal markup */ +/* FAQ: Because we cannot pass `className`to via */ +/* FAQ: Because has a superfluous root element */ +.status > span > span /* i.e. 's */ { + white-space: nowrap; +} + +.reset { + /* … */ +} + +.approve-container { + i { + font-size: 14px; + } + + --vertical-buffer: 0.125em; /* vertical space between wrapped buttons */ + + /* Add space above all buttons to match space below each */ + padding-top: var(--vertical-buffer); + /* Remove space above and bellow buttons */ + margin-top: calc(-1 * var(--vertical-buffer)); + margin-bottom: calc(-1 * var(--vertical-buffer)); +} + +.approve { + display: inline; /* Do not use `inline-block` because value was `flex` */ + /* Add space below each button */ + margin-bottom: var(--vertical-buffer); + + font-size: 14px; + border-radius: 0; + padding-top: 0; + padding-bottom: 0; +} + +.approve:not(:last-child) { + margin-right: 1em; +} + +.approve > *:nth-child(1) { + margin-right: 0.5em; +} + +.action-link { + padding: 0; + + font-size: 14px; +} +.action-link:not(:first-child) { + margin-left: 0.25em; +} +.action-link:not(:last-child) { + margin-right: 0.25em; +} + +.highlightCell:has(> *.staffwait) { + background-color: #e6f4ff; +} + +.users { + table-layout: fixed; + + col:nth-child(1) { + width: 12%; + } + col:nth-child(2) { + width: 14%; + } + col:nth-child(3) { + width: 27%; + } + th:nth-child(4) { + width: 33%; + } + col:nth-child(4) { + width: 18%; + } + col:nth-child(5) { + width: 15%; + } + col:nth-child(6) { + width: 14%; + } +} + +.no-users-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 90%; +} + +/* NOTE: Mimicked on: DataFiles, DataFilesProjectsList, DataFilesProjectFileListing */ +.root-placeholder { + flex-grow: 1; + + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-admin__action-spinner .inline { + width: 10px; + height: 10px; + font-size: 14px; +} diff --git a/client/src/onboarding/layouts/OnboardingAdminLayout.tsx b/client/src/onboarding/layouts/OnboardingAdminLayout.tsx new file mode 100644 index 0000000000..2abd2e1a23 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingAdminLayout.tsx @@ -0,0 +1,418 @@ +import React, { useState, useEffect } from 'react'; +import { + Alert, + Layout, + Checkbox, + Table, + TableColumnType, + Dropdown, + Space, + Typography, + Flex, +} from 'antd'; +import type { MenuProps } from 'antd'; +import { CheckOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; +import { useSearchParams } from 'react-router-dom'; +import { SecondaryButton, Spinner } from '@client/common-components'; +import { + OnboardingStatus, + OnboardingEventLogModal, + OnboardingAdminSearchbar, +} from '@client/onboarding'; +import styles from './OnboardingAdminLayout.module.css'; +import { + TOnboardingStep, + TOnboardingUser, + useSendOnboardingAction, + useGetOnboardingAdminList, + TOnboardingAdminList, +} from '@client/hooks'; + +const OnboardingApproveActions: React.FC<{ + step: TOnboardingStep; + username: string; +}> = ({ step, username }) => { + const { + mutate: sendOnboardingAction, + isPending, + variables, + } = useSendOnboardingAction(); + + return ( +
+ + sendOnboardingAction({ + body: { action: 'staff_approve', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'staff_approve'} + disabled={isPending && variables?.body.action === 'staff_deny'} + icon={} + > + Approve + + + sendOnboardingAction({ + body: { action: 'staff_deny', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'staff_deny'} + disabled={isPending && variables?.body.action === 'staff_approve'} + icon={} + > + Deny + +
+ ); +}; + +const OnboardingResetLinks: React.FC<{ + step: TOnboardingStep; + username: string; +}> = ({ step, username }) => { + const { + mutate: sendOnboardingAction, + isPending, + variables, + } = useSendOnboardingAction(); + + return ( +
+ + sendOnboardingAction({ + body: { action: 'reset', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'reset'} + > + Reset + + | + + sendOnboardingAction({ + body: { action: 'complete', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'complete'} + > + Skip + +
+ ); +}; + +const OnboardingAdminList: React.FC<{ + data: TOnboardingAdminList; + viewLogCallback: (user: TOnboardingUser, step: TOnboardingStep) => void; +}> = ({ data, viewLogCallback }) => { + const [searchParams, setSearchParams] = useSearchParams(); + + type TOnboardingAdminTableRowData = { + user: TOnboardingUser; + step: TOnboardingStep; + index: number; + }; + + const columns: TableColumnType[] = [ + { + title: 'User', + dataIndex: 'user', + className: styles.highlightCell, + render: (user: TOnboardingUser, record) => ( + + {`${user.firstName} ${user.lastName}`} +
+ {user.username} +
+ ), + onCell: (record) => ({ + rowSpan: record.index === 0 ? record.user.steps.length : 0, + }), + }, + { + title: 'Step', + dataIndex: 'step', + className: styles.highlightCell, + render: (step: TOnboardingStep) => ( + + {step.displayName} + + ), + }, + { + title: 'Status', + dataIndex: 'step', + key: 'status', + className: styles.highlightCell, + render: (step: TOnboardingStep) => ( + + + + ), + }, + { + title: 'Administrative Actions', + dataIndex: 'step', + key: 'actions', + className: styles.highlightCell, + render: (step: TOnboardingStep, record) => ( + + + {step.state === 'staffwait' && ( + + )} + + ), + }, + { + title: 'Log', + dataIndex: 'step', + key: 'log', + className: styles.highlightCell, + render: (step: TOnboardingStep, record) => ( + + viewLogCallback(record.user, step)} + > + View Log + + + ), + }, + ]; + + const { users, total, totalSteps } = data; + + const dataSource: TOnboardingAdminTableRowData[] = []; + users.forEach((user) => { + user.steps.forEach((step, index) => { + return dataSource.push({ user, step, index }); + }); + }); + + return ( + <> + + { + searchParams.set('page', page.toString()); + setSearchParams(searchParams); + }, + }} + sticky + /> + + ); +}; + +const OnboardingAdminTable: React.FC<{ + data?: TOnboardingAdminList; + isLoading: boolean; + isError: boolean; +}> = ({ data, isLoading, isError }) => { + const [eventLogModalParams, setEventLogModalParams] = useState<{ + user: TOnboardingUser; + step: TOnboardingStep; + } | null>(null); + + // Update modal step data if the user is updated + useEffect(() => { + if (eventLogModalParams?.user && data) { + setEventLogModalParams({ + user: eventLogModalParams.user, + step: + data.users + .find((user) => user.username === eventLogModalParams.user.username) + ?.steps.find( + (step) => step.step === eventLogModalParams.step.step + ) || eventLogModalParams.step, + }); + } + }, [data]); + + if (isLoading) { + return ; + } + if (isError || !data) { + return ( +
+ +
+ ); + } + + const viewLogCallback = (user: TOnboardingUser, step: TOnboardingStep) => + setEventLogModalParams({ user, step }); + + const { users } = data; + + return ( + <> + {users.length === 0 && ( +
+ +
+ )} +
+ {users.length > 0 && ( + + )} +
+ {eventLogModalParams && ( + setEventLogModalParams(null)} + /> + )} + + ); +}; + +const OnboardingAdminLayout = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { data, isError, isLoading } = useGetOnboardingAdminList(); + + useEffect(() => {}, [searchParams]); + + const toggleShowIncomplete = () => { + const showIncompleteOnly = searchParams.get('showIncompleteOnly'); + const newSearchParams = searchParams; + if (!showIncompleteOnly) { + newSearchParams.set('showIncompleteOnly', 'true'); + } else { + newSearchParams.delete('showIncompleteOnly'); + newSearchParams.delete('page'); + } + + setSearchParams(newSearchParams); + }; + + const { Header } = Layout; + const headerStyle = { + background: 'transparent', + paddingLeft: 0, + paddingRight: 0, + borderBottom: '1px solid #707070', + fontSize: 16, + }; + + const setOrderBy = (orderBy: string) => { + searchParams.set('orderBy', orderBy); + setSearchParams(searchParams); + }; + + const orderByItems: MenuProps['items'] = [ + { + key: 'default', + label: 'Default (Date Joined, Incomplete, Last Name, First Name)', + }, + { + key: 'last_name', + label: 'Last Name (A-Z)', + }, + { + key: 'first_name', + label: 'First Name (A-Z)', + }, + { + key: '-date_joined', + label: 'Date Joined (Newest First)', + }, + { + key: 'profile__setup_complete', + label: 'Setup Complete (Incomplete First)', + }, + ]; + + return ( + + +
Administrator Controls
+
+ + + setOrderBy(key), + }} + > + + + Order By + + + + + + +
+ +
+
+ ); +}; + +export default OnboardingAdminLayout; diff --git a/client/src/onboarding/layouts/OnboardingBaseLayout.tsx b/client/src/onboarding/layouts/OnboardingBaseLayout.tsx new file mode 100644 index 0000000000..e50b2652e2 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingBaseLayout.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { Flex, Layout } from 'antd'; +import OnboardingWebsocketHandler from './OnboardingWebsocketHandler'; + +const OnboardingRoot: React.FC = () => { + return ( + + + + + + + ); +}; + +export default OnboardingRoot; diff --git a/client/src/onboarding/layouts/OnboardingUserLayout.module.css b/client/src/onboarding/layouts/OnboardingUserLayout.module.css new file mode 100644 index 0000000000..ae8590744d --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingUserLayout.module.css @@ -0,0 +1,10 @@ +.access { + text-align: right; +} + +.content { + padding-bottom: 1em; +} +.content > * { + max-width: 768px; /* ~640px design * 1.2 design-to-app ratio */ +} diff --git a/client/src/onboarding/layouts/OnboardingUserLayout.tsx b/client/src/onboarding/layouts/OnboardingUserLayout.tsx new file mode 100644 index 0000000000..ffb785420d --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingUserLayout.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Layout, Space } from 'antd'; +import { PrimaryButton } from '@client/common-components'; +import { OnboardingStep } from '@client/onboarding'; +import { + useAuthenticatedUser, + useGetOnboardingUserSuspense, +} from '@client/hooks'; +import styles from './OnboardingUserLayout.module.css'; + +export const OnboardingUserLayout: React.FC = () => { + const { user: authenticatedUser } = useAuthenticatedUser(); + const { username } = useParams(); + const { data: onboardingUser } = useGetOnboardingUserSuspense( + username || (authenticatedUser?.username as string) + ); + + const { Header } = Layout; + const headerStyle = { + background: 'transparent', + paddingLeft: 0, + paddingRight: 0, + borderBottom: '1px solid #707070', + fontSize: 16, + }; + + return ( + + +
+ {authenticatedUser?.isStaff + ? `Onboarding Administration for ${onboardingUser.username} - ${onboardingUser.lastName}, ${onboardingUser.firstName}` + : 'The following steps must be completed before accessing the portal'} +
+ <> + {onboardingUser.steps.map((step) => ( + + ))} +
+ + Get Help + +      + + Continue + +
+ +
+
+ ); +}; diff --git a/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx b/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx new file mode 100644 index 0000000000..5e94c4d0e1 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; +import useWebSocket from 'react-use-websocket'; +import { useQueryClient } from '@tanstack/react-query'; +import { + TSetupStepEvent, + TOnboardingUser, + TOnboardingAdminList, +} from '@client/hooks'; + +function updateAdminUsersFromEvent( + oldData: TOnboardingAdminList, + event: TSetupStepEvent +) { + return { + ...oldData, + users: oldData.users.map((user) => + user.username === event.username + ? { ...updateUserFromEvent(user, event) } + : user + ), + }; +} + +function updateUserFromEvent(oldData: TOnboardingUser, event: TSetupStepEvent) { + return { + ...oldData, + setupComplete: !!event.data?.setupComplete, + steps: [ + ...oldData.steps.map((step) => { + if (step.step === event.step) { + return { + ...step, + state: event.state, + events: [event, ...step.events], + }; + } + return step; + }), + ], + }; +} + +const OnboardingWebsocketHandler = () => { + const { lastMessage } = useWebSocket( + `wss://${window.location.host}/ws/websockets/` + ); + const queryClient = useQueryClient(); + const processSetupEvent = (event: TSetupStepEvent) => { + queryClient.setQueriesData( + { queryKey: ['onboarding', 'adminList'], exact: false }, + (oldData) => + (oldData as TOnboardingAdminList)?.users + ? updateAdminUsersFromEvent(oldData as TOnboardingAdminList, event) + : oldData + ); + queryClient.setQueryData( + ['onboarding', 'user', event.username], + (oldData: TOnboardingUser) => + oldData ? updateUserFromEvent(oldData, event) : oldData + ); + }; + + useEffect(() => { + if (lastMessage !== null) { + const event = JSON.parse(lastMessage.data); + if (event.event_type === 'setup_event') { + processSetupEvent(event.setup_event); + } + } + }, [lastMessage]); + + return null; +}; + +export default OnboardingWebsocketHandler; diff --git a/client/src/onboarding/onboardingRouter.tsx b/client/src/onboarding/onboardingRouter.tsx new file mode 100644 index 0000000000..7eb65f5c94 --- /dev/null +++ b/client/src/onboarding/onboardingRouter.tsx @@ -0,0 +1,49 @@ +import { Suspense } from 'react'; +import { Layout } from 'antd'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import { Spinner } from '@client/common-components'; +import OnboardingAdminLayout from './layouts/OnboardingAdminLayout'; +import { OnboardingUserLayout } from './layouts/OnboardingUserLayout'; +import OnboardingBaseLayout from './layouts/OnboardingBaseLayout'; + +const onboardingRouter = createBrowserRouter( + [ + { + id: 'root', + path: '/', + element: , + children: [ + { + path: '', + element: , + }, + { + id: 'admin', + path: 'admin', + element: , + }, + { + path: `setup/:username?`, + element: ( + + + + } + > + + + ), + }, + { + path: '*', + element: , + }, + ], + }, + ], + { basename: '/onboarding' } +); + +export default onboardingRouter; diff --git a/client/src/workspace/layouts/WorkspaceBaseLayout.tsx b/client/src/workspace/layouts/WorkspaceBaseLayout.tsx index b454d92deb..35fa21ce7a 100644 --- a/client/src/workspace/layouts/WorkspaceBaseLayout.tsx +++ b/client/src/workspace/layouts/WorkspaceBaseLayout.tsx @@ -16,6 +16,7 @@ import { usePrefetchGetSystems, usePrefetchGetAllocations, InteractiveModalContext, + useAuthenticatedUser, } from '@client/hooks'; import styles from './layout.module.css'; @@ -25,6 +26,11 @@ const WorkspaceRoot: React.FC = () => { usePrefetchGetApps(useGetAppParams()); usePrefetchGetSystems(); usePrefetchGetAllocations(); + const { user } = useAuthenticatedUser(); + + if (user && !user.setupComplete) { + window.location.replace(`${window.location.origin}/onboarding/setup`); + } const { data, isLoading } = useAppsListing(); const [interactiveModalDetails, setInteractiveModalDetails] = useState({ diff --git a/client/tsconfig.base.json b/client/tsconfig.base.json index c66e21b834..613b2c6399 100644 --- a/client/tsconfig.base.json +++ b/client/tsconfig.base.json @@ -18,6 +18,7 @@ "@client/common-components": ["modules/_common_components/src/index.ts"], "@client/datafiles": ["modules/datafiles/src/index.ts"], "@client/hooks": ["modules/_hooks/src/index.ts"], + "@client/onboarding": ["modules/onboarding/src/index.ts"], "@client/test-fixtures": ["modules/_test-fixtures/src/index.ts"], "@client/workspace": ["modules/workspace/src/index.ts"] } diff --git a/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py b/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py new file mode 100644 index 0000000000..a9181c2f51 --- /dev/null +++ b/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2024-10-15 23:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("designsafe_accounts", "0021_designsafeprofile_homedir"), + ] + + operations = [ + migrations.AddField( + model_name="designsafeprofile", + name="setup_complete", + field=models.BooleanField(default=False), + ), + ] diff --git a/designsafe/apps/accounts/models.py b/designsafe/apps/accounts/models.py index 33ef9d1d0a..ddd0ce232a 100644 --- a/designsafe/apps/accounts/models.py +++ b/designsafe/apps/accounts/models.py @@ -79,6 +79,9 @@ class DesignSafeProfile(models.Model): update_required = models.BooleanField(default=True) last_updated = models.DateTimeField(auto_now=True, null=True) + # Default to False. If PORTAL_USER_ACCOUNT_SETUP_STEPS is empty, setup_complete will be set to True on first login + setup_complete = models.BooleanField(default=False) + def send_mail(self, subject, body=None): send_mail(subject, body, diff --git a/designsafe/apps/accounts/views.py b/designsafe/apps/accounts/views.py index 69765b0335..44e7f2988e 100644 --- a/designsafe/apps/accounts/views.py +++ b/designsafe/apps/accounts/views.py @@ -11,12 +11,10 @@ from designsafe.apps.accounts import forms, integrations from designsafe.apps.accounts.models import (NEESUser, DesignSafeProfile, NotificationPreferences) -from designsafe.apps.auth.tasks import check_or_configure_system_and_user_directory, get_systems_to_configure from designsafe.apps.accounts.tasks import create_report from pytas.http import TASClient from pytas.models import User as TASUser import logging -import json import requests import re from termsandconditions.models import TermsAndConditions @@ -467,12 +465,6 @@ def email_confirmation(request, code=None): user = tas.get_user(username=username) if tas.verify_user(user['id'], code, password=password): logger.info('TAS Account activation succeeded.') - systems_to_configure = get_systems_to_configure(username) - for system in systems_to_configure: - check_or_configure_system_and_user_directory.apply_async(args=(user.username, - system["system_id"], - system["path"], - system["create_path"])) return HttpResponseRedirect(reverse('designsafe_accounts:manage_profile')) else: messages.error(request, @@ -481,7 +473,7 @@ def email_confirmation(request, code=None): 'open a support ticket.') form = forms.EmailConfirmationForm( initial={'code': code, 'username': username}) - except: + except Exception: logger.exception('TAS Account activation failed') form.add_error('__all__', 'Account activation failed. Please confirm your ' diff --git a/designsafe/apps/api/notifications/receivers.py b/designsafe/apps/api/notifications/receivers.py index eada12fd0a..2b7c564157 100644 --- a/designsafe/apps/api/notifications/receivers.py +++ b/designsafe/apps/api/notifications/receivers.py @@ -4,9 +4,12 @@ import json from django.db.models.signals import post_save from django.dispatch import receiver +from django.contrib.auth import get_user_model from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from designsafe.apps.api.notifications.models import Notification, Broadcast +from designsafe.apps.onboarding.models import SetupEvent + logger = logging.getLogger(__name__) @@ -27,8 +30,6 @@ def send_notification_ws(instance, created, **kwargs): f"ds_{instance.user}", {"type": "ds.notification", "message": instance_dict} ) - return - @receiver(post_save, sender=Broadcast, dispatch_uid="broadcast_msg") def send_broadcast_ws(instance, created, **kwargs): @@ -44,4 +45,26 @@ def send_broadcast_ws(instance, created, **kwargs): "ds_broadcast", {"type": "ds.notification", "message": instance_dict} ) - return + +@receiver(post_save, sender=SetupEvent, dispatch_uid="setup_event") +def send_setup_event(instance, **kwargs): + """Send a websocket message to the user and all staff when a new setup event is created.""" + + logger.info("Sending setup event through websocket") + setup_event = instance + + # All staff will receive websocket notifications so they can see + # setup event updates for users they are administering + receiving_users = list(get_user_model().objects.all().filter(is_staff=True)) + + channel_layer = get_channel_layer() + + # Add the setup_event's user to the notification list + receiving_users.append(setup_event.user) + + data = {"event_type": "setup_event", "setup_event": setup_event.to_dict()} + for user in set(receiving_users): + async_to_sync(channel_layer.group_send)( + f"ds_{user.username}", + {"type": "ds.notification", "message": json.dumps(data)}, + ) diff --git a/designsafe/apps/api/projects_v2/operations/project_system_operations.py b/designsafe/apps/api/projects_v2/operations/project_system_operations.py index 25cf38f8d0..3be39c7379 100644 --- a/designsafe/apps/api/projects_v2/operations/project_system_operations.py +++ b/designsafe/apps/api/projects_v2/operations/project_system_operations.py @@ -10,9 +10,6 @@ from designsafe.libs.common.context_managers import AsyncTaskContext -# from portal.apps.onboarding.steps.system_access_v3 import create_system_credentials, register_public_key - - logger = logging.getLogger(__name__) diff --git a/designsafe/apps/api/systems/utils.py b/designsafe/apps/api/systems/utils.py index 984efeba77..e6f1fea32d 100644 --- a/designsafe/apps/api/systems/utils.py +++ b/designsafe/apps/api/systems/utils.py @@ -34,7 +34,7 @@ def add_pub_key_to_resource( :param str hostname: Resource's hostname :param int port: Port to use for ssh connection - :raises: :class:`~portal.apps.accounts.managers.` + :raises: :class:`~designsafe.apps.accounts.managers.` """ success = True diff --git a/designsafe/apps/api/systems/views.py b/designsafe/apps/api/systems/views.py index 12bbbe9397..7597621115 100644 --- a/designsafe/apps/api/systems/views.py +++ b/designsafe/apps/api/systems/views.py @@ -7,7 +7,7 @@ import json from django.http import JsonResponse from designsafe.apps.api.views import AuthenticatedApiView -from designsafe.utils.system_access import create_system_credentials +from designsafe.apps.onboarding.steps.system_access_v3 import create_system_credentials from designsafe.utils.encryption import createKeyPair from .utils import add_pub_key_to_resource diff --git a/designsafe/apps/workspace/api/tas_to_tacc_resources.json b/designsafe/apps/api/users/tas_to_tacc_resources.json similarity index 94% rename from designsafe/apps/workspace/api/tas_to_tacc_resources.json rename to designsafe/apps/api/users/tas_to_tacc_resources.json index 5671063e38..9f610ede0c 100644 --- a/designsafe/apps/workspace/api/tas_to_tacc_resources.json +++ b/designsafe/apps/api/users/tas_to_tacc_resources.json @@ -73,5 +73,10 @@ "name": "Longhorn", "host": "longhorn.tacc.utexas.edu", "type": "HPC" + }, + "Vista": { + "name": "Vista", + "host": "vista.tacc.utexas.edu", + "type": "HPC" } -} \ No newline at end of file +} diff --git a/designsafe/apps/workspace/api/tasks.py b/designsafe/apps/api/users/tasks.py similarity index 73% rename from designsafe/apps/workspace/api/tasks.py rename to designsafe/apps/api/users/tasks.py index e458d17972..415c8bb61e 100644 --- a/designsafe/apps/workspace/api/tasks.py +++ b/designsafe/apps/api/users/tasks.py @@ -1,9 +1,7 @@ -""" -Workspace tasks -""" +""" Celery tasks for users api """ from celery import shared_task -from .views import _get_latest_allocations +from .utils import _get_latest_allocations @shared_task(bind=True, max_retries=3, queue="indexing") diff --git a/designsafe/apps/api/users/utils.py b/designsafe/apps/api/users/utils.py index bd51e92e52..3d93baa099 100644 --- a/designsafe/apps/api/users/utils.py +++ b/designsafe/apps/api/users/utils.py @@ -1,6 +1,13 @@ +"""Utility functions for user API""" + import logging +import json from pytas.http import TASClient +from django.conf import settings from django.db.models import Q +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth import get_user_model +from designsafe.apps.workspace.models.allocations import UserAllocations logger = logging.getLogger(__name__) @@ -42,5 +49,87 @@ def q_to_model_queries(q): query = Q(email__icontains=q) query |= Q(first_name__icontains=q) query |= Q(last_name__icontains=q) + query |= Q(username__icontains=q) return query + + +def _get_tas_allocations(username): + """Returns user allocations on TACC resources + + : returns: allocations + : rtype: dict + """ + + tas_client = TASClient( + baseURL=settings.TAS_URL, + credentials={ + "username": settings.TAS_CLIENT_KEY, + "password": settings.TAS_CLIENT_SECRET, + }, + ) + tas_projects = tas_client.projects_for_user(username) + + with open( + "designsafe/apps/api/users/tas_to_tacc_resources.json", encoding="utf-8" + ) as file: + tas_to_tacc_resources = json.load(file) + + hosts = {} + + for tas_proj in tas_projects: + # Each project from tas has an array of length 1 for its allocations + alloc = tas_proj["allocations"][0] + charge_code = tas_proj["chargeCode"] + if alloc["resource"] in tas_to_tacc_resources: + resource = dict(tas_to_tacc_resources[alloc["resource"]]) + resource["allocation"] = dict(alloc) + + # Separate active and inactive allocations and make single entry for each project + if resource["allocation"]["status"] == "Active": + if ( + resource["host"] in hosts + and charge_code not in hosts[resource["host"]] + ): + hosts[resource["host"]].append(charge_code) + elif resource["host"] not in hosts: + hosts[resource["host"]] = [charge_code] + return { + "hosts": hosts, + } + + +def _get_latest_allocations(username): + """ + Creates or updates allocations cache for a given user and returns new allocations + """ + user = get_user_model().objects.get(username=username) + allocations = _get_tas_allocations(username) + UserAllocations.objects.update_or_create(user=user, defaults={"value": allocations}) + return allocations + + +def get_allocations(user, force=False): + """ + Returns indexed allocation data stored in Django DB, or fetches + allocations from TAS and stores them. + Parameters + ---------- + user: User object + TACC username to fetch allocations for. + force: bool + Returns + ------- + dict + """ + username = user.username + try: + if force: + logger.info(f"Forcing TAS allocation retrieval for user:{username}") + raise ObjectDoesNotExist + result = {"hosts": {}} + result.update(UserAllocations.objects.get(user=user).value) + return result + except ObjectDoesNotExist: + # Fall back to getting allocations from TAS + return _get_latest_allocations(username) diff --git a/designsafe/apps/api/users/views.py b/designsafe/apps/api/users/views.py index 0ffa7a833c..39a0a646f5 100644 --- a/designsafe/apps/api/users/views.py +++ b/designsafe/apps/api/users/views.py @@ -4,23 +4,32 @@ from designsafe.apps.api.users import utils as users_utils from django.contrib.auth import get_user_model from django.forms.models import model_to_dict -from django.http import HttpResponseNotFound, JsonResponse, HttpResponse, HttpRequest +from django.http import HttpResponseNotFound, JsonResponse, HttpRequest from django.views.generic.base import View from django.core.exceptions import ObjectDoesNotExist from pytas.http import TASClient from designsafe.apps.api.views import BaseApiView, ApiException from designsafe.apps.data.models.elasticsearch import IndexedFile, IndexedPublication from designsafe.libs.elasticsearch.utils import new_es_client -from elasticsearch_dsl import Q, Search +from elasticsearch_dsl import Q logger = logging.getLogger(__name__) + def check_public_availability(username): es_client = new_es_client() - query = Q({'multi_match': {'fields': ['project.value.teamMembers', - 'project.value.coPis', - 'project.value.pi'], - 'query': username}}) + query = Q( + { + "multi_match": { + "fields": [ + "project.value.teamMembers", + "project.value.coPis", + "project.value.pi", + ], + "query": username, + } + } + ) res = IndexedPublication.search(using=es_client).filter(query).execute() return res.hits.total.value > 0 @@ -29,15 +38,21 @@ class UsageView(SecureMixin, View): def get(self, request): current_user = request.user - q = IndexedFile.search()\ - .query('bool', must=[Q("prefix", **{"path._exact": '/' + current_user.username})])\ - .extra(size=0) - q.aggs.metric('total_storage_bytes', 'sum', field="length") + q = ( + IndexedFile.search() + .query( + "bool", + must=[Q("prefix", **{"path._exact": "/" + current_user.username})], + ) + .extra(size=0) + ) + q.aggs.metric("total_storage_bytes", "sum", field="length") result = q.execute() agg = result.to_dict()["aggregations"] out = {"total_storage_bytes": agg["total_storage_bytes"]["value"]} return JsonResponse(out) + class AuthenticatedView(View): def get(self, request): @@ -56,15 +71,15 @@ def get(self, request): } return JsonResponse(out) - return JsonResponse({'message': 'Unauthorized'}, status=401) + return JsonResponse({"message": "Unauthorized"}, status=401) class SearchView(View): def get(self, request): - resp_fields = ['first_name', 'last_name', 'email', 'username'] + resp_fields = ["first_name", "last_name", "email", "username"] model = get_user_model() - q = request.GET.get('username') + q = request.GET.get("username") # Do not return user details if the user is not part of a public project. if not request.user.is_authenticated and not check_public_availability(q): @@ -73,23 +88,21 @@ def get(self, request): if q: try: user = model.objects.get(username=q) - except ObjectDoesNotExist as err: + except ObjectDoesNotExist: return HttpResponseNotFound() res_dict = { - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email, - 'username': user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "username": user.username, } - if(user.profile.orcid_id): - res_dict['orcid_id'] = user.profile.orcid_id + if user.profile.orcid_id: + res_dict["orcid_id"] = user.profile.orcid_id try: user_tas = TASClient().get_user(username=q) - res_dict['profile'] = { - 'institution': user_tas['institution'] - } - except Exception as err: - logger.info('No Profile.') + res_dict["profile"] = {"institution": user_tas["institution"]} + except Exception: + logger.info("No Profile.") return JsonResponse(res_dict) @@ -97,8 +110,8 @@ def get(self, request): if not request.user.is_authenticated: return JsonResponse({}) - q = request.GET.get('q') - role = request.GET.get('role') + q = request.GET.get("q") + role = request.GET.get("role") user_rs = model.objects.filter() if q: query = users_utils.q_to_model_queries(q) @@ -123,6 +136,7 @@ def get(self, request): class ProjectUserView(BaseApiView): """View for handling search for project users""" + def get(self, request: HttpRequest): """retrieve a user by their exact TACC username.""" if not request.user.is_authenticated: @@ -130,21 +144,25 @@ def get(self, request: HttpRequest): username_query = request.GET.get("q") user_match = get_user_model().objects.filter(username__iexact=username_query) - user_resp = [{"fname": u.first_name, - "lname": u.last_name, - "inst": u.profile.institution, - "email": u.email, - "username": u.username} for u in user_match] + user_resp = [ + { + "fname": u.first_name, + "lname": u.last_name, + "inst": u.profile.institution, + "email": u.email, + "username": u.username, + } + for u in user_match + ] return JsonResponse({"result": user_resp}) - class PublicView(View): def get(self, request): model = get_user_model() - nl = json.loads(request.GET.get('usernames')) + nl = json.loads(request.GET.get("usernames")) res_list = [] @@ -152,7 +170,9 @@ def get(self, request): users = [] for username in nl: # Do not return user details if the user is not part of a public project. - if not request.user.is_authenticated and not check_public_availability(username): + if not request.user.is_authenticated and not check_public_availability( + username + ): continue try: users.append(model.objects.get(username=username)) @@ -161,13 +181,13 @@ def get(self, request): for user in users: data = { - 'fname': user.first_name, - 'lname': user.last_name, - 'username': user.username, - 'email': user.email, + "fname": user.first_name, + "lname": user.last_name, + "username": user.username, + "email": user.email, } res_list.append(data) - except ObjectDoesNotExist as err: + except ObjectDoesNotExist: return HttpResponseNotFound() res_dict = {"userData": res_list} diff --git a/designsafe/apps/auth/backends_unit_test.py b/designsafe/apps/auth/backends_unit_test.py index f02d3e4680..7a22b6d267 100644 --- a/designsafe/apps/auth/backends_unit_test.py +++ b/designsafe/apps/auth/backends_unit_test.py @@ -1,9 +1,10 @@ import pytest from django.contrib.auth import get_user_model from mock import Mock -from designsafe.apps.auth.backends import TapisOAuthBackend from tapipy.tapis import TapisResult from tapipy.errors import BaseTapyException +from designsafe.apps.auth.backends import TapisOAuthBackend +from designsafe.apps.auth.views import launch_setup_checks pytestmark = pytest.mark.django_db @@ -38,12 +39,11 @@ def update_institution_from_tas_mock(mocker): yield mocker.patch("designsafe.apps.auth.backends.update_institution_from_tas") -# def test_launch_setup_checks(mocker, regular_user, settings): -# mocker.patch("designsafe.apps.auth.views.new_user_setup_check") -# mock_execute = mocker.patch("designsafe.apps.auth.views.execute_setup_steps") -# regular_user.profile.setup_complete = False -# launch_setup_checks(regular_user) -# mock_execute.apply_async.assert_called_with(args=["username"]) +def test_launch_setup_checks(mocker, regular_user, settings): + mocker.patch("designsafe.apps.auth.views.new_user_setup_check") + mock_execute = mocker.patch("designsafe.apps.auth.views.execute_setup_steps") + launch_setup_checks(regular_user) + mock_execute.apply_async.assert_called_with(args=["username"]) def test_bad_backend_params(tapis_mock): diff --git a/designsafe/apps/auth/tasks.py b/designsafe/apps/auth/tasks.py index 1dd1a986b0..af3c504e26 100644 --- a/designsafe/apps/auth/tasks.py +++ b/designsafe/apps/auth/tasks.py @@ -4,117 +4,13 @@ from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist from django.core.mail import send_mail from celery import shared_task from pytas.http import TASClient -from tapipy.errors import ( - NotFoundError, - BaseTapyException, - ForbiddenError, - UnauthorizedError, -) -from designsafe.apps.api.agave import get_service_account_client, get_tg458981_client -from designsafe.apps.api.tasks import agave_indexer from designsafe.apps.api.notifications.models import Notification -from designsafe.utils.system_access import ( - register_public_key, - create_system_credentials, -) -from designsafe.utils.encryption import createKeyPair - - logger = logging.getLogger(__name__) -def get_systems_to_configure(username): - """Get systems to configure either during startup or for new user""" - - systems = [] - for system in settings.TAPIS_SYSTEMS_TO_CONFIGURE: - system_copy = system.copy() - system_copy["path"] = system_copy["path"].format(username=username) - systems.append(system_copy) - return systems - - -@shared_task(default_retry_delay=30, max_retries=3, queue="onboarding", bind=True) -def check_or_configure_system_and_user_directory( - self, username, system_id, path, create_path -): - """Check if user has access to system and path, if not, configure it.""" - try: - user_client = get_user_model().objects.get(username=username).tapis_oauth.client - user_client.files.listFiles(systemId=system_id, path=path) - logger.info( - f"System Works: " - f"Checked and there is no need to configure system:{system_id} path:{path} for {username}" - ) - return - except ObjectDoesNotExist: - # User is missing; handling email confirmation process where user has not logged in - logger.info( - f"New User: " - f"Checked and there is a need to configure system:{system_id} path:{path} for {username} " - ) - except BaseTapyException as e: - logger.info( - f"Unable to list system/files: " - f"Checked and there is a need to configure system:{system_id} path:{path} for {username}: {e}" - ) - - try: - if create_path: - try: - # Use user account to check if path exists and is accessible - user_client.files.listFiles(systemId=system_id, path=path) - logger.info( - f"Directory for user={username} on system={system_id}/{path} exists and works. " - ) - except (NotFoundError, ForbiddenError, UnauthorizedError): - logger.info( - "Ensuring directory exists for user=%s then going to run setfacl on system=%s path=%s", - username, - system_id, - path, - ) - - tg458981_client = get_tg458981_client() - - # Create directory, resolves NotFoundError - tg458981_client.files.mkdir(systemId=system_id, path=path) - - # Set ACLs, resolves UnauthorizedError and ForbiddenError - tg458981_client.files.setFacl( - systemId=system_id, - path=path, - operation="ADD", - recursionMethod="PHYSICAL", - aclString=f"d:u:{username}:rwX,u:{username}:rwX,d:u:tg458981:rwX,u:tg458981:rwX,d:o::---,o::---,d:m::rwX,m::rwX", - ) - agave_indexer.apply_async( - kwargs={"systemId": system_id, "filePath": path, "recurse": False}, - queue="indexing", - ) - - # create keys, push to key service and use as credential for Tapis system - logger.info( - "Creating credentials for user=%s on system=%s", username, system_id - ) - (private_key, public_key) = createKeyPair() - register_public_key(username, public_key, system_id) - service_account = get_service_account_client() - create_system_credentials( - service_account, username, public_key, private_key, system_id - ) - except BaseTapyException as exc: - logger.exception( - "Failed to configure system (i.e. create directory, set acl, create credentials).", - extra={"user": username, "systemId": system_id, "path": path}, - ) - raise self.retry(exc=exc) - - @shared_task(default_retry_delay=30, max_retries=3) def new_user_alert(username): user = get_user_model().objects.get(username=username) diff --git a/designsafe/apps/auth/test_tasks.py b/designsafe/apps/auth/test_tasks.py index c29121fe68..1eb3fb8e9d 100644 --- a/designsafe/apps/auth/test_tasks.py +++ b/designsafe/apps/auth/test_tasks.py @@ -1,13 +1,5 @@ import pytest from unittest import mock -from django.core.exceptions import ObjectDoesNotExist -from tapipy.errors import ( - NotFoundError, - BaseTapyException, - ForbiddenError, - UnauthorizedError, -) -from designsafe.apps.auth.tasks import check_or_configure_system_and_user_directory @pytest.fixture @@ -60,124 +52,3 @@ def mock_create_system_credentials(): def mock_agave_indexer(): with mock.patch("designsafe.apps.auth.tasks.agave_indexer") as mock_agave_indexer: yield mock_agave_indexer - - -def test_check_or_configure_system_and_user_directory_no_configuration_needed( - mock_get_user_model, authenticated_user -): - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", False - ) - authenticated_user.tapis_oauth.client.files.listFiles.assert_called_once_with( - systemId="testsystem", path="/testpath" - ) - - -def test_check_or_configure_system_and_user_directory_user_missing( - mock_get_user_model, - mock_register_public_key, - mock_create_system_credentials, - mock_createKeyPair, - mock_get_service_account_client, -): - mock_get_user_model().objects.get.side_effect = ObjectDoesNotExist - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", False - ) - mock_get_user_model().objects.get.assert_called_once_with(username="testuser") - - -def test_check_or_configure_system_and_user_directory_base_tapy_exception( - mock_get_user_model, - authenticated_user, - mock_register_public_key, - mock_create_system_credentials, - mock_createKeyPair, - mock_get_service_account_client, -): - authenticated_user.tapis_oauth.client.files.listFiles.side_effect = ( - BaseTapyException - ) - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", False - ) - authenticated_user.tapis_oauth.client.files.listFiles.assert_called_once_with( - systemId="testsystem", path="/testpath" - ) - - -def test_check_or_configure_system_and_user_directory_create_path( - mock_get_user_model, - authenticated_user, - mock_get_tg458981_client, - mock_createKeyPair, - mock_register_public_key, - mock_get_service_account_client, - mock_create_system_credentials, - mock_agave_indexer, -): - authenticated_user.tapis_oauth.client.files.listFiles.side_effect = NotFoundError - tg458981_client = mock_get_tg458981_client() - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", True - ) - tg458981_client.files.mkdir.assert_called_once_with( - systemId="testsystem", path="/testpath" - ) - tg458981_client.files.setFacl.assert_called_once() - mock_createKeyPair.assert_called_once() - mock_register_public_key.assert_called_once_with( - "testuser", "public_key", "testsystem" - ) - mock_create_system_credentials.assert_called_once() - mock_agave_indexer.apply_async.assert_called_once() - - -def test_check_or_configure_system_and_user_directory_forbidden_error( - mock_get_user_model, - authenticated_user, - mock_get_tg458981_client, - mock_createKeyPair, - mock_register_public_key, - mock_get_service_account_client, - mock_create_system_credentials, - mock_agave_indexer, -): - authenticated_user.tapis_oauth.client.files.listFiles.side_effect = ForbiddenError - tg458981_client = mock_get_tg458981_client() - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", True - ) - tg458981_client.files.setFacl.assert_called_once() - mock_createKeyPair.assert_called_once() - mock_register_public_key.assert_called_once_with( - "testuser", "public_key", "testsystem" - ) - mock_create_system_credentials.assert_called_once() - mock_agave_indexer.apply_async.assert_called_once() - - -def test_check_or_configure_system_and_user_directory_unauthorized_error( - mock_get_user_model, - authenticated_user, - mock_get_tg458981_client, - mock_createKeyPair, - mock_register_public_key, - mock_get_service_account_client, - mock_create_system_credentials, - mock_agave_indexer, -): - authenticated_user.tapis_oauth.client.files.listFiles.side_effect = ( - UnauthorizedError - ) - tg458981_client = mock_get_tg458981_client() - check_or_configure_system_and_user_directory( - "testuser", "testsystem", "/testpath", True - ) - tg458981_client.files.setFacl.assert_called_once() - mock_createKeyPair.assert_called_once() - mock_register_public_key.assert_called_once_with( - "testuser", "public_key", "testsystem" - ) - mock_create_system_credentials.assert_called_once() - mock_agave_indexer.apply_async.assert_called_once() diff --git a/designsafe/apps/auth/urls.py b/designsafe/apps/auth/urls.py index aa75734711..fc956002c4 100644 --- a/designsafe/apps/auth/urls.py +++ b/designsafe/apps/auth/urls.py @@ -1,5 +1,5 @@ """ -.. module:: portal.apps.auth.urls +.. module:: designsafe.apps.auth.urls :synopsis: Auth URLs """ @@ -8,7 +8,7 @@ app_name = "designsafe_auth" urlpatterns = [ - path('', views.tapis_oauth, name="login"), + path("", views.tapis_oauth, name="login"), path("logged-out/", views.logged_out, name="logout"), path("tapis/", views.tapis_oauth, name="tapis_oauth"), path("tapis/callback/", views.tapis_oauth_callback, name="tapis_oauth_callback"), diff --git a/designsafe/apps/auth/views.py b/designsafe/apps/auth/views.py index 31cd958ba2..bd53393379 100644 --- a/designsafe/apps/auth/views.py +++ b/designsafe/apps/auth/views.py @@ -12,12 +12,9 @@ from django.urls import reverse from django.http import HttpResponseRedirect, HttpResponseBadRequest from django.shortcuts import render -from designsafe.apps.auth.tasks import ( - check_or_configure_system_and_user_directory, - get_systems_to_configure, -) -from designsafe.apps.workspace.api.tasks import cache_allocations +from designsafe.apps.api.users.tasks import cache_allocations from designsafe.apps.auth.tasks import new_user_alert +from designsafe.apps.onboarding.execute import execute_setup_steps, new_user_setup_check from .models import TapisOAuthToken logger = logging.getLogger(__name__) @@ -71,19 +68,20 @@ def tapis_oauth(request): def launch_setup_checks(user): """Perform any onboarding checks or non-onboarding steps that may spawn celery tasks""" - logger.info("Starting tasks to check or configure systems for %s", user.username) - for system in get_systems_to_configure(user.username): - check_or_configure_system_and_user_directory.apply_async( - args=( - user.username, - system["system_id"], - system["path"], - system["create_path"], - ), - queue="files", + + # Check onboarding settings + new_user_setup_check(user) + if not user.profile.setup_complete: + logger.info("Executing onboarding setup steps for %s", user.username) + execute_setup_steps.apply_async(args=[user.username]) + return HttpResponseRedirect(reverse("designsafe_onboarding:user")) + else: + logger.info( + "Already onboarded, running non-onboarding steps (e.g. update cached " + "allocation information) for %s", + user.username, ) - logger.info("Creating/updating cached allocation information for %s", user.username) - cache_allocations.apply_async(args=(user.username,)) + cache_allocations.apply_async(args=(user.username,)) def tapis_oauth_callback(request): @@ -132,7 +130,9 @@ def tapis_oauth_callback(request): user = authenticate(backend="tapis", token=token_data["access_token"]) if user: - _, created = TapisOAuthToken.objects.update_or_create(user=user, defaults={**token_data}) + _, created = TapisOAuthToken.objects.update_or_create( + user=user, defaults={**token_data} + ) if created: new_user_alert.apply_async(args=(user.username,)) diff --git a/designsafe/apps/auth/views_unit_test.py b/designsafe/apps/auth/views_unit_test.py index fbbc149d07..ce62a2f397 100644 --- a/designsafe/apps/auth/views_unit_test.py +++ b/designsafe/apps/auth/views_unit_test.py @@ -3,9 +3,8 @@ import pytest from django.conf import settings from django.urls import reverse +from designsafe.apps.auth.views import launch_setup_checks -# TODOV3: Onboarding Tests https://tacc-main.atlassian.net/browse/DES-2822 -# from portal.apps.auth.views import launch_setup_checks TEST_STATE = "ABCDEFG123456" @@ -75,20 +74,29 @@ def test_tapis_callback_mismatched_state(client): assert response.status_code == 400 -# TODOV3: Onboarding Tests https://tacc-main.atlassian.net/browse/DES-2822 -# def test_launch_setup_checks(regular_user, mocker): -# mock_execute_setup_steps = mocker.patch( -# "portal.apps.auth.views.execute_setup_steps" -# ) -# launch_setup_checks(regular_user) -# mock_execute_setup_steps.apply_async.assert_called_with( -# args=[regular_user.username] -# ) +def test_launch_setup_checks(regular_user, mocker): + mock_execute_setup_steps = mocker.patch( + "designsafe.apps.auth.views.execute_setup_steps" + ) + mock_new_user_setup_check = mocker.patch( + "designsafe.apps.auth.views.new_user_setup_check" + ) + launch_setup_checks(regular_user) + mock_new_user_setup_check.assert_called_with(regular_user) + mock_execute_setup_steps.apply_async.assert_called_with( + args=[regular_user.username] + ) -# TODOV3: Onboarding Tests https://tacc-main.atlassian.net/browse/DES-2822 -# def test_launch_setup_checks_already_onboarded(regular_user, mocker): -# regular_user.profile.setup_complete = True -# mock_index_allocations = mocker.patch("portal.apps.auth.views.index_allocations") -# launch_setup_checks(regular_user) -# mock_index_allocations.apply_async.assert_called_with(args=[regular_user.username]) +def test_launch_setup_checks_already_onboarded(regular_user, mocker): + regular_user.profile.setup_complete = True + mocker.patch("designsafe.apps.auth.views.new_user_setup_check") + mock_execute_setup_steps = mocker.patch( + "designsafe.apps.auth.views.execute_setup_steps" + ) + mock_cache_allocations = mocker.patch( + "designsafe.apps.auth.views.cache_allocations" + ) + launch_setup_checks(regular_user) + mock_cache_allocations.apply_async.assert_called_with(args=(regular_user.username,)) + mock_execute_setup_steps.apply_async.assert_not_called() diff --git a/designsafe/apps/djangoRT/fixtures/ticket_detail.txt b/designsafe/apps/djangoRT/fixtures/ticket_detail.txt index 798c1605d2..5d4abe26bd 100644 --- a/designsafe/apps/djangoRT/fixtures/ticket_detail.txt +++ b/designsafe/apps/djangoRT/fixtures/ticket_detail.txt @@ -22,5 +22,4 @@ LastUpdated: Sun Feb 28 01:01:24 2016 TimeEstimated: 0 TimeWorked: 0 TimeLeft: 0 -CF.{Resource}: - +CF.{Resource}: diff --git a/designsafe/apps/djangoRT/fixtures/users.json b/designsafe/apps/djangoRT/fixtures/users.json deleted file mode 100644 index fc280b2a03..0000000000 --- a/designsafe/apps/djangoRT/fixtures/users.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "fields": { - "username": "ds_admin", - "first_name": "DesignSafe", - "last_name": "Admin", - "is_active": true, - "is_superuser": true, - "is_staff": true, - "last_login": "2016-03-01T00:00:00.000Z", - "groups": [], - "user_permissions": [], - "password": "", - "email": "admin@designsafe-ci.org", - "date_joined": "2016-03-01T00:00:00.000Z" - }, - "model": "auth.user", - "pk": 1 - }, - { - "fields": { - "username": "ds_user", - "first_name": "DesignSafe", - "last_name": "User", - "is_active": true, - "is_superuser": false, - "is_staff": false, - "last_login": "2016-03-01T00:00:00.000Z", - "groups": [], - "user_permissions": [], - "password": "", - "email": "user@designsafe-ci.org", - "date_joined": "2016-03-01T00:00:00.000Z" - }, - "model": "auth.user", - "pk": 2 - } -] diff --git a/designsafe/apps/djangoRT/tests.py b/designsafe/apps/djangoRT/tests.py index 000162171b..1cc247e61a 100755 --- a/designsafe/apps/djangoRT/tests.py +++ b/designsafe/apps/djangoRT/tests.py @@ -1,222 +1,100 @@ -from django.test import TestCase +import pytest from django.urls import reverse -from django.contrib.auth import get_user_model, signals -from unittest import skip -import mock -import requests_mock -@skip("anonymous tickets disabled due to spam.") -class AnonymousViewTests(TestCase): +@pytest.mark.django_db +def test_index(client, authenticated_user): """ - Almost all views by anonymous trigger a redirect. However, anonymous CAN create - tickets. The create ticket view for anonymous includes a Captcha field, so ensure - that form subclass is activated. + For authenticated users, the default/index view redirects to the mytickets view. """ + resp = client.get(reverse("djangoRT:index")) + assert resp.status_code == 302 + assert resp.url == reverse("djangoRT:mytickets") - def test_index(self): - """ - For anonymous users, the default/index view redirects to the ticketcreate view. - :return: - """ - resp = self.client.get(reverse('djangoRT:index')) - self.assertRedirects(resp, reverse('djangoRT:ticketcreate')) - def test_my_tickets(self): - requested_url = reverse('djangoRT:mytickets') - resp = self.client.get(requested_url) - expected_redirect = reverse('login') + '?next=' + requested_url - self.assertRedirects(resp, expected_redirect, target_status_code=302) +@pytest.mark.django_db +def test_mytickets_none(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + with open("designsafe/apps/djangoRT/fixtures/user_tickets_resp.txt") as f: + requests_mock.get("/REST/1.0/search/ticket?format=l", text=f.read()) - def test_detail(self): - requested_url = reverse('djangoRT:ticketdetail', args=[999]) - resp = self.client.get(requested_url) - expected_redirect = reverse('login') + '?next=' + requested_url - self.assertRedirects(resp, expected_redirect, target_status_code=302) + resp = client.get(reverse("djangoRT:mytickets")) + assert "No tickets to display!" in resp.content.decode() - def test_create(self): - resp = self.client.get(reverse('djangoRT:ticketcreate')) - self.assertEqual(resp.status_code, 200) - def test_create_with_error_context(self): - query = 'error_page=/page/that/failed&http_referer=https://www.google.com' - resp = self.client.get(reverse('djangoRT:ticketcreate') + '?' + query) +@pytest.mark.django_db +def test_mytickets_resolved(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + with open("designsafe/apps/djangoRT/fixtures/user_tickets_resolved_resp.txt") as f: + requests_mock.get("/REST/1.0/search/ticket?format=l", text=f.read()) - self.assertContains(resp, '', html=True) - self.assertContains(resp, '', html=True) + resp = client.get(reverse("djangoRT:mytickets") + "?show_resolved=1") + assert "No tickets to display!" not in resp.content.decode() - def test_reply(self): - requested_url = reverse('djangoRT:ticketreply', args=[999]) - resp = self.client.get(requested_url) - expected_redirect = reverse('login') + '?next=' + requested_url - self.assertRedirects(resp, expected_redirect, target_status_code=302) - def test_close(self): - requested_url = reverse('djangoRT:ticketclose', args=[999]) - resp = self.client.get(requested_url) - expected_redirect = reverse('login') + '?next=' + requested_url - self.assertRedirects(resp, expected_redirect, target_status_code=302) +@pytest.mark.django_db +def test_detail(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + ticket_id = 29152 + with open("designsafe/apps/djangoRT/fixtures/ticket_detail.txt") as f: + requests_mock.get(f"/REST/1.0/ticket/{ticket_id}/show", text=f.read()) + with open("designsafe/apps/djangoRT/fixtures/ticket_history.txt") as f: + requests_mock.get(f"/REST/1.0/ticket/{ticket_id}/history", text=f.read()) - def test_attachment(self): - requested_url = reverse('djangoRT:ticketattachment', args=[999, 1001]) - resp = self.client.get(requested_url) - expected_redirect = reverse('login') + '?next=' + requested_url - self.assertRedirects(resp, expected_redirect, target_status_code=302) + resp = client.get(reverse("djangoRT:ticketdetail", args=[ticket_id])) + assert "Test Post, Please Ignore" in resp.content.decode() -class AuthenticatedViewTests(TestCase): +@pytest.mark.django_db +def test_create(client, authenticated_user): + resp = client.get(reverse("djangoRT:ticketcreate")) + assert "Captcha" not in resp.content.decode() - fixtures = ['users.json'] - def setUp(self): - # set password for users - user = get_user_model().objects.get(pk=2) - user.set_password('password') - user.save() +@pytest.mark.django_db +def test_create_with_error_context(client, authenticated_user): + query = "error_page=/page/that/failed&http_referer=https://www.google.com" + resp = client.get(reverse("djangoRT:ticketcreate") + "?" + query) - # disconnect user_logged_in signal + assert "Captcha" not in resp.content.decode() + assert ( + '' + in resp.content.decode() + ) + assert ( + '' + in resp.content.decode() + ) - def test_index(self): - """ - For authenticated users, the default/index view redirects to the mytickets view. - :return: - """ - # log user in - self.client.login(username='ds_user', password='password') +@pytest.mark.django_db +def test_reply(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + ticket_id = 29152 + with open("designsafe/apps/djangoRT/fixtures/ticket_detail.txt") as f: + requests_mock.get(f"/REST/1.0/ticket/{ticket_id}/show", text=f.read()) - resp = self.client.get(reverse('djangoRT:index')) - self.assertRedirects(resp, reverse('djangoRT:mytickets'), - fetch_redirect_response=False) + resp = client.get(reverse("djangoRT:ticketreply", args=[ticket_id])) + assert f"Reply to #{ticket_id}" in resp.content.decode() - @requests_mock.Mocker() - def test_mytickets_none(self, req_mock): - # log user in - self.client.login(username='ds_user', password='password') +@pytest.mark.django_db +def test_reopen(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + ticket_id = 29152 + with open("designsafe/apps/djangoRT/fixtures/ticket_detail_resolved.txt") as f: + requests_mock.get(f"/REST/1.0/ticket/{ticket_id}/show", text=f.read()) - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') + resp = client.get(reverse("djangoRT:ticketreply", args=[ticket_id])) + assert f"Reopen #{ticket_id}" in resp.content.decode() - # mock ticket query - with open('designsafe/apps/djangoRT/fixtures/user_tickets_resp.txt') as f: - req_mock.get('/REST/1.0/search/ticket?format=l', text=f.read()) - resp = self.client.get(reverse('djangoRT:mytickets')) - self.assertContains(resp, 'No tickets to display!') +@pytest.mark.django_db +def test_close(client, authenticated_user, requests_mock): + requests_mock.post("/REST/1.0/", text="RT/4.2.1 200 Ok") + ticket_id = 29152 + with open("designsafe/apps/djangoRT/fixtures/ticket_detail.txt") as f: + requests_mock.get(f"/REST/1.0/ticket/{ticket_id}/show", text=f.read()) - @requests_mock.Mocker() - def test_mytickets_resolved(self, req_mock): - - # log user in - self.client.login(username='ds_user', password='password') - - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') - - # mock ticket query - with open('designsafe/apps/djangoRT/fixtures/user_tickets_resolved_resp.txt') as f: - req_mock.get('/REST/1.0/search/ticket?format=l', text=f.read()) - - resp = self.client.get(reverse('djangoRT:mytickets') + '?show_resolved=1') - self.assertNotContains(resp, 'No tickets to display!') - - @requests_mock.Mocker() - def test_detail(self, req_mock): - # log user in - self.client.login(username='ds_user', password='password') - - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') - - ticket_id = 29152 - - # mock ticket request - with open('designsafe/apps/djangoRT/fixtures/ticket_detail.txt') as f: - req_mock.get('/REST/1.0/ticket/{}/show'.format(ticket_id), text=f.read()) - - # mock ticket history - with open('designsafe/apps/djangoRT/fixtures/ticket_history.txt') as f: - req_mock.get('/REST/1.0/ticket/{}/history'.format(ticket_id), text=f.read()) - - resp = self.client.get(reverse('djangoRT:ticketdetail', args=[ticket_id])) - self.assertContains(resp, 'Test Post, Please Ignore') - - def test_create(self): - # log user in - self.client.login(username='ds_user', password='password') - - resp = self.client.get(reverse('djangoRT:ticketcreate')) - self.assertNotContains(resp, 'Captcha') - - def test_create_with_error_context(self): - # log user in - self.client.login(username='ds_user', password='password') - - query = 'error_page=/page/that/failed&http_referer=https://www.google.com' - resp = self.client.get(reverse('djangoRT:ticketcreate') + '?' + query) - - self.assertNotContains(resp, 'Captcha') - self.assertContains(resp, '', html=True) - self.assertContains(resp, '', html=True) - - @requests_mock.Mocker() - def test_reply(self, req_mock): - # log user in - self.client.login(username='ds_user', password='password') - - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') - - ticket_id = 29152 - - # mock ticket request - with open('designsafe/apps/djangoRT/fixtures/ticket_detail.txt') as f: - req_mock.get('/REST/1.0/ticket/{}/show'.format(ticket_id), text=f.read()) - - resp = self.client.get(reverse('djangoRT:ticketreply', args=[ticket_id])) - self.assertContains(resp, 'Reply to #{}'.format(ticket_id)) - - @requests_mock.Mocker() - def test_reopen(self, req_mock): - # log user in - self.client.login(username='ds_user', password='password') - - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') - - ticket_id = 29152 - - # mock ticket request - with open('designsafe/apps/djangoRT/fixtures/ticket_detail_resolved.txt') as f: - req_mock.get('/REST/1.0/ticket/{}/show'.format(ticket_id), text=f.read()) - - resp = self.client.get(reverse('djangoRT:ticketreply', args=[ticket_id])) - self.assertContains(resp, 'Reopen #{}'.format(ticket_id)) - - @requests_mock.Mocker() - def test_close(self, req_mock): - # log user in - self.client.login(username='ds_user', password='password') - - # mock login - req_mock.post('/REST/1.0/', text='RT/4.2.1 200 Ok') - - ticket_id = 29152 - - # mock ticket request - with open('designsafe/apps/djangoRT/fixtures/ticket_detail.txt') as f: - req_mock.get('/REST/1.0/ticket/{}/show'.format(ticket_id), text=f.read()) - - resp = self.client.get(reverse('djangoRT:ticketclose', args=[ticket_id])) - self.assertContains(resp, 'Close #{}'.format(ticket_id)) - - - @skip("TODO implement attachment test - @mrhanlon; 2016-08-10") - def test_attachment(self): - # TODO - pass + resp = client.get(reverse("djangoRT:ticketclose", args=[ticket_id])) + assert f"Close #{ticket_id}" in resp.content.decode() diff --git a/designsafe/apps/onboarding/__init__.py b/designsafe/apps/onboarding/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/onboarding/api/__init__.py b/designsafe/apps/onboarding/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/onboarding/api/urls.py b/designsafe/apps/onboarding/api/urls.py new file mode 100644 index 0000000000..ecda14042d --- /dev/null +++ b/designsafe/apps/onboarding/api/urls.py @@ -0,0 +1,12 @@ +""" URL routing for the onboarding API. """ + +from django.urls import path +from designsafe.apps.onboarding.api import views + + +app_name = "onboarding_api" +urlpatterns = [ + path("user/", views.SetupStepView.as_view(), name="user_self_view"), + path("user//", views.SetupStepView.as_view(), name="user_view"), + path("admin/", views.SetupAdminView.as_view(), name="user_admin"), +] diff --git a/designsafe/apps/onboarding/api/views.py b/designsafe/apps/onboarding/api/views.py new file mode 100644 index 0000000000..218cf40f77 --- /dev/null +++ b/designsafe/apps/onboarding/api/views.py @@ -0,0 +1,323 @@ +""""API views for the Onboarding app.""" + +import logging +import json +import math +from django.conf import settings +from django.http import ( + Http404, + JsonResponse, + HttpResponseBadRequest, +) +from django.contrib.auth import get_user_model +from django.contrib.admin.views.decorators import staff_member_required +from django.core.exceptions import PermissionDenied, ObjectDoesNotExist +from django.utils.decorators import method_decorator +from designsafe.apps.api.views import AuthenticatedApiView +from designsafe.apps.api.users.utils import q_to_model_queries +from designsafe.apps.onboarding.models import SetupEvent, SetupEventEncoder +from designsafe.apps.onboarding.execute import ( + log_setup_state, + load_setup_step, + execute_single_step, + execute_setup_steps, +) +from designsafe.apps.onboarding.state import SetupState + +logger = logging.getLogger(__name__) + + +def get_user_onboarding(user): + """Get a user's onboarding status""" + # Result dictionary for user + result = { + "username": user.username, + "lastName": user.last_name, + "firstName": user.first_name, + "email": user.email, + "isStaff": user.is_staff, + "steps": [], + "setupComplete": user.profile.setup_complete, + } + + # Populate steps list in result dictionary, in order of + # steps as listed in PORTAL_USER_ACCOUNT_SETUP_STEPS + account_setup_steps = getattr(settings, "PORTAL_USER_ACCOUNT_SETUP_STEPS", []) + for step in account_setup_steps: + # Get step events in descending order of time + step_events = ( + SetupEvent.objects.all() + .filter(user=user, step=step["step"]) + .order_by("-time") + ) + + step_instance = load_setup_step(user, step["step"]) + + # Upon retrieving step data such as viewing the Onboarding page, + # If a step has the 'retry' setting set to True and the step is not completed, + # retry the step with asynchronous processing. + if ( + "retry" in step + and step["retry"] + and step_instance.state != SetupState.PENDING + and step_instance.state != SetupState.COMPLETED + ): + step_instance.state = SetupState.PROCESSING + execute_single_step.apply_async(args=[user.username, step["step"]]) + logger.info("Retrying setup step %s for %s", step["step"], user.username) + + step_data = { + "step": step["step"], + "displayName": step_instance.display_name(), + "description": step_instance.description(), + "userConfirm": step_instance.user_confirm, + "staffApprove": step_instance.staff_approve, + "staffDeny": step_instance.staff_deny, + "state": step_instance.state, + "events": list(step_events), + "data": None, + } + custom_status = step_instance.custom_status() + if custom_status: + step_data["customStatus"] = custom_status + + if step_instance.last_event: + step_data["data"] = step_instance.last_event.data + + # Append all events. SetupEventEncoder will serialize + # SetupEvent objects later + result["steps"].append(step_data) + + return result + + +class SetupStepView(AuthenticatedApiView): + """View for managing user setup steps""" + + def _get_user_parameter(self, request, username): + """ + Validate request for action on a username + + Staff should be able to act on any user, but non-staff users + should only be able to act on themselves. + """ + # A user should only be able to retrieve info about themselves. + # A staff member should be able to retrieve anyone. + if username != request.user.username and not request.user.is_staff: + raise PermissionDenied + + user = None + try: + user = get_user_model().objects.get(username=username) + except get_user_model().DoesNotExist as exc: + raise Http404 from exc + + return user + + def get(self, request, username=None): + """ + View for returning a user's setup step events. + + Result structure will be: + + { + ... + user info + ... + "setupComplete" : true | false, + "steps" : [ + { + "step" : "step1", + "state" : "pending", + "events" : [ + SetupEvent, SetupEvent... + ] + }, + ... + ] + } + + Where step dictionaries are in matching order of PORTAL_USER_ACCOUNT_SETUP_STEPS + """ + if username is None: + username = request.user.username + + user = self._get_user_parameter(request, username) + + result = get_user_onboarding(user) + + return JsonResponse( + { + "status": 200, + "response": result, + }, + encoder=SetupEventEncoder, + ) + + def complete(self, request, setup_step): + """ + Move any step to COMPLETED + """ + if not request.user.is_staff: + raise PermissionDenied + setup_step.state = SetupState.COMPLETED + setup_step.log( + f"{setup_step.display_name()} marked complete by {request.user.username}" + ) + + def reset(self, request, setup_step): + """ + Call prepare() for the step. This should set it to its initial state. + """ + if not request.user.is_staff: + raise PermissionDenied + setup_step.log(f"{setup_step.display_name()} reset by {request.user.username}") + + # Mark the user's setup_complete as False + setup_step.user.profile.setup_complete = False + setup_step.user.profile.save() + log_setup_state( + setup_step.user, + f"{setup_step.user.username} setup marked incomplete, due to reset of {setup_step.step_name()}", + ) + setup_step.prepare() + + def client_action(self, request, setup_step, action, data): + """ + Call client_action on a setup step + """ + setup_step.log( + f"{action} action on {setup_step.step_name()} by {request.user.username}" + ) + setup_step.client_action(action, data, request) + + def post(self, request, username): + """ + Action handler for manipulating a user's setup step state. + POST data from the client includes: + + { + "action" : "staff_approve" | "staff_deny" | "user_confirm" | + "set_state" | "reset" + "step" : SetupStep module and classname, + "data" : an optional dictionary of data to send to the action + } + + ..return: A JsonResponse with the last_event for the user's SetupStep, + reflecting state change + """ + if username is None: + username = request.user.username + + # Get the user object requested in the route parameter + user = self._get_user_parameter(request, username) + + # Get POST action data + step_name = None + action = None + data = None + + try: + request_data = json.loads(request.body) + step_name = request_data["step"] + action = request_data["action"] + if "data" in request_data: + data = request_data["data"] + except (json.JSONDecodeError, KeyError): + logger.exception("Error parsing POST data") + return HttpResponseBadRequest() + + # Instantiate the step instance requested by the POST, from the SetupEvent model. + setup_step = load_setup_step(user, step_name) + + # Call action handler + if action == "reset": + self.reset(request, setup_step) + elif action == "complete": + self.complete(request, setup_step) + else: + self.client_action(request, setup_step, action, data) + + # If no exception was generated from any of the above actions, continue. + # Retry executing the setup queue for this user + execute_setup_steps.apply_async(args=[user.username]) + + # Serialize and send back the last event on this step + return JsonResponse( + { + "status": 200, + "response": setup_step.last_event, + }, + encoder=SetupEventEncoder, + ) + + +@method_decorator(staff_member_required, name="dispatch") +class SetupAdminView(AuthenticatedApiView): + """Admin view for managing user setup steps""" + + # pylint: disable=too-many-locals + def get(self, request): + """Get all users for page and their setup steps""" + + page = int(request.GET.get("page", 1)) + limit = int(request.GET.get("limit", 20)) + show_incomplete_only = request.GET.get("showIncompleteOnly", "False").lower() + query = request.GET.get("q", None) + order_by = request.GET.get("orderBy", None) + + users = [] + filter_args = {} + + # Filter users based on the showIncompleteOnly parameter + if show_incomplete_only == "true": + filter_args["profile__setup_complete"] = False + + # Get users, with most recently joined users that do not have setup_complete, first + if query: + query = q_to_model_queries(query) + results = get_user_model().objects.filter(query, **filter_args) + else: + results = get_user_model().objects.filter(**filter_args) + + if order_by and order_by in [ + "-date_joined", + "profile__setup_complete", + "last_name", + "first_name", + ]: + results = results.order_by(order_by) + else: + results = results.order_by( + "-date_joined", "profile__setup_complete", "last_name", "first_name" + ) + + # Uncomment this line to simulate many user results + # results = list(results) * 105 + + total = math.ceil(len(results) / limit) + offset = (page - 1) * limit + users_in_page = results[offset : limit * page] # noqa: E203 + account_setup_steps = getattr(settings, "PORTAL_USER_ACCOUNT_SETUP_STEPS", []) + + # Assemble an array with the User data we care about + for user in users_in_page: + try: + users.append(get_user_onboarding(user)) + except ObjectDoesNotExist as err: + # If a user does not have a DesignSafeProfile, skip it + logger.info(err) + + response = { + "users": users, + "total": total, + "totalSteps": len(account_setup_steps), + } + + return JsonResponse( + { + "status": 200, + "response": response, + }, + encoder=SetupEventEncoder, + ) diff --git a/designsafe/apps/onboarding/api/views_unit_test.py b/designsafe/apps/onboarding/api/views_unit_test.py new file mode 100644 index 0000000000..3e1b753aa4 --- /dev/null +++ b/designsafe/apps/onboarding/api/views_unit_test.py @@ -0,0 +1,323 @@ +import pytest +import logging +import json +from mock import MagicMock +from django.http import JsonResponse +from django.db.models import signals +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.api.views import SetupStepView, get_user_onboarding + +logger = logging.getLogger(__name__) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def disconnect_signal(): + yield signals.post_save.disconnect(sender=SetupEvent, dispatch_uid="setup_event") + + +@pytest.fixture(autouse=True) +def mocked_executor(mocker): + yield mocker.patch("designsafe.apps.onboarding.api.views.execute_setup_steps") + + +@pytest.fixture(autouse=True) +def mocked_log_setup_state(mocker): + yield mocker.patch("designsafe.apps.onboarding.api.views.log_setup_state") + + +@pytest.fixture(autouse=True) +def mocked_get_tas_client(mocker): + yield mocker.patch( + "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep.get_tas_client" + ) + + +""" +SetupStepView tests +""" + + +def test_get_user_unauthenticated_forbidden(client, regular_user): + response = client.get("/api/onboarding/user/{}/".format(regular_user.username)) + assert response.status_code == 401 + + +def test_get_other_user_forbidden(client, authenticated_user, staff_user): + response = client.get("/api/onboarding/user/{}/".format(staff_user.username)) + assert response.status_code == 403 + + +def test_get_user_as_staff(client, authenticated_staff, regular_user): + response = client.get("/api/onboarding/user/{}/".format(regular_user.username)) + assert response.status_code == 200 + result = json.loads(response.content)["response"] + assert result["username"] == regular_user.username + assert len(result["steps"][0]["events"]) == 0 + + +def test_get_user_as_staff_with_steps( + settings, authenticated_staff, client, mock_steps +): + response = client.get("/api/onboarding/user/username", follow=True) + result = response.json()["response"] + + # Make sure result json is correct. + assert result["username"] == "username" + assert len(result["steps"][0]["events"]) == 2 + + +def test_get_non_existent_user_as_staff(client, authenticated_staff): + response = client.get("/api/onboarding/user/non_existent_user/") + assert response.status_code == 404 + + +def test_get_user_as_user(client, settings, authenticated_user, mock_steps): + # A user should be able to retrieve their own setup event info + response = client.get( + "/api/onboarding/user/{}".format(authenticated_user.username), follow=True + ) + result = response.json()["response"] + + result = json.loads(response.content)["response"] + assert result["username"] == authenticated_user.username + assert "steps" in result + assert ( + result["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + assert result["steps"][0]["displayName"] == "Mock Step" + assert result["steps"][0]["state"] == SetupState.COMPLETED + assert len(result["steps"][0]["events"]) == 2 + + +def test_retry_step(client, settings, authenticated_user, mock_retry_step, mocker): + mock_execute_single_step = mocker.patch( + "designsafe.apps.onboarding.api.views.execute_single_step" + ) + response = client.get( + "/api/onboarding/user/{}".format(authenticated_user.username), follow=True + ) + mock_execute_single_step.apply_async.assert_called_with( + args=[ + authenticated_user.username, + "designsafe.apps.onboarding.steps.test_steps.MockStep", + ] + ) + result = json.loads(response.content)["response"] + assert result["username"] == authenticated_user.username + assert "steps" in result + assert ( + result["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + assert result["steps"][0]["state"] == SetupState.PROCESSING + + +def test_incomplete_post(client, authenticated_user): + # post should return HttpResponseBadRequest (400) if fields are missing + response = client.post( + "/api/onboarding/user/{}/".format(authenticated_user), + content_type="application/json", + data=json.dumps({"action": "user_confirm"}), + ) + assert response.status_code == 400 + + response = client.post( + "/api/onboarding/user/{}/".format(authenticated_user), + content_type="application/json", + data=json.dumps({"step": "setupstep"}), + ) + assert response.status_code == 400 + + +def test_client_action(regular_user, rf): + view = SetupStepView() + mock_step = MagicMock() + mock_step.step_name.return_value = "Mock Step" + request = rf.post("/api/onboarding/user/username") + request.user = regular_user + view.client_action(request, mock_step, "user_confirm", None) + mock_step.log.assert_called() + mock_step.client_action.assert_called_with("user_confirm", None, request) + + +def test_reset_not_staff(client, authenticated_user): + response = client.post( + "/api/onboarding/user/{}/".format(authenticated_user.username), + content_type="application/json", + data=json.dumps( + { + "action": "reset", + "step": "designsafe.apps.onboarding.steps.test_steps.MockStep", + } + ), + ) + assert response.status_code == 403 + + +def test_reset(rf, staff_user, regular_user, mocked_log_setup_state): + # The reset function should call prepare on a step + # and flag the user's setup_complete as False + view = SetupStepView() + request = rf.post("/api/onboarding/user/username") + request.user = staff_user + mock_step = MagicMock() + mock_step.user = regular_user + + # Call reset function + view.reset(request, mock_step) + + mock_step.prepare.assert_called() + mock_step.log.assert_called() + mocked_log_setup_state.assert_called() + assert not mock_step.user.profile.setup_complete + + +def test_complete_not_staff(client, authenticated_user): + response = client.post( + "/api/onboarding/user/{}/".format(authenticated_user.username), + content_type="application/json", + data=json.dumps( + { + "action": "complete", + "step": "designsafe.apps.onboarding.steps.test_steps.MockStep", + } + ), + ) + assert response.status_code == 403 + + +def test_complete( + client, authenticated_staff, regular_user, mock_steps, mocked_executor +): + response = client.post( + "/api/onboarding/user/{}/".format(regular_user.username), + content_type="application/json", + data=json.dumps( + { + "action": "complete", + "step": "designsafe.apps.onboarding.steps.test_steps.MockStep", + } + ), + ) + + # set_state should have put MockStep in COMPLETED, as per request + events = [event for event in SetupEvent.objects.all()] + assert events[-1].step == "designsafe.apps.onboarding.steps.test_steps.MockStep" + assert events[-1].state == SetupState.COMPLETED + + # execute_setup_steps should have been run + mocked_executor.apply_async.assert_called_with(args=[regular_user.username]) + last_event = json.loads(response.content)["response"] + assert last_event["state"] == SetupState.COMPLETED + + +""" +SetupAdminView tests +""" + + +def test_admin_route(client, authenticated_staff): + # If the user is authenticated and is_staff, then the route should + # return a JsonResponse + response = client.get("/api/onboarding/admin/") + assert isinstance(response, JsonResponse) + + +def test_admin_route_is_protected(authenticated_user, client): + # Test to make sure route is protected + # If the user is not staff, then the route should return a redirect to admin login + response = client.get("/api/onboarding/admin/", follow=False) + assert response.status_code == 302 + + +def test_get_user_onboarding(mock_steps, regular_user): + # Test retrieving a user's events + result = get_user_onboarding(regular_user) + assert ( + result["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + + +def test_get_no_profile(client, authenticated_staff, regular_user): + # Test that no object is returned for a user with no profile + regular_user.profile.delete() + response = client.get("/api/onboarding/admin/") + response_data = json.loads(response.content)["response"] + + # regular_user should not appear in results + assert not any( + [ + True + for user in response_data["users"] + if user["username"] == regular_user.username + ] + ) + + +def test_get(client, authenticated_staff, regular_user, mock_steps): + regular_user.profile.setup_complete = False + regular_user.profile.save() + + authenticated_staff.profile.setup_complete = True + authenticated_staff.profile.save() + + # Make a request without 'showIncompleteOnly' parameter + response = client.get("/api/onboarding/admin/") + result = json.loads(response.content)["response"] + + users = result["users"] + + # Make a request with 'showIncompleteOnly' parameter set to true + response_incomplete_users = client.get( + "/api/onboarding/admin/?showIncompleteOnly=true" + ) + result_incomplete_users = json.loads(response_incomplete_users.content)["response"] + + users_incomplete = result_incomplete_users["users"] + + # Assertions without 'showIncompleteOnly' + # The first result should be the regular_user, since they have not completed setup + assert users[0]["username"] == regular_user.username + + # User regular_user's last event should be MockStep + assert ( + users[0]["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + + # There should be two users returned + assert len(users) == 2 + + # Assertions with 'showIncompleteOnly=true' + assert users_incomplete[0]["username"] == regular_user.username + assert ( + users_incomplete[0]["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + + # There should be one user since only one user has setup_complete = True + assert len(users_incomplete) == 1 + + +def test_get_search(client, authenticated_staff, regular_user, mock_steps): + response = client.get("/api/onboarding/admin/?q=Firstname") + result = json.loads(response.content)["response"] + + users = result["users"] + + # The first result should be the regular_user, since they have not completed setup + assert users[0]["username"] == regular_user.username + + # User regular_user's last event should be MockStep + assert ( + users[0]["steps"][0]["step"] + == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + + # There should be two users returned + assert len(users) == 1 diff --git a/designsafe/apps/onboarding/apps.py b/designsafe/apps/onboarding/apps.py new file mode 100644 index 0000000000..ed4697c5da --- /dev/null +++ b/designsafe/apps/onboarding/apps.py @@ -0,0 +1,9 @@ +""" Django app configuration for onboarding app. """ + +from django.apps import AppConfig + + +class OnboardingConfig(AppConfig): + """Django app configuration for onboarding app.""" + + name = "designsafe.apps.onboarding" diff --git a/designsafe/apps/onboarding/conftest.py b/designsafe/apps/onboarding/conftest.py new file mode 100644 index 0000000000..a258003ffd --- /dev/null +++ b/designsafe/apps/onboarding/conftest.py @@ -0,0 +1,46 @@ +""" Pytest fixtures for the onboarding app. """ + +import pytest +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.state import SetupState + + +@pytest.fixture +def mock_steps(regular_user, settings): + """Mock steps for testing.""" + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + {"step": "designsafe.apps.onboarding.steps.test_steps.MockStep"} + ] + pending_step = SetupEvent.objects.create( + user=regular_user, + step="designsafe.apps.onboarding.steps.test_steps.MockStep", + state=SetupState.PENDING, + message="message", + ) + + completed_step = SetupEvent.objects.create( + user=regular_user, + step="designsafe.apps.onboarding.steps.test_steps.MockStep", + state=SetupState.COMPLETED, + message="message", + ) + yield (pending_step, completed_step) + + +@pytest.fixture +def mock_retry_step(regular_user, settings): + """Mock a step that needs to be retried.""" + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockStep", + "retry": True, + "settings": {}, + } + ] + retry_step = SetupEvent.objects.create( + user=regular_user, + step="designsafe.apps.onboarding.steps.test_steps.MockStep", + state=SetupState.USERWAIT, + message="message", + ) + yield retry_step diff --git a/designsafe/apps/onboarding/execute.py b/designsafe/apps/onboarding/execute.py new file mode 100644 index 0000000000..34892985c4 --- /dev/null +++ b/designsafe/apps/onboarding/execute.py @@ -0,0 +1,130 @@ +"""Methods for executing setup steps for a user.""" + +import logging +from inspect import isclass +from importlib import import_module +from celery import shared_task +from django.conf import settings +from django.contrib.auth import get_user_model +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.steps.abstract import AbstractStep +from designsafe.apps.accounts.models import DesignSafeProfile + +logger = logging.getLogger(__name__) + + +class StepExecuteException(Exception): + """ + Exception raised when setup step processing + is interrupted + """ + + def __init__(self, message): + super().__init__(message) + + +def new_user_setup_check(user): + """Check if a user has completed setup steps""" + extra_steps = getattr(settings, "PORTAL_USER_ACCOUNT_SETUP_STEPS", []) + if len(extra_steps) == 0: + logger.info("No extra setup steps for user %s", user.username) + profile = DesignSafeProfile.objects.get(user=user) + profile.setup_complete = True + profile.save() + else: + logger.info("Preparing onboarding steps for user %s", user.username) + prepare_setup_steps(user) + + +def log_setup_state(user, message): + """Log the state of a user's setup""" + # Create an event log for a user completing setup. + # This will also signal the front end + SetupEvent.objects.create( + user=user, + step="designsafe.apps.onboarding.execute.execute_setup_steps", + state=( + SetupState.COMPLETED if user.profile.setup_complete else SetupState.FAILED + ), + message=message, + data={"setupComplete": user.profile.setup_complete}, + ) + + +def load_setup_step(user, step): + """Load a setup step class from a string""" + module_str, callable_str = step.rsplit(".", 1) + module = import_module(module_str) + call = getattr(module, callable_str) + if not isclass(call): + raise ValueError(f"Setup step {step} is not a class") + setup_step = call(user) + if not isinstance(setup_step, AbstractStep): + raise ValueError(f"Setup step {step} is not a subclass of AbstractStep") + return setup_step + + +def prepare_setup_steps(user): + """ + Set the initial state of all setup steps for a given user + """ + extra_steps = getattr(settings, "PORTAL_USER_ACCOUNT_SETUP_STEPS", []) + for step in extra_steps: + setup_step = load_setup_step(user, step["step"]) + if setup_step.last_event is None: + setup_step.prepare() + + +def process_setup_step(setup_step): + """Process a setup step""" + setup_step.state = SetupState.PROCESSING + setup_step.log("Beginning automated processing") + try: + setup_step.process() + except Exception as err: # pylint: disable=broad-except + logger.exception("Problem processing setup step") + setup_step.state = SetupState.ERROR + setup_step.log(f"Exception: {str(err)}") + + +@shared_task() +def execute_setup_steps(username): + """Execute all setup steps for a user""" + + user = get_user_model().objects.get(username=username) + + extra_steps = getattr(settings, "PORTAL_USER_ACCOUNT_SETUP_STEPS", []) + for step in extra_steps: + # Restore state of this setup step for this user + setup_step = load_setup_step(user, step["step"]) + # Run step, if waiting for automatic execution + # should have this state + if setup_step.state == SetupState.PENDING: + process_setup_step(setup_step) + # If step is not COMPLETED either from this execution or a prior + # one, then it could be in USERWAIT, STAFFWAIT or FAIL + # at which point we should raise an execption + if setup_step.state != SetupState.COMPLETED: + raise StepExecuteException(setup_step) + + # If execution was not interrupted by a StepExecuteException, such as + # a step failing to reach the COMPLETED state, mark the user as setup_complete + user.profile.setup_complete = True + user.profile.save() + log_setup_state(user, f"{user.username} setup is now complete") + + +@shared_task() +def execute_single_step(username, step_name): + """Execute a single setup step for a user""" + + user = get_user_model().objects.get(username=username) + # Process specified setup step + setup_step = load_setup_step(user, step_name) + process_setup_step(setup_step) + # If execution was not interrupted after processing this step, i.e. + # if it completed successfully, continue executing the rest of the onboarding + # steps + if setup_step.state == SetupState.COMPLETED: + execute_setup_steps(username) diff --git a/designsafe/apps/onboarding/execute_unit_test.py b/designsafe/apps/onboarding/execute_unit_test.py new file mode 100644 index 0000000000..8bb771981d --- /dev/null +++ b/designsafe/apps/onboarding/execute_unit_test.py @@ -0,0 +1,374 @@ +import pytest +from mock import MagicMock +from django.db.models import signals +from designsafe.apps.onboarding.steps.test_steps import MockProcessingCompleteStep +from designsafe.apps.accounts.models import DesignSafeProfile +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.execute import ( + execute_setup_steps, + execute_single_step, + prepare_setup_steps, + load_setup_step, + log_setup_state, + new_user_setup_check, + StepExecuteException, +) + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def disconnect_signal(): + yield signals.post_save.disconnect(sender=SetupEvent, dispatch_uid="setup_event") + + +@pytest.fixture +def mock_event_create(mocker): + yield mocker.patch( + "designsafe.apps.onboarding.execute.SetupEvent.objects.create", autospec=True + ) + + +def test_log_setup_state_complete(authenticated_user, mock_event_create): + """ + Test that a SetupEvent is logged for setting the user's setup_complete flag to True + """ + authenticated_user.profile.setup_complete = True + log_setup_state(authenticated_user, "test message") + mock_event_create.assert_called_with( + user=authenticated_user, + step="designsafe.apps.onboarding.execute.execute_setup_steps", + state=SetupState.COMPLETED, + message="test message", + data={"setupComplete": True}, + ) + + +def test_log_setup_state_incomplete(authenticated_user, mock_event_create): + """ + Test that a SetupEvent is logged for setting the user's setup_complete flag to False + """ + authenticated_user.profile.setup_complete = False + log_setup_state(authenticated_user, "test message") + mock_event_create.assert_called_with( + user=authenticated_user, + step="designsafe.apps.onboarding.execute.execute_setup_steps", + state=SetupState.FAILED, + message="test message", + data={"setupComplete": False}, + ) + + +def test_prepare_setup_steps(authenticated_user, mocker, settings): + """ + Test that a step is loaded and prepared for a user that does not have step history + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [{"step": "TestStep"}] + mock_step = MagicMock(last_event=None) + mock_loader = mocker.patch("designsafe.apps.onboarding.execute.load_setup_step") + mock_loader.return_value = mock_step + prepare_setup_steps(authenticated_user) + mock_loader.assert_called_with(authenticated_user, "TestStep") + mock_step.prepare.assert_called() + + +def test_step_loader(authenticated_user): + """ + Test the dynamic step loader + """ + step = load_setup_step( + authenticated_user, + "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep", + ) + assert step is not None + + +def test_invalid_step_function(authenticated_user): + """ + Test an invalid configuration that passes a function instead of a class + + This may occur due to a legacy setting "designsafe.apps.accounts.steps.step_one" + """ + with pytest.raises(ValueError): + load_setup_step( + authenticated_user, + "designsafe.apps.onboarding.steps.test_steps.mock_invalid_step_function", + ) + + +def test_invalid_step_class(authenticated_user): + """ + Test an invalid configuration that passes a class that is not + a child of AbstractStep + + This may occur due to a legacy setting "designsafe.apps.accounts.steps.StepThree" + """ + with pytest.raises(ValueError): + load_setup_step( + authenticated_user, + "designsafe.apps.onboarding.steps.test_steps.MockInvalidStepClass", + ) + + +def test_successful_step(settings, authenticated_user, mocker): + """ + Test that a step that completes successfully is executed without error + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + } + ] + mock_log_setup_state = mocker.patch( + "designsafe.apps.onboarding.execute.log_setup_state" + ) + + prepare_setup_steps(authenticated_user) + execute_setup_steps(authenticated_user.username) + + # Last event should be COMPLETED for MockPendingCompleteStep + setup_event = ( + SetupEvent.objects.all() + .filter( + step="designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep", + user=authenticated_user, + ) + .latest("time") + ) + assert setup_event.message == "Completed" + + # After last event has completed, setup_complete should be true for user + profile_result = DesignSafeProfile.objects.all().filter(user=authenticated_user)[0] + assert profile_result.setup_complete + + mock_log_setup_state.assert_called() + + +def test_fail_step(settings, authenticated_user): + """ + Test that a step that fails halts execution. + + MockProcessingFailStep should invoke and leave an event, + but MockProcessingCompleteStep (which occurs after in the mock setting) + should not execute due to the previous step failing. + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + {"step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep"}, + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + }, + ] + with pytest.raises(StepExecuteException): + prepare_setup_steps(authenticated_user) + execute_setup_steps(authenticated_user.username) + + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 4 + setup_event = SetupEvent.objects.all()[3] + assert ( + setup_event.step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep" + ) + assert setup_event.message == "Failure" + profile = DesignSafeProfile.objects.get(user=authenticated_user) + assert not profile.setup_complete + + +def test_error_step(settings, authenticated_user): + """ + Assert that when a setup step causes an error that the error is logged + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + {"step": "designsafe.apps.onboarding.steps.test_steps.MockErrorStep"} + ] + with pytest.raises(StepExecuteException): + prepare_setup_steps(authenticated_user) + execute_setup_steps(authenticated_user.username) + + exception_event = SetupEvent.objects.all().filter( + user=authenticated_user, + step="designsafe.apps.onboarding.steps.test_steps.MockErrorStep", + state=SetupState.ERROR, + )[0] + assert exception_event.message == "Exception: MockErrorStep" + + +def test_userwait_step(settings, authenticated_user): + """ + Test that a step in USERWAIT (or really any state that is not PENDING) + prevents the rest of the steps from executing + + MockUserWaitStep.prepare should invoke and leave an event, + but MockPendingCompleteStep (which occurs after in the mock setting) + should not execute due to the first one not being "COMPLETE". + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + {"step": "designsafe.apps.onboarding.steps.test_steps.MockUserStep"}, + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + }, + ] + with pytest.raises(StepExecuteException): + prepare_setup_steps(authenticated_user) + execute_setup_steps(authenticated_user.username) + + # Setup event log should not progress due to first + # step being USERWAIT + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 2 + setup_event = SetupEvent.objects.all()[1] + assert ( + setup_event.step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + ) + assert setup_event.state == SetupState.PENDING + + +def test_sequence(settings, authenticated_user): + """ + Test that execution continues when a step completes + + MockProcessingCompleteStep should complete successfully and log an event. + MockProcessingFailStep should execute and fail, and leave a log event. + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + }, + {"step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep"}, + ] + with pytest.raises(StepExecuteException): + prepare_setup_steps(authenticated_user) + execute_setup_steps(authenticated_user.username) + + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 6 + assert ( + setup_events[2].step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + ) + assert setup_events[2].state == SetupState.PROCESSING + assert ( + setup_events[3].step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + ) + assert setup_events[3].state == SetupState.COMPLETED + assert ( + setup_events[4].step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep" + ) + assert setup_events[4].state == SetupState.PROCESSING + assert ( + setup_events[5].step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep" + ) + assert setup_events[5].state == SetupState.FAILED + + +def test_sequence_with_history(settings, authenticated_user): + """ + Test that execution skips a previously completed step + + MockProcessingFailStep should execute and fail, and leave a log event. + There should be two log events + """ + + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + }, + {"step": "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep"}, + ] + + # Artificially fail MockProcessingCompleteStep + mock_complete_step = MockProcessingCompleteStep(authenticated_user) + mock_complete_step.fail("Mock Failure") + + # Artificially execute MockProcessingCompleteStep + # The latest event instance should be a success, + # therefore the step should be skipped in the future + mock_complete_step = MockProcessingCompleteStep(authenticated_user) + mock_complete_step.process() + + # The previous two transactions should create a history with two steps + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 2 + + # A new event should be generated for MockProcessingFail + prepare_setup_steps(authenticated_user) + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 3 + + with pytest.raises(StepExecuteException): + execute_setup_steps(authenticated_user.username) + + # Executing should now generate more events + setup_events = SetupEvent.objects.all() + assert len(setup_events) == 5 + + # MockPendingCompleteStep should appear in the log exactly twice + complete_events = SetupEvent.objects.all().filter( + step="designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep" + ) + assert len(complete_events) == 2 + + # Last event should be MockPendingFailStep + assert ( + setup_events[4].step + == "designsafe.apps.onboarding.steps.test_steps.MockProcessingFailStep" + ) + assert setup_events[4].state == SetupState.FAILED + + +def test_no_setup_steps(settings, authenticated_user): + """ + Assert that when there are no setup steps, a user is flagged as setup_complete + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [] + new_user_setup_check(authenticated_user) + profile = DesignSafeProfile.objects.get(user=authenticated_user) + assert profile.setup_complete + + +def test_setup_steps_prepared_from_list(settings, authenticated_user, mocker): + """ + Assert that when there are setup steps, they are prepared for a user + """ + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = ["onboarding.step"] + mock_prepare = mocker.patch( + "designsafe.apps.onboarding.execute.prepare_setup_steps" + ) + new_user_setup_check(authenticated_user) + mock_prepare.assert_called_with(authenticated_user) + + +def test_execute_single_step(mocker, authenticated_user): + """ + Test that the single step executor triggers a follow up execution of + the rest of the step queue + """ + mock_execute = mocker.patch( + "designsafe.apps.onboarding.execute.execute_setup_steps" + ) + execute_single_step( + authenticated_user.username, + "designsafe.apps.onboarding.steps.test_steps.MockProcessingCompleteStep", + ) + mock_execute.assert_called_with(authenticated_user.username) + + +def test_execute_single_step_does_not_complete(mocker, authenticated_user): + """ + Test that the single step executor does not trigger a follow up execution of + the rest of the step queue if the step does not complete + """ + mock_execute = mocker.patch( + "designsafe.apps.onboarding.execute.execute_setup_steps" + ) + execute_single_step( + authenticated_user.username, + "designsafe.apps.onboarding.steps.test_steps.MockUserStep", + ) + mock_execute.assert_not_called() diff --git a/designsafe/apps/onboarding/migrations/0001_initial.py b/designsafe/apps/onboarding/migrations/0001_initial.py new file mode 100644 index 0000000000..dd426c2f30 --- /dev/null +++ b/designsafe/apps/onboarding/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2024-10-15 23:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SetupEvent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("time", models.DateTimeField(auto_now_add=True)), + ("step", models.CharField(max_length=300)), + ("state", models.CharField(max_length=16)), + ("message", models.CharField(max_length=300)), + ("data", models.JSONField(null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/designsafe/apps/onboarding/migrations/__init__.py b/designsafe/apps/onboarding/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/onboarding/models.py b/designsafe/apps/onboarding/models.py new file mode 100644 index 0000000000..28ea73f814 --- /dev/null +++ b/designsafe/apps/onboarding/models.py @@ -0,0 +1,57 @@ +"""Onboarding models.""" + +from django.db import models +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder + + +class SetupEvent(models.Model): + """Setup Events + + A log of events for setup steps + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name="+", on_delete=models.CASCADE + ) + + # Auto increment auto add timestamp for event + time = models.DateTimeField(auto_now_add=True) + + # Name of SetupStep class, i.e. designsafe.apps.onboarding.steps.access.RequestAccessStep + step = models.CharField(max_length=300) + + # Short name for setup state, defined per SetupState class. + # "pending", "failed", "completed" etc + state = models.CharField(max_length=16) + + # Detailed setup message + message = models.CharField(max_length=300) + + # JSON Data + data = models.JSONField(null=True) + + def __str__(self): + return f"{self.user.username} {self.time} {self.step} ({self.state}) - {self.message} ({self.data})" + + def to_dict(self): + """Return a dictionary representation of the event""" + return { + "step": self.step, + "username": self.user.username, + "state": self.state, + "time": str(self.time), + "message": self.message, + "data": self.data, + } + + +class SetupEventEncoder(DjangoJSONEncoder): + """Custom JSON Encoder for SetupEvent objects""" + + def default(self, o): + if isinstance(o, SetupEvent): + event = o + return event.to_dict() + + return super().default(o) diff --git a/designsafe/apps/onboarding/models_unit_test.py b/designsafe/apps/onboarding/models_unit_test.py new file mode 100644 index 0000000000..0c2d3d74d3 --- /dev/null +++ b/designsafe/apps/onboarding/models_unit_test.py @@ -0,0 +1,39 @@ +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.models import SetupEvent +from django.db.models import signals +import pytest + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def disconnect_signal(): + yield signals.post_save.disconnect(sender=SetupEvent, dispatch_uid="setup_event") + + +@pytest.fixture +def onboarding_event(authenticated_user): + event = SetupEvent.objects.create( + user=authenticated_user, + state=SetupState.PENDING, + step="TestStep", + message="test message", + ) + yield event + + +def test_onboarding_model(authenticated_user, onboarding_event): + event = SetupEvent.objects.all()[0] + assert event.user == authenticated_user + assert event.state == SetupState.PENDING + assert event.step == "TestStep" + assert event.message == "test message" + + +def test_unicode(authenticated_user, onboarding_event): + event_str = str(onboarding_event) + assert authenticated_user.username in event_str + assert "TestStep" in event_str + assert SetupState.PENDING in event_str + assert "test message" in event_str diff --git a/designsafe/apps/onboarding/state.py b/designsafe/apps/onboarding/state.py new file mode 100644 index 0000000000..159ce72c74 --- /dev/null +++ b/designsafe/apps/onboarding/state.py @@ -0,0 +1,40 @@ +""" State definitions for onboarding steps """ + + +# pylint: disable=too-few-public-methods +class SetupState: + """State definitions for onboarding steps""" + + # Steps in PENDING will be have their process methods called + # by designsafe.apps.onboarding.execute.execute_setup_steps + PENDING = "pending" + + # Steps in PROCESSING have had their process methods invoked + # by designsafe.apps.onboarding.execute.execute_setup_steps + PROCESSING = "processing" + + # Steps in FAILED state will display as a failure in the client + # They may be set to FAILED by calling the fail method + FAILED = "failed" + + # Steps in COMPLETED state will cause the next step to be + # checked for automated processing + # by designsafe.apps.onboarding.execute.execute_setup_steps + COMPLETED = "completed" + + # Steps in USERWAIT state will show a Confirm button in the + # client to the user, allowing them to confirm an action. + # Once they have pressed the Confirm button, the client + # will send "user_confirm" to the step's client_action method + USERWAIT = "userwait" + + # Steps in STAFFWAIT state will show an "Approve" and "Deny" + # action to staff users, allowing them to approve or deny + # a portal onboarding step. The client will send + # "staff_approve" or "staff_deny" to the step's client_action method + STAFFWAIT = "staffwait" + + # Steps in ERROR have been processed in execute_setup_steps but + # generated an exception. This state will also cause the front end + # to display a "submit a ticket" link to the user + ERROR = "error" diff --git a/designsafe/apps/onboarding/steps/__init__.py b/designsafe/apps/onboarding/steps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/onboarding/steps/abstract.py b/designsafe/apps/onboarding/steps/abstract.py new file mode 100644 index 0000000000..c4946ee07e --- /dev/null +++ b/designsafe/apps/onboarding/steps/abstract.py @@ -0,0 +1,139 @@ +"""Abstract class for user setup steps.""" + +from abc import ABCMeta, abstractmethod +from six import add_metaclass +from django.conf import settings +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.state import SetupState + + +# pylint: disable=too-many-instance-attributes +@add_metaclass(ABCMeta) +class AbstractStep: + """ + An abstract class that allows user setup steps to be structured + with a state machine. + """ + + def __init__(self, user): + self.state = None + self.user = user + self.user_confirm = "Confirm" + self.staff_approve = "Approve" + self.staff_deny = "Deny" + self.last_event = None + self.events = [] + + try: + steps = settings.PORTAL_USER_ACCOUNT_SETUP_STEPS + step_dict = next( + (step for step in steps if step["step"] == self.step_name()), {} + ) + self.settings = step_dict["settings"] + except KeyError: + self.settings = None + + try: + # Restore event history + self.events = list( + SetupEvent.objects.filter(user=user, step=self.step_name()).order_by( + "time" + ) + ) + + self.last_event = self.events[-1] if len(self.events) > 0 else None + self.state = self.last_event.state + except (IndexError, AttributeError): + pass + + def log(self, message, data=None): + """ + Log current state of setup step. This must be called by subclasses and any method that + needs to set the state of the setup step for this user. + """ + self.last_event = SetupEvent.objects.create( + user=self.user, + step=self.step_name(), + state=self.state, + message=message, + data=data, + ) + self.events.append(self.last_event) + + def fail(self, message, data=None): + """ + Mark this setup step as failed. + """ + self.state = SetupState.FAILED + self.log(message, data) + + def complete(self, message, data=None): + """ + Mark this setup step as completed + """ + self.state = SetupState.COMPLETED + self.log(message, data) + + def __str__(self): + return f"<{self.step_name()} for {self.user.username} is {self.state}>" + + def step_name(self): + """Return the full name of the step class""" + return f"{self.__module__}.{self.__class__.__name__}" + + @abstractmethod + def display_name(self): + """ + Called when displaying this step in the client. Should return a string + that is a friendly name for a step. + """ + return NotImplemented + + @abstractmethod + def description(self): + """ + Called when displaying this step in the client. Should return a string + that is a detailed description for a step. + """ + return NotImplemented + + def custom_status(self): + """ + Called when displaying this step in the client. Should return a string + that displays a custom status + """ + return None + + @abstractmethod + def prepare(self): + """ + Called during profile setup in designsafe.apps.accounts.managers.accounts.setup + if no log data exists for this step. Child implementations should perform + any pre-processing, then set state to PENDING, USERWAIT or STAFFWAIT and + call self.log with a message to save this state. + + Also called when a staff user invokes the reset action from the client + """ + return NotImplemented + + def client_action(self, action, data, request): + """ + Called by designsafe.apps.onboarding.api.views.SetupStepView.post + + Child implementations should override this to handle interactions with + the front-end client, and should call complete, fail, or log. + The child implementation should check the identity of request.user before + allowing execution of an action + + ..param: action can be "user_confirm" | "staff_approve" | "staff_deny" + """ + + def process(self): + """ + Called by designsafe.apps.onboarding.execute.execute_setup_steps if + state is SetupState.PENDING. execute_setup_steps will put the step + in the PROCESSING state before this method is called. + + Child implementations should override this to handle long processing + calls. + """ diff --git a/designsafe/apps/onboarding/steps/abstract_unit_test.py b/designsafe/apps/onboarding/steps/abstract_unit_test.py new file mode 100644 index 0000000000..93ff1425a0 --- /dev/null +++ b/designsafe/apps/onboarding/steps/abstract_unit_test.py @@ -0,0 +1,88 @@ +from designsafe.apps.onboarding.models import SetupEvent +from designsafe.apps.onboarding.state import SetupState +from django.db.models import signals +from designsafe.apps.onboarding.steps.test_steps import MockStep +import pytest + + +@pytest.fixture(autouse=True) +def disconnect_signal(): + yield signals.post_save.disconnect(sender=SetupEvent, dispatch_uid="setup_event") + + +@pytest.fixture +def mock_step(settings, regular_user): + yield MockStep(regular_user) + + +def test_init_not_event(mock_step): + assert mock_step.last_event is None + assert len(mock_step.events) == 0 + + +def test_step_name(mock_step): + assert ( + mock_step.step_name() == "designsafe.apps.onboarding.steps.test_steps.MockStep" + ) + + +def test_log(mock_step, regular_user): + mock_step.state = SetupState.PENDING + mock_step.log("test event") + events = SetupEvent.objects.all().filter(user=regular_user) + assert events[0].message == "test event" + assert events[0].state == SetupState.PENDING + + +def test_init_with_event(mock_step, regular_user): + mock_step.state = SetupState.PENDING + mock_step.log("event 1") + mock_step.state = SetupState.COMPLETED + mock_step.log("event 2") + + # Re-initialize the step to load last event + mock_step = MockStep(regular_user) + assert mock_step.last_event.state == SetupState.COMPLETED + assert len(mock_step.events) == 2 + + +def test_complete(mock_step): + mock_step.complete("Completed") + assert mock_step.state == SetupState.COMPLETED + assert mock_step.last_event.state == SetupState.COMPLETED + assert SetupEvent.objects.all()[0].message == "Completed" + + +def test_fail(mock_step): + mock_step.fail("Failure") + assert mock_step.state == SetupState.FAILED + assert mock_step.last_event.state == SetupState.FAILED + assert SetupEvent.objects.all()[0].message == "Failure" + + +def test_str(mock_step): + mock_step.state = SetupState.PENDING + assert ( + str(mock_step) + == "" + ) + + +def test_settings(mock_step): + assert mock_step.settings == {"key": "value"} + + +def test_step_missing(regular_user, settings): + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [] + mock_step = MockStep(regular_user) + assert mock_step.settings is None + + +def test_step_setting_missing(regular_user, settings): + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.test_steps.MockStep", + } + ] + mock_step = MockStep(regular_user) + assert mock_step.settings is None diff --git a/designsafe/apps/onboarding/steps/access.py b/designsafe/apps/onboarding/steps/access.py new file mode 100644 index 0000000000..068e4e4a98 --- /dev/null +++ b/designsafe/apps/onboarding/steps/access.py @@ -0,0 +1,54 @@ +"""Request Access Step for Onboarding.""" + +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.steps.abstract import AbstractStep + + +class RequestAccessStep(AbstractStep): + """Request Access Step for Onboarding.""" + + def __init__(self, user): + """ + Call super class constructor + """ + super().__init__(user) + self.user_confirm = "Request Portal Access" + self.staff_approve = "Grant Portal Access" + self.staff_deny = "Deny Access Request" + + def display_name(self): + return "Requesting Access" + + def description(self): + return """This notifies a system administrator of your request to access the portal. + After sending the request, wait for their approval.""" + + def prepare(self): + super().prepare() + self.state = SetupState.PENDING + self.log("Waiting for access check") + + def process(self): + self.state = SetupState.USERWAIT + self.log("Please click Request Portal Access and then wait for staff approval.") + + def custom_status(self): + if self.state == SetupState.COMPLETED: + return "Access Granted" + return None + + def client_action(self, action, data, request): + if action == "user_confirm" and request.user == self.user: + self.state = SetupState.STAFFWAIT + self.log("Please wait for staff approval") + return + + if not request.user.is_staff: + return + + if action == "staff_approve": + self.complete(f"Portal access request approved by {request.user.username}") + elif action == "staff_deny": + self.fail("Portal access request has not been approved.") + else: + self.fail(f"Invalid client action {action}") diff --git a/designsafe/apps/onboarding/steps/access_unit_test.py b/designsafe/apps/onboarding/steps/access_unit_test.py new file mode 100644 index 0000000000..db21f95c34 --- /dev/null +++ b/designsafe/apps/onboarding/steps/access_unit_test.py @@ -0,0 +1,60 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from mock import patch, ANY +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.steps.access import RequestAccessStep +import pytest + + +@pytest.mark.django_db(transaction=True) +class TestRequestAccessStep(TestCase): + def setUp(self): + super(TestRequestAccessStep, self).setUp() + + # Create a test user + User = get_user_model() + self.user = User.objects.create_user("test", "test@test.com", "test") + + self.staff = User.objects.create_user("staff", "staff@staff.com", "staff") + self.staff.is_staff = True + + def tearDown(self): + super(TestRequestAccessStep, self).tearDown() + + @patch("designsafe.apps.onboarding.steps.access.RequestAccessStep.log") + def test_prepare(self, mock_log): + # prepare should log a STAFFWAIT state + step = RequestAccessStep(self.user) + step.prepare() + self.assertEqual(step.state, SetupState.PENDING) + mock_log.assert_called_with(ANY) + + @patch("designsafe.apps.onboarding.steps.access.RequestAccessStep.fail") + @patch("designsafe.apps.onboarding.steps.access.RequestAccessStep.complete") + def test_not_staff(self, mock_complete, mock_fail): + step = RequestAccessStep(self.user) + request = RequestFactory().post("/api/setup/test") + request.user = self.user + step.client_action("staff_approve", None, request) + mock_complete.assert_not_called() + mock_fail.assert_not_called() + + @patch("designsafe.apps.onboarding.steps.access.RequestAccessStep.complete") + def test_staff_approve(self, mock_complete): + # staff_approve should log a COMPLETED state + step = RequestAccessStep(self.user) + request = RequestFactory().post("/api/setup/test") + request.user = self.staff + step.client_action("staff_approve", None, request) + mock_complete.assert_called_with(ANY) + + @patch("designsafe.apps.onboarding.steps.access.RequestAccessStep.fail") + def test_fail_actions(self, mock_fail): + # staff_approve should log a FAILED state + step = RequestAccessStep(self.user) + request = RequestFactory().post("/api/setup/test") + request.user = self.staff + step.client_action("staff_deny", None, request) + mock_fail.assert_called_with(ANY) + step.client_action("invalid", None, request) + mock_fail.assert_called_with(ANY) diff --git a/designsafe/apps/onboarding/steps/allocation.py b/designsafe/apps/onboarding/steps/allocation.py new file mode 100644 index 0000000000..3d3dff3877 --- /dev/null +++ b/designsafe/apps/onboarding/steps/allocation.py @@ -0,0 +1,35 @@ +"""Allocation step for Onboarding.""" + +from designsafe.apps.onboarding.steps.abstract import AbstractStep +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.api.users.utils import get_allocations + + +class AllocationStep(AbstractStep): + """Allocation Access Step for Onboarding.""" + + def display_name(self): + return "Allocations" + + def description(self): + return """Accessing your allocations. If unsuccessful, verify the PI has added you to the allocations for this project.""" + + def prepare(self): + self.state = SetupState.PENDING + self.log("Awaiting allocation retrieval") + + def client_action(self, action, data, request): + if action == "user_confirm" and request.user.username == self.user.username: + self.prepare() + + def process(self): + self.state = SetupState.PROCESSING + self.log("Retrieving your allocations") + + # Force allocation retrieval from TAS and refresh elasticsearch + allocations = get_allocations(self.user, force=True) + if not allocations.get("hosts"): + self.state = SetupState.USERWAIT + self.log(f"User {self.user.username} does not have any allocations") + else: + self.complete("Allocations retrieved", data=allocations) diff --git a/designsafe/apps/onboarding/steps/allocation_unit_test.py b/designsafe/apps/onboarding/steps/allocation_unit_test.py new file mode 100644 index 0000000000..ed03eddd18 --- /dev/null +++ b/designsafe/apps/onboarding/steps/allocation_unit_test.py @@ -0,0 +1,58 @@ +from designsafe.apps.onboarding.steps.allocation import AllocationStep +from mock import ANY +import pytest + + +@pytest.fixture +def get_allocations_mock(mocker): + get_allocations = mocker.patch( + "designsafe.apps.onboarding.steps.allocation.get_allocations" + ) + get_allocations.return_value = { + "hosts": {"allocation": []}, + } + yield get_allocations + + +@pytest.fixture +def get_allocations_failure_mock(mocker): + get_allocations = mocker.patch( + "designsafe.apps.onboarding.steps.allocation.get_allocations" + ) + get_allocations.return_value = { + "hosts": {}, + } + yield get_allocations + + +@pytest.fixture +def allocation_step_complete_mock(mocker): + yield mocker.patch.object(AllocationStep, "complete") + + +@pytest.fixture +def allocation_step_log_mock(mocker): + yield mocker.patch.object(AllocationStep, "log") + + +def test_get_allocations( + regular_user, get_allocations_mock, allocation_step_complete_mock +): + step = AllocationStep(regular_user) + step.process() + get_allocations_mock.assert_called_with(regular_user, force=True) + allocation_step_complete_mock.assert_called_with( + "Allocations retrieved", + data={ + "hosts": {"allocation": []}, + }, + ) + + +def test_get_allocations_failure( + regular_user, get_allocations_failure_mock, allocation_step_log_mock +): + step = AllocationStep(regular_user) + step.process() + get_allocations_failure_mock.assert_called_with(regular_user, force=True) + allocation_step_log_mock.assert_called_with(ANY) diff --git a/designsafe/apps/onboarding/steps/project_membership.py b/designsafe/apps/onboarding/steps/project_membership.py new file mode 100644 index 0000000000..2d00def7aa --- /dev/null +++ b/designsafe/apps/onboarding/steps/project_membership.py @@ -0,0 +1,217 @@ +"""Project Membership Step for Onboarding.""" + +import logging +from requests.auth import HTTPBasicAuth +from django.conf import settings +from pytas.http import TASClient +from rt import Rt +from designsafe.apps.onboarding.steps.abstract import AbstractStep +from designsafe.apps.onboarding.state import SetupState + + +logger = logging.getLogger(__name__) + + +class ProjectMembershipStep(AbstractStep): + """Project Membership Step for Onboarding""" + + def __init__(self, user): + """ + Call super class constructor + """ + super().__init__(user) + self.project = self.get_tas_project() + self.user_confirm = "Request Project Access" + self.staff_approve = f"Add to {self.project['title']}" + self.staff_deny = "Deny Project Access Request" + + def get_tas_client(self): + """Get a TAS client""" + tas_client = TASClient( + baseURL=settings.TAS_URL, + credentials={ + "username": settings.TAS_CLIENT_KEY, + "password": settings.TAS_CLIENT_SECRET, + }, + ) + return tas_client + + def get_tas_project(self): + """Get a TAS project""" + return self.get_tas_client().project(self.settings["project_sql_id"]) + + def description(self): + if self.settings is not None and "description" in self.settings: + return self.settings["description"] + return """This confirms if you have access to the project. If not, request access and + wait for the system administrator’s approval.""" + + def display_name(self): + return "Checking Project Membership" + + def prepare(self): + """Prepare the step""" + self.state = SetupState.PENDING + self.log("Awaiting project membership check") + + def get_tracker(self): + """Get a RT client""" + return Rt( + settings.DJANGO_RT["RT_HOST"], + settings.DJANGO_RT["RT_UN"], + settings.DJANGO_RT["RT_PW"], + http_auth=HTTPBasicAuth( + settings.DJANGO_RT["RT_UN"], settings.DJANGO_RT["RT_PW"] + ), + ) + + def is_project_member(self): + """Check if the user is a member of the project in TAS""" + username = self.user.username + tas_client = self.get_tas_client() + project_users = tas_client.get_project_users(self.settings["project_sql_id"]) + return any(u["username"] == username for u in project_users) + + def send_project_request(self, request): + """Send a project request to the RT system""" + tracker = self.get_tracker() + ticket_text = f""" + User {self.user.username} is requesting membership on the {self.project["title"]} project. + Please visit {request.build_absolute_uri(f'/onboarding/setup/{self.user.username}')} + to complete this request. + """ + + try: + if tracker.login(): + result = tracker.create_ticket( + Queue=self.settings.get("rt_queue") or "Accounts", + Subject=f"{self.project['title']} Project Membership Request for {self.user.username}", + Text=ticket_text, + Requestors=self.user.email, + CF_resource=self.settings.get("rt_tag") or "", + ) + tracker.logout() + + if not result: + raise Exception( # pylint: disable=broad-exception-raised + "Could not create ticket" + ) + + self.state = SetupState.STAFFWAIT + self.log( + "Thank you for your request. It will be reviewed by TACC staff.", + data={"ticket": result}, + ) + else: + raise Exception( # pylint: disable=broad-exception-raised + "Could not log in to RT" + ) + except Exception as err: # pylint: disable=broad-except + logger.exception( + msg="Could not create ticket on behalf of user during ProjectMembershipStep" + ) + logger.error(err.args) + self.fail( + "We were unable to submit a portal access request ticket on your behalf." + ) + + def add_to_project(self): + """Add the user to the TAS project""" + tas_client = self.get_tas_client() + # Project number is not the GID number, but the primary key + # in the database for the project record. + # When viewing a project in tas.tacc.utexas.edu, you should see "?id=xxxxx" + # in the address bar. This is the SQL ID + try: + tas_client.add_project_user( + self.settings["project_sql_id"], self.user.username + ) + except Exception as exc: # pylint: disable=broad-except + reason = str(exc) + if "is already a member" in reason: + self.complete( + f"{self.user.username} is already a member of the {self.project['title']}" + ) + else: + self.fail( + f"{self.user.username} could not be added to {self.project['title']} due to error {reason}" + ) + raise exc + + def deny_project_request(self): + """Deny a project request and close the ticket""" + ticket_id = None + for event in self.events: + if event.data and "ticket" in event.data: + ticket_id = event.data["ticket"] + tracker = self.get_tracker() + request_text = f"""Your request for membership on the {self.project["title"]} project has been + denied. If you believe this is an error, please submit a help ticket. + """ + if tracker.login(): + tracker.reply(ticket_id, text=request_text) + tracker.comment( + ticket_id, + text=f"User was not added to the {self.project['title']} TAS Project (GID {self.project['gid']}) at https://{settings.SESSION_COOKIE_DOMAIN}", + ) + tracker.edit_ticket(ticket_id, Status="resolved") + else: + self.fail(f"The portal was unable to close RT Ticket {ticket_id}") + + def close_project_request(self): + """Close the project request RT ticket""" + ticket_id = None + for event in self.events: + if event.data and "ticket" in event.data: + ticket_id = event.data["ticket"] + tracker = self.get_tracker() + request_text = f"""Your request for membership on the {self.project["title"]} project has been + granted. Please login at https://{settings.SESSION_COOKIE_DOMAIN}/onboarding/setup to continue setting up your account. + """ + if tracker.login(): + tracker.reply(ticket_id, text=request_text) + tracker.comment( + ticket_id, + text=f"User has been added to the {self.project['title']} TAS Project (GID {self.project['gid']}) via https://{settings.SESSION_COOKIE_DOMAIN}", + ) + tracker.edit_ticket(ticket_id, Status="resolved") + else: + self.fail(f"The portal was unable to close RT Ticket {ticket_id}") + + def process(self): + if self.is_project_member(): + self.complete( + "You have the required project membership to access this portal." + ) + else: + self.state = SetupState.USERWAIT + data = None + if self.settings is not None and "userlink" in self.settings: + data = {"userlink": self.settings["userlink"]} + self.log("Please confirm your request to use this portal.", data=data) + + def client_action(self, action, data, request): + if action == "user_confirm": + self.send_project_request(request) + return + + if request.user.is_staff and action == "staff_approve": + try: + self.add_to_project() + self.close_project_request() + self.complete( + f"Portal access request approved by {request.user.username}" + ) + except Exception as err: # pylint: disable=broad-except + logger.exception( + msg=f"Error during staff_approve on {self.step_name()}" + ) + logger.error(err.args) + self.fail( + "An error occurred while trying to add this user to the project" + ) + elif action == "staff_deny": + self.deny_project_request() + self.fail("Portal access request has not been approved.") + else: + self.fail(f"Invalid client action {action}") diff --git a/designsafe/apps/onboarding/steps/project_membership_unit_test.py b/designsafe/apps/onboarding/steps/project_membership_unit_test.py new file mode 100644 index 0000000000..d51cb8474d --- /dev/null +++ b/designsafe/apps/onboarding/steps/project_membership_unit_test.py @@ -0,0 +1,196 @@ +from django.conf import settings +from designsafe.apps.onboarding.steps.project_membership import ProjectMembershipStep +from designsafe.apps.onboarding.models import SetupEvent +from mock import MagicMock, ANY +import pytest +import json +import os + + +@pytest.fixture +def tas_client(mocker): + with open( + os.path.join(settings.BASE_DIR, "designsafe/fixtures/tas/tas_project.json") + ) as f: + tas_project = json.load(f) + with open( + os.path.join( + settings.BASE_DIR, "designsafe/fixtures/tas/tas_project_users.json" + ) + ) as f: + tas_project_users = json.load(f) + tas_client_mock = mocker.patch( + "designsafe.apps.onboarding.steps.project_membership.TASClient", autospec=True + ) + tas_client_mock.return_value.project.return_value = tas_project + tas_client_mock.return_value.get_project_users.return_value = tas_project_users + yield tas_client_mock + + +@pytest.fixture +def mock_rt(mocker): + mock_tracker = mocker.patch( + "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep.get_tracker" + ) + mock_tracker.return_value.login.return_value = True + yield mock_tracker + + +@pytest.fixture +def project_membership_step(settings, regular_user, tas_client, mock_rt): + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "settings": {"project_sql_id": 12345}, + } + ] + step = ProjectMembershipStep(regular_user) + yield step + + +@pytest.fixture +def project_membership_step_with_userlink(settings, regular_user, tas_client): + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "settings": { + "project_sql_id": 12345, + "userlink": {"url": "/", "text": "Request Access"}, + }, + "retry": True, + } + ] + step = ProjectMembershipStep(regular_user) + yield step + + +@pytest.fixture +def project_membership_log(mocker): + yield mocker.patch.object(ProjectMembershipStep, "log") + + +@pytest.fixture +def project_membership_fail(mocker): + yield mocker.patch.object(ProjectMembershipStep, "fail") + + +@pytest.fixture +def project_membership_complete(mocker): + yield mocker.patch.object(ProjectMembershipStep, "complete") + + +def test_is_project_member(tas_client, project_membership_step): + assert project_membership_step.is_project_member() + tas_client.return_value.get_project_users.return_value = [] + assert not project_membership_step.is_project_member() + + +def test_process_user_is_member( + monkeypatch, project_membership_step, project_membership_complete +): + def mock_is_project_member(): + return True + + monkeypatch.setattr( + project_membership_step, "is_project_member", mock_is_project_member + ) + project_membership_step.process() + project_membership_complete.assert_called_with( + "You have the required project membership to access this portal." + ) + + +def test_process_user_is_not_member( + monkeypatch, project_membership_step, project_membership_log +): + def mock_is_project_member(): + return False + + monkeypatch.setattr( + project_membership_step, "is_project_member", mock_is_project_member + ) + project_membership_step.process() + project_membership_log.assert_called_with( + "Please confirm your request to use this portal.", data=None + ) + + +def test_process_userlink( + monkeypatch, project_membership_step_with_userlink, project_membership_log +): + def mock_is_project_member(): + return False + + monkeypatch.setattr( + project_membership_step_with_userlink, + "is_project_member", + mock_is_project_member, + ) + project_membership_step_with_userlink.process() + project_membership_log.assert_called_with( + "Please confirm your request to use this portal.", + data={"userlink": {"url": "/", "text": "Request Access"}}, + ) + + +def test_send_project_request( + rf, project_membership_step, project_membership_log, mock_rt, regular_user +): + request = rf.get("https://cep.dev/") + request.user = regular_user + project_membership_step.send_project_request(request) + mock_rt.return_value.create_ticket.assert_called() + + +def test_add_to_project(regular_user, project_membership_step, tas_client): + project_membership_step.add_to_project() + tas_client.return_value.add_project_user.assert_called_with( + 12345, regular_user.username + ) + + +def test_close_project_request(regular_user, project_membership_step, mock_rt): + project_membership_step.events = [ + SetupEvent(user=regular_user), + SetupEvent(user=regular_user, data={}), + SetupEvent(user=regular_user, data={"ticket": "1234"}), + SetupEvent(user=regular_user, data={"ticket": "12345"}), + ] + project_membership_step.close_project_request() + mock_rt.return_value.reply.assert_called_with("12345", text=ANY) + mock_rt.return_value.comment.assert_called_with("12345", text=ANY) + mock_rt.return_value.edit_ticket.assert_called_with("12345", Status="resolved") + + +def test_client_action( + regular_user, rf, monkeypatch, project_membership_step, project_membership_complete +): + request = rf.get("/api/onboarding") + request.user = regular_user + mock_send = MagicMock() + mock_add = MagicMock() + mock_close = MagicMock() + monkeypatch.setattr(project_membership_step, "send_project_request", mock_send) + monkeypatch.setattr(project_membership_step, "add_to_project", mock_add) + monkeypatch.setattr(project_membership_step, "close_project_request", mock_close) + project_membership_step.client_action("user_confirm", {}, request) + mock_send.assert_called_with(request) + request.user.is_staff = True + project_membership_step.client_action("staff_approve", {}, request) + mock_add.assert_called_with() + mock_close.assert_called_with() + project_membership_complete.assert_called_with(ANY) + + +def test_client_action_fail( + rf, regular_user, monkeypatch, project_membership_step, project_membership_fail +): + mock_add = MagicMock(side_effect=Exception("Mock exception", "Mock reason")) + monkeypatch.setattr(project_membership_step, "add_to_project", mock_add) + request = rf.get("/api/onboarding") + request.user = regular_user + request.user.is_staff = True + project_membership_step.client_action("staff_approve", {}, request) + project_membership_fail.assert_called_with( + "An error occurred while trying to add this user to the project" + ) diff --git a/designsafe/apps/onboarding/steps/system_access.py b/designsafe/apps/onboarding/steps/system_access.py new file mode 100644 index 0000000000..24e6c6b184 --- /dev/null +++ b/designsafe/apps/onboarding/steps/system_access.py @@ -0,0 +1,57 @@ +"""System Allocation Access Step for Onboarding.""" + +import logging +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.api.users.utils import get_allocations +from .project_membership import ProjectMembershipStep + +logger = logging.getLogger(__name__) + + +class SystemAccessStep(ProjectMembershipStep): + """System Access Step for Onboarding.""" + + def __init__(self, user): + """ + Call super class constructor + """ + super().__init__(user) + self.user_confirm = "Request System Access" + self.staff_deny = "Deny System Access Request" + + def display_name(self): + return "Checking System Access" + + def description(self): + return """This confirms if you have access to the required HPC systems to + access this portal. If not, request access and wait for the + system administrator’s approval.""" + + def prepare(self): + self.state = SetupState.PENDING + self.log("Awaiting system access check") + + def has_required_systems(self): + """Check if the user has the required systems for accessing the portal.""" + + systems = self.settings["required_systems"] + if len(systems) == 0: + return True + + resources = [] + try: + resources = get_allocations(self.user)["hosts"].keys() + # If the intersection of the set of systems and resources has + # items, the user has the necessary allocation + return len(set(systems).intersection(resources)) > 0 + except Exception as exc: # pylint: disable=broad-except + logger.error(exc) + self.fail("We were unable to retrieve your allocations.") + return False + + def process(self): + if self.has_required_systems() or self.is_project_member(): + self.complete("You have the required systems for accessing this portal") + else: + self.state = SetupState.USERWAIT + self.log("Please confirm your request to use this portal.") diff --git a/designsafe/apps/onboarding/steps/system_access_unit_test.py b/designsafe/apps/onboarding/steps/system_access_unit_test.py new file mode 100644 index 0000000000..6cad3c5ea2 --- /dev/null +++ b/designsafe/apps/onboarding/steps/system_access_unit_test.py @@ -0,0 +1,49 @@ +from django.conf import settings +from designsafe.apps.onboarding.steps.system_access import SystemAccessStep +import pytest +import json +import os + + +@pytest.fixture +def tas_client(mocker): + with open( + os.path.join(settings.BASE_DIR, "designsafe/fixtures/tas/tas_project.json") + ) as f: + tas_project = json.load(f) + tas_client_mock = mocker.patch( + "designsafe.apps.onboarding.steps.project_membership.TASClient", autospec=True + ) + tas_client_mock.return_value.project.return_value = tas_project + tas_client_mock.return_value.projects_for_user.return_value = [tas_project] + yield tas_client_mock + + +@pytest.fixture +def mock_user_allocations(mocker): + yield mocker.patch( + "designsafe.apps.onboarding.steps.system_access.get_allocations", autospec=True + ) + + +@pytest.fixture +def system_access_step(settings, regular_user, tas_client, mock_user_allocations): + settings.PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.system_access.SystemAccessStep", + "settings": { + "required_systems": [ + "stampede2.tacc.utexas.edu", + "ls5.tacc.utexas.edu", + ], + "project_sql_id": 12345, + }, + } + ] + step = SystemAccessStep(regular_user) + yield step + + +def test_not_has_required_systems(system_access_step, mock_user_allocations): + mock_user_allocations.return_value = {"hosts": {}} + assert not system_access_step.has_required_systems() diff --git a/designsafe/apps/onboarding/steps/system_access_v3.py b/designsafe/apps/onboarding/steps/system_access_v3.py new file mode 100644 index 0000000000..12bbfcf523 --- /dev/null +++ b/designsafe/apps/onboarding/steps/system_access_v3.py @@ -0,0 +1,179 @@ +"""System Access Step for Onboarding.""" + +import logging +import requests +from requests.exceptions import HTTPError +from django.conf import settings +from tapipy.errors import ( + NotFoundError, + BaseTapyException, + ForbiddenError, + UnauthorizedError, +) +from designsafe.apps.onboarding.steps.abstract import AbstractStep +from designsafe.apps.onboarding.state import SetupState +from designsafe.utils.encryption import createKeyPair +from designsafe.apps.api.agave import get_service_account_client, get_tg458981_client +from designsafe.apps.api.tasks import agave_indexer + + +logger = logging.getLogger(__name__) + + +def create_system_credentials( # pylint: disable=too-many-arguments + client, + username, + public_key, + private_key, + system_id, + skipCredentialCheck=False, # pylint: disable=invalid-name +) -> int: + """ + Set an RSA key pair as the user's auth credential on a Tapis system. + """ + logger.info(f"Creating user credential for {username} on Tapis system {system_id}") + data = {"privateKey": private_key, "publicKey": public_key} + client.systems.createUserCredential( + systemId=system_id, + userName=username, + skipCredentialCheck=skipCredentialCheck, + **data, + ) + + +def register_public_key( + username, publicKey, system_id # pylint: disable=invalid-name +) -> int: + """ + Push a public key to the Key Service API. + """ + url = "https://api.tacc.utexas.edu/keys/v2/" + username + headers = {"Authorization": f"Bearer {settings.KEY_SERVICE_TOKEN}"} + data = {"key_value": publicKey, "tags": [{"name": "system", "value": system_id}]} + response = requests.post(url, json=data, headers=headers, timeout=60) + response.raise_for_status() + return response.status_code + + +def set_user_permissions(user, system_id): + """Apply read/write/execute permissions to files and read permissions on the system.""" + logger.info(f"Adding {user.username} permissions to Tapis system {system_id}") + client = get_service_account_client() + client.systems.grantUserPerms( + systemId=system_id, userName=user.username, permissions=["READ"] + ) + client.files.grantPermissions( + systemId=system_id, path="/", username=user.username, permission="MODIFY" + ) + + +def create_path_and_permissions(system_id, path, username) -> None: + """Create a path and set permissions for a user on a system.""" + + tg458981_client = get_tg458981_client() + + # Create directory, resolves NotFoundError + tg458981_client.files.mkdir(systemId=system_id, path=path) + + # Set ACLs, resolves UnauthorizedError and ForbiddenError + tg458981_client.files.setFacl( + systemId=system_id, + path=path, + operation="ADD", + recursionMethod="PHYSICAL", + aclString=f"d:u:{username}:rwX,u:{username}:rwX,d:u:tg458981:rwX,u:tg458981:rwX,d:o::---,o::---,d:m::rwX,m::rwX", + ) + agave_indexer.apply_async( + kwargs={"systemId": system_id, "filePath": path, "recurse": False}, + queue="indexing", + ) + + +class SystemAccessStepV3(AbstractStep): + """System Access Step for Onboarding.""" + + def display_name(self): + return "System Access" + + def description(self): + return "Setting up access to TACC storage and execution systems. No action required." + + def prepare(self): + self.state = SetupState.PENDING + self.log("Awaiting TACC systems access.") + + def check_system(self, system_id, path="/") -> None: + """ + Check whether a user already has access to a storage system by attempting a listing. + """ + self.user.tapis_oauth.client.files.listFiles(systemId=system_id, path=path) + + def process(self): + self.log(f"Processing system access for user {self.user.username}") + for system in self.settings.get("access_systems") or []: + try: + set_user_permissions(self.user, system) + self.log(f"Successfully granted permissions for system: {system}") + except BaseTapyException as exc: + logger.error(exc) + self.fail(f"Failed to grant permissions for system: {system}") + + for system in self.settings.get("credentials_systems") or []: + try: + self.check_system(system) + self.log(f"Credentials already created for system: {system}") + continue + except BaseTapyException: + self.log(f"Creating credentials for system: {system}") + + (priv, pub) = createKeyPair() + + try: + register_public_key(self.user.username, pub, system) + self.log(f"Successfully registered public key for system: {system}") + except HTTPError as exc: + logger.error(exc) + self.fail( + f"Failed to register public key with key service for system: {system}" + ) + + try: + create_system_credentials( + self.user.tapis_oauth.client, self.user.username, pub, priv, system + ) + self.log(f"Successfully created credentials for system: {system}") + except BaseTapyException as exc: + logger.error(exc) + self.fail(f"Failed to create credentials for system: {system}") + + for system in self.settings.get("create_path_systems") or []: + system_id = system["system_id"] + path = system["path"].format(username=self.user.username) + try: + self.check_system(system_id, path) + self.log( + f"Path and permissions already created for system: {system_id} and path: {path} for user: {self.user.username}" + ) + continue + except (NotFoundError, ForbiddenError, UnauthorizedError): + logger.info( + "Ensuring directory exists for user=%s then going to run setfacl on system=%s path=%s", + self.user.username, + system_id, + path, + ) + self.log(f"Creating directory tapis://{system_id}/{path} for user") + + create_path_and_permissions(system_id, path, self.user.username) + + # Check if the path with permissions was created successfully + self.check_system(system_id, path) + + except BaseTapyException as exc: + logger.error(exc) + self.fail( + f"Failed to create path and permissions for system:{system} path:{path} for {self.user.username}" + ) + + if self.state != SetupState.FAILED: + self.complete("User is processed.") diff --git a/designsafe/apps/onboarding/steps/test_steps.py b/designsafe/apps/onboarding/steps/test_steps.py new file mode 100644 index 0000000000..7447c0cc98 --- /dev/null +++ b/designsafe/apps/onboarding/steps/test_steps.py @@ -0,0 +1,160 @@ +from mock import MagicMock +from designsafe.apps.onboarding.state import SetupState +from designsafe.apps.onboarding.steps.abstract import AbstractStep + + +class MockStep(AbstractStep): + """ + Fixture for testing AbstractStep, that + simply calls spy methods + """ + + def __init__(self, user): + self.prepare_spy = MagicMock() + super().__init__(user) + + def display_name(self): + return "Mock Step" + + def description(self): + return "Long description for a mock step" + + def prepare(self): + self.prepare_spy() + + +class MockProcessingCompleteStep(AbstractStep): + """ + Fixture for testing automated processing steps that complete successfully + """ + + def __init__(self, user): + super().__init__(user) + self.process_spy = MagicMock() + + def prepare(self): + self.state = SetupState.PENDING + self.log("Pending") + + def display_name(self): + return "Mock Processing Complete Step" + + def description(self): + return "Long description of a mock step that automatically processes then completes" + + def process(self): + self.complete("Completed") + self.process_spy() + + +class MockProcessingFailStep(AbstractStep): + """ + Fixture for testing automated processing steps that fail + """ + + def __init__(self, user): + super().__init__(user) + self.process_spy = MagicMock() + + def prepare(self): + self.state = SetupState.PENDING + self.log("Pending") + + def display_name(self): + return "Mock Processing Fail Step" + + def description(self): + return "Long description of a mock step that automatically processes then fails" + + def process(self): + self.fail("Failure") + self.process_spy() + + +class MockUserStep(AbstractStep): + """ + Fixture for testing steps that block for client action + from the user + """ + + def __init__(self, user): + super().__init__(user) + self.client_action_spy = MagicMock() + + def prepare(self): + self.state = SetupState.USERWAIT + self.log("Waiting for user") + + def display_name(self): + return "Mock User Wait Step" + + def description(self): + return "Long description of a mock step that waits for user interaction" + + def client_action(self, action, data, request): + if action == "user_confirm" and request.user is self.user: + self.complete("Complete") + self.client_action_spy(action, data, request) + + +class MockStaffStep(AbstractStep): + """ + Fixture for testing AbstractStaffSteps + """ + + def __init__(self, user): + super().__init__(user) + self.staff_approve_spy = MagicMock() + self.staff_deny_spy = MagicMock() + + def prepare(self): + self.state = SetupState.STAFFWAIT + self.log("Waiting for staff") + + def display_name(self): + return "Mock Staff Wait Step" + + def description(self): + return "Long description of a mock step that waits for staff approval or denial" + + def client_action(self, action, data, request): + if not request.user.is_staff: + return + + if action == "staff_approve": + self.complete("Approved by {user}".format(user=request.user.username)) + self.staff_approve_spy(action, data, request) + elif action == "staff_deny": + self.fail("Denied by {user}".format(user=request.user.username)) + self.staff_deny_spy(action, data, request) + + +class MockErrorStep(AbstractStep): + """ + Fixture for testing steps that generate exceptions + """ + + def __init__(self, user): + super().__init__(user) + + def prepare(self): + self.state = SetupState.PENDING + self.log("Pending") + + def display_name(self): + return "Mock Error Step" + + def description(self): + return "Long description of a mock step that results in error upon processing" + + def process(self): + raise Exception("MockErrorStep") + + +class MockInvalidStepClass: + def __init__(self, user): + pass + + +def mock_invalid_step_function(): + pass diff --git a/designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.j2 b/designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.j2 new file mode 100644 index 0000000000..6da365c964 --- /dev/null +++ b/designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.j2 @@ -0,0 +1,24 @@ +{% extends "base_angular.html" %} +{% load cms_tags static sekizai_tags%} +{% block title %}Onboarding{% endblock %} +{% block content %} + +
+ +{% addtoblock "react_assets" %} +{% if debug and react_flag %} + + + +{% else %} +{% include "react-assets.html" %} +{% endif %} +{% endaddtoblock %} + +{% endblock %} diff --git a/designsafe/apps/onboarding/urls.py b/designsafe/apps/onboarding/urls.py new file mode 100644 index 0000000000..a5554205b9 --- /dev/null +++ b/designsafe/apps/onboarding/urls.py @@ -0,0 +1,10 @@ +"""URLs for the Onboarding app.""" + +from django.urls import re_path, path +from designsafe.apps.onboarding.views import OnboardingView + +app_name = "workbench" +urlpatterns = [ + re_path("^", OnboardingView.as_view(), name="user"), + path("admin", OnboardingView.as_view(), name="admin"), +] diff --git a/designsafe/apps/onboarding/views.py b/designsafe/apps/onboarding/views.py new file mode 100644 index 0000000000..e86da1e4df --- /dev/null +++ b/designsafe/apps/onboarding/views.py @@ -0,0 +1,18 @@ +"""Views for Onboarding""" + +from django.views.generic.base import TemplateView +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie +from django.contrib.auth.decorators import login_required + + +@method_decorator(login_required, name="dispatch") +class OnboardingView(TemplateView): + """Onboarding View""" + + template_name = "designsafe/apps/onboarding/index.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, request, *args, **kwargs): + """Overwrite dispatch to ensure csrf cookie""" + return super().dispatch(request, *args, **kwargs) diff --git a/designsafe/apps/workspace/api/views.py b/designsafe/apps/workspace/api/views.py index 6d8e746398..37ce7eb98c 100644 --- a/designsafe/apps/workspace/api/views.py +++ b/designsafe/apps/workspace/api/views.py @@ -11,9 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models import F, Count from django.db.models.lookups import GreaterThan -from django.contrib.auth import get_user_model from django.urls import reverse -from pytas.http import TASClient from tapipy.errors import InternalServerError, UnauthorizedError from designsafe.apps.api.exceptions import ApiException from designsafe.apps.api.users.utils import get_user_data @@ -26,7 +24,7 @@ AppTrayCategory, AppVariant, ) -from designsafe.apps.workspace.models.allocations import UserAllocations +from designsafe.apps.api.users.utils import get_allocations from designsafe.apps.workspace.api.utils import check_job_for_timeout @@ -114,61 +112,6 @@ def _get_app(app_id, app_version, user): return data -def _get_tas_allocations(username): - """Returns user allocations on TACC resources - - : returns: allocations - : rtype: dict - """ - - tas_client = TASClient( - baseURL=settings.TAS_URL, - credentials={ - "username": settings.TAS_CLIENT_KEY, - "password": settings.TAS_CLIENT_SECRET, - }, - ) - tas_projects = tas_client.projects_for_user(username) - - with open( - "designsafe/apps/workspace/api/tas_to_tacc_resources.json", encoding="utf-8" - ) as file: - tas_to_tacc_resources = json.load(file) - - hosts = {} - - for tas_proj in tas_projects: - # Each project from tas has an array of length 1 for its allocations - alloc = tas_proj["allocations"][0] - charge_code = tas_proj["chargeCode"] - if alloc["resource"] in tas_to_tacc_resources: - resource = dict(tas_to_tacc_resources[alloc["resource"]]) - resource["allocation"] = dict(alloc) - - # Separate active and inactive allocations and make single entry for each project - if resource["allocation"]["status"] == "Active": - if ( - resource["host"] in hosts - and charge_code not in hosts[resource["host"]] - ): - hosts[resource["host"]].append(charge_code) - elif resource["host"] not in hosts: - hosts[resource["host"]] = [charge_code] - return { - "hosts": hosts, - } - - -def _get_latest_allocations(username): - """ - Creates or updates allocations cache for a given user and returns new allocations - """ - user = get_user_model().objects.get(username=username) - allocations = _get_tas_allocations(username) - UserAllocations.objects.update_or_create(user=user, defaults={"value": allocations}) - return allocations - - def test_system_needs_keys(tapis, system_id): """Tests a Tapis system by making a file listing call. @@ -726,9 +669,9 @@ def _submit_job(self, request, body, tapis, username): if not job_post.get("archiveSystemId"): job_post["archiveSystemId"] = settings.AGAVE_STORAGE_SYSTEM if not job_post.get("archiveSystemDir"): - job_post[ - "archiveSystemDir" - ] = f"{username}/tapis-jobs-archive/${{JobCreateDate}}/${{JobName}}-${{JobUUID}}" + job_post["archiveSystemDir"] = ( + f"{username}/tapis-jobs-archive/${{JobCreateDate}}/${{JobName}}-${{JobUUID}}" + ) # Check for and set license environment variable if app requires one lic_type = body.get("licenseType") @@ -894,37 +837,13 @@ def post(self, request, *args, **kwargs): class AllocationsView(AuthenticatedApiView): """Allocations API View""" - def _get_allocations(self, user, force=False): - """ - Returns indexed allocation data stored in Django DB, or fetches - allocations from TAS and stores them. - Parameters - ---------- - username: str - TACC username to fetch allocations for. - Returns - ------- - dict - """ - username = user.username - try: - if force: - logger.info(f"Forcing TAS allocation retrieval for user:{username}") - raise ObjectDoesNotExist - result = {"hosts": {}} - result.update(UserAllocations.objects.get(user=user).value) - return result - except ObjectDoesNotExist: - # Fall back to getting allocations from TAS - return _get_latest_allocations(username) - def get(self, request): """Returns active user allocations on TACC resources : returns: {'response': {'active': allocations, 'portal_alloc': settings.PORTAL_ALLOCATION, 'inactive': inactive, 'hosts': hosts}} : rtype: dict """ - data = self._get_allocations(request.user) + data = get_allocations(request.user) # Exclude allocation based on allocation setting list. for host, allocations in data["hosts"].items(): data["hosts"][host] = [ diff --git a/designsafe/conftest.py b/designsafe/conftest.py index d32b49dc54..603bd2be35 100644 --- a/designsafe/conftest.py +++ b/designsafe/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import patch from django.conf import settings from designsafe.apps.auth.models import TapisOAuthToken +from designsafe.apps.accounts.models import DesignSafeProfile @pytest.fixture @@ -24,7 +25,7 @@ def regular_user(django_user_model, mock_tapis_client): password="password", first_name="Firstname", last_name="Lastname", - email="user@user.com", + email="user@designsafe-ci.org", ) user = django_user_model.objects.get(username="username") TapisOAuthToken.objects.create( @@ -34,6 +35,7 @@ def regular_user(django_user_model, mock_tapis_client): expires_in=14400, created=1523633447, ) + DesignSafeProfile.objects.create(user=user) yield user @@ -41,12 +43,12 @@ def regular_user(django_user_model, mock_tapis_client): @pytest.fixture def regular_user_using_jwt(regular_user, client): """Fixture for regular user who is using jwt for authenticated requests""" - with patch('designsafe.apps.api.decorators.Tapis') as mock_tapis: + with patch("designsafe.apps.api.decorators.Tapis") as mock_tapis: # Mock the Tapis's validate_token method within the tapis_jwt_login decorator mock_validate_token = mock_tapis.return_value.validate_token mock_validate_token.return_value = {"tapis/username": regular_user.username} - client.defaults['HTTP_X_TAPIS_TOKEN'] = 'fake_token_string' + client.defaults["HTTP_X_TAPIS_TOKEN"] = "fake_token_string" yield client @@ -80,3 +82,26 @@ def tapis_tokens_create_mock(): "r", ) ) + + +@pytest.fixture +def staff_user(django_user_model, mock_tapis_client): + django_user_model.objects.create_user(username="staff", password="password") + user = django_user_model.objects.get(username="staff") + user.is_staff = True + user.save() + TapisOAuthToken.objects.create( + user=user, + access_token="1234fsf", + refresh_token="123123123", + expires_in=14400, + created=1523633447, + ) + DesignSafeProfile.objects.create(user=user) + yield user + + +@pytest.fixture +def authenticated_staff(client, staff_user): + client.force_login(staff_user) + return staff_user diff --git a/designsafe/fixtures/tas/tas_add_user_to_project.json b/designsafe/fixtures/tas/tas_add_user_to_project.json new file mode 100644 index 0000000000..5e213e4631 --- /dev/null +++ b/designsafe/fixtures/tas/tas_add_user_to_project.json @@ -0,0 +1,68 @@ +{ + "status": "success", + "message": null, + "result": { + "id": 11111, + "title": "My Test Project", + "description": "A test project for allocations requests.", + "chargeCode": "My-Test-Project", + "gid": 22222, + "source": null, + "fieldId": 92, + "field": "Software Development", + "typeId": 2, + "type": "Startup", + "piId": 333333, + "pi": { + "id": 333333, + "username": "username", + "email": "username@something.com", + "firstName": "First", + "lastName": "Last", + "institution": "University of Texas at Austin (UT) (UT Austin)", + "institutionId": 1, + "department": null, + "departmentId": 0, + "country": "United States", + "countryId": 230, + "citizenship": "United States", + "citizenshipId": 230, + "piEligibility": "Eligible", + "source": "Standard", + "phone": "512123456", + "title": "Center Non-Researcher Staff", + "uid": 444444, + "homeDirectory": "01234/username", + "gid": 555555, + "emailConfirmations": [] + }, + "allocations": [ + { + "id": 66666, + "start": "2021-08-24T05:00:00Z", + "end": "2021-12-31T06:00:00Z", + "status": "Active", + "justification": "Admin-created allocation.", + "decisionSummary": "created test allocation for TACC staff", + "dateRequested": "2021-08-24T21:10:11Z", + "dateReviewed": "2021-08-24T21:10:11Z", + "computeRequested": 500, + "computeAllocated": 500, + "storageRequested": 0, + "storageAllocated": 0, + "memoryRequested": 0, + "memoryAllocated": 0, + "resourceId": 56, + "resource": "Frontera", + "projectId": 42237, + "project": "My-Test-Project", + "requestorId": 333333, + "requestor": "User Name", + "reviewerId": 0, + "reviewer": null, + "computeUsed": 0.0 + } + ], + "nickname": null + } +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_add_user_to_project_error.json b/designsafe/fixtures/tas/tas_add_user_to_project_error.json new file mode 100644 index 0000000000..5764bcd246 --- /dev/null +++ b/designsafe/fixtures/tas/tas_add_user_to_project_error.json @@ -0,0 +1,5 @@ +{ + "status": "error", + "message": "Cannot add user. User username is already a member of project 42237", + "result": null +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_delete_user_from_project.json b/designsafe/fixtures/tas/tas_delete_user_from_project.json new file mode 100644 index 0000000000..24916e8593 --- /dev/null +++ b/designsafe/fixtures/tas/tas_delete_user_from_project.json @@ -0,0 +1,68 @@ +{ + "status": "success", + "message": null, + "result": { + "id": 11111, + "title": "My Test Project", + "description": "A test project for allocations requests.", + "chargeCode": "My-Test-Project", + "gid": 22222, + "source": null, + "fieldId": 92, + "field": "Software Development", + "typeId": 2, + "type": "Startup", + "piId": 333333, + "pi": { + "id": 333333, + "username": "username", + "email": "username@something.com", + "firstName": "First", + "lastName": "Last", + "institution": "University of Texas at Austin (UT) (UT Austin)", + "institutionId": 1, + "department": null, + "departmentId": 0, + "country": "United States", + "countryId": 230, + "citizenship": "United States", + "citizenshipId": 230, + "piEligibility": "Eligible", + "source": "Standard", + "phone": "512123456", + "title": "Center Non-Researcher Staff", + "uid": 444444, + "homeDirectory": "01234/username", + "gid": 555555, + "emailConfirmations": [] + }, + "allocations": [ + { + "id": 66666, + "start": "2021-08-24T05:00:00Z", + "end": "2021-12-31T06:00:00Z", + "status": "Active", + "justification": "Admin-created allocation.", + "decisionSummary": "created test allocation for TACC staff", + "dateRequested": "2021-08-24T21:10:11Z", + "dateReviewed": "2021-08-24T21:10:11Z", + "computeRequested": 500, + "computeAllocated": 500, + "storageRequested": 0, + "storageAllocated": 0, + "memoryRequested": 0, + "memoryAllocated": 0, + "resourceId": 56, + "resource": "Frontera", + "projectId": 42237, + "project": "My-Test-Project", + "requestorId": 333333, + "requestor": "User Name", + "reviewerId": 0, + "reviewer": null, + "computeUsed": 0.0 + } + ], + "nickname": null + } +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_delete_user_from_project_error.json b/designsafe/fixtures/tas/tas_delete_user_from_project_error.json new file mode 100644 index 0000000000..5764bcd246 --- /dev/null +++ b/designsafe/fixtures/tas/tas_delete_user_from_project_error.json @@ -0,0 +1,5 @@ +{ + "status": "error", + "message": "Cannot add user. User username is already a member of project 42237", + "result": null +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_project.json b/designsafe/fixtures/tas/tas_project.json new file mode 100644 index 0000000000..ba80e3412b --- /dev/null +++ b/designsafe/fixtures/tas/tas_project.json @@ -0,0 +1,64 @@ +{ + "id": 12345, + "title": "WMA-Stampede2-Test", + "description": "Project Description", + "chargeCode": "WMA-Stampede2-Test", + "gid": 123456, + "source": null, + "fieldId": 76, + "field": "Software Engineering", + "typeId": 2, + "type": "Startup", + "piId": 123456, + "pi": { + "id": 123456, + "username": "pi_username", + "email": "pi_username@email.com", + "firstName": "First", + "lastName": "Last", + "institution": "University of Texas at Austin", + "institutionId": 1, + "department": "Texas Advanced Computing Center", + "departmentId": 127, + "country": "United States", + "countryId": 230, + "citizenship": "United States", + "citizenshipId": 230, + "piEligibility": "Eligible", + "source": "Standard", + "phone": "1234567890", + "title": "Center Non-Researcher Staff", + "uid": 123456, + "homeDirectory": "12345/pi_username", + "gid": 123456, + "emailConfirmations": [] + }, + "allocations": [ + { + "id": 60643, + "start": "2018-10-23T05:00:00Z", + "end": "2019-03-31T05:00:00Z", + "status": "Inactive", + "justification": "Project Justification", + "decisionSummary": "Decision Summary", + "dateRequested": "2018-10-23T15:39:39Z", + "dateReviewed": "2018-10-23T17:06:32Z", + "computeRequested": 100, + "computeAllocated": 100, + "storageRequested": 0, + "storageAllocated": 0, + "memoryRequested": 0, + "memoryAllocated": 0, + "resourceId": 49, + "resource": "Stampede4", + "projectId": 39726, + "project": "WMA-Stampede2-Test", + "requestorId": 171476, + "requestor": "pi_username", + "reviewerId": 0, + "reviewer": null, + "computeUsed": 0 + } + ], + "nickname": null +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_project_users.json b/designsafe/fixtures/tas/tas_project_users.json new file mode 100644 index 0000000000..af3e657eb7 --- /dev/null +++ b/designsafe/fixtures/tas/tas_project_users.json @@ -0,0 +1,26 @@ +[ + { + "email": "pi_user@username.com", + "firstName": "PILastname", + "id": 1234567, + "lastName": "PIFirstname", + "role": "PI", + "username": "username" + }, + { + "email": "deleage_user@username.com", + "firstName": "DelegateLastname", + "id": 123456, + "lastName": "DelegateFirstname", + "role": "Delegate", + "username": "username" + }, + { + "email": "user@username.com", + "firstName": "Lastname", + "id": 12345, + "lastName": "Firstname", + "role": "Standard", + "username": "username" + } +] \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_user.json b/designsafe/fixtures/tas/tas_user.json new file mode 100644 index 0000000000..b1c59bc4d9 --- /dev/null +++ b/designsafe/fixtures/tas/tas_user.json @@ -0,0 +1,23 @@ +{ + "id": 123456, + "username": "username", + "email": "user@username.com", + "firstName": "Firstname", + "lastName": "Lastname", + "institution": "University of Texas at Austin", + "institutionId": 1, + "department": "Texas Advanced Computing Center", + "departmentId": 127, + "country": "United States", + "countryId": 230, + "citizenship": "United States", + "citizenshipId": 230, + "piEligibility": "Eligible", + "source": "Standard", + "phone": "5125125125", + "title": "Center Non-Researcher Staff", + "uid": 123456, + "homeDirectory": "01234/username", + "gid": 456789, + "emailConfirmations": [] +} \ No newline at end of file diff --git a/designsafe/fixtures/tas/tas_user_with_underscore.json b/designsafe/fixtures/tas/tas_user_with_underscore.json new file mode 100644 index 0000000000..f8419ecad4 --- /dev/null +++ b/designsafe/fixtures/tas/tas_user_with_underscore.json @@ -0,0 +1,23 @@ +{ + "id": 123456, + "username": "user_name", + "email": "user_name@username.com", + "firstName": "Firstname2", + "lastName": "Lastname2", + "institution": "University of Texas at Austin", + "institutionId": 1, + "department": "Texas Advanced Computing Center", + "departmentId": 127, + "country": "United States", + "countryId": 230, + "citizenship": "United States", + "citizenshipId": 230, + "piEligibility": "Eligible", + "source": "Standard", + "phone": "5125125125", + "title": "Center Non-Researcher Staff", + "uid": 123456, + "homeDirectory": "01234/user_name", + "gid": 456789, + "emailConfirmations": [] +} \ No newline at end of file diff --git a/designsafe/fixtures/user-data.json b/designsafe/fixtures/user-data.json index 5d1cb1c5db..534603d667 100644 --- a/designsafe/fixtures/user-data.json +++ b/designsafe/fixtures/user-data.json @@ -99,5 +99,13 @@ }, "model": "designsafe_accounts.notificationpreferences", "pk": 1 + }, + { + "model": "designsafe_accounts.designsafeprofile", + "pk": 1, + "fields": { + "user": 1, + "setup_complete": true + } } ] diff --git a/designsafe/settings/celery_settings.py.orig b/designsafe/settings/celery_settings.py.orig deleted file mode 100644 index 1b4e976f5d..0000000000 --- a/designsafe/settings/celery_settings.py.orig +++ /dev/null @@ -1,48 +0,0 @@ -""" -# Celery settings -""" -import os -from kombu import Exchange, Queue -#BROKER_URL = 'redis://redis:6379/0' -BROKER_URL_PROTOCOL = os.environ.get('BROKER_URL_PROTOCOL', 'amqp://') -BROKER_URL_USERNAME = os.environ.get('BROKER_URL_USERNAME', 'username') -BROKER_URL_PWD = os.environ.get('BROKER_URL_PWD', 'pwd') -BROKER_URL_HOST = os.environ.get('BROKER_URL_HOST', 'localhost') -BROKER_URL_PORT = os.environ.get('BROKER_URL_PORT', '123') -BROKER_URL_VHOST = os.environ.get('BROKER_URL_VHOST', 'vhost') -BROKER_URL = ''.join([BROKER_URL_PROTOCOL, BROKER_URL_USERNAME, ':', - BROKER_URL_PWD, '@', BROKER_URL_HOST, ':', - BROKER_URL_PORT, '/', BROKER_URL_VHOST]) -#BROKER_URL = 'amqp://designsafe:pwd@rabbitmq:5672//' -CELERY_RESULT_BACKEND_PROTOCOL = os.environ.get('CELERY_RESULT_BACKEND_PROTOCOL', 'redis://') -CELERY_RESULT_BACKEND_USERNAME = os.environ.get('CELERY_RESULT_BACKEND_USERNAME', 'username') -CELERY_RESULT_BACKEND_PWD = os.environ.get('CELERY_RESULT_BACKEND_PWD', 'pwd') -CELERY_RESULT_BACKEND_HOST = os.environ.get('CELERY_RESULT_BACKEND_HOST', 'localhost') -CELERY_RESULT_BACKEND_PORT = os.environ.get('CELERY_RESULT_BACKEND_PORT', '1234') -CELERY_RESULT_BACKEND_DB = os.environ.get('CELERY_RESULT_BACKEND_DB', '0') -CELERY_RESULT_BACKEND = ''.join([CELERY_RESULT_BACKEND_PROTOCOL, - CELERY_RESULT_BACKEND_HOST, ':', CELERY_RESULT_BACKEND_PORT, - '/', CELERY_RESULT_BACKEND_DB]) - -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' -CELERYD_HIJACK_ROOT_LOGGER = False -CELERYD_LOG_FORMAT = '[DJANGO] $(processName)s %(levelname)s %(asctime)s %(module)s '\ - '%(name)s.%(funcName)s:%(lineno)s: %(message)s' -#CELERY_ANOTATIONS = {'designsafe.apps.api.tasks.reindex_agave': {'time_limit': 60 * 15}} - - -CELERY_DEFAULT_EXCHANGE_TYPE = 'direct' -CELERY_QUEUES = ( - Queue('default', Exchange('default'), routing_key='default'), - #Use to queue indexing tasks - Queue('indexing', Exchange('io'), routing_key='io.indexing'), - #Use to queue tasks which handle files - Queue('files', Exchange('io'), routing_key='io.files'), - #Use to queue tasks which mainly call external APIs - Queue('api', Exchange('api'), routing_key='api.agave'), - ) -CELERY_DEFAULT_QUEUE = 'default' -CELERY_DEFAULT_EXCHANGE = 'default' -CELERY_DEFAULT_ROUTING_KEY = 'default' diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py index 98e05e81ee..237e365674 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -112,6 +112,7 @@ 'designsafe.apps.search', 'designsafe.apps.geo', 'designsafe.apps.rapid', + 'designsafe.apps.onboarding', #haystack integration 'haystack' @@ -543,11 +544,6 @@ AGAVE_USER_STORE_ID = os.environ.get('AGAVE_USER_STORE_ID', 'TACC') AGAVE_USE_SANDBOX = os.environ.get('AGAVE_USE_SANDBOX', 'False').lower() == 'true' -TAPIS_SYSTEMS_TO_CONFIGURE = [ - {"system_id": AGAVE_STORAGE_SYSTEM, "path": "{username}", "create_path": True}, - {"system_id": "cloud.data", "path": "/ ", "create_path": False}, -] - # Tapis Client Configuration PORTAL_ADMIN_USERNAME = os.environ.get('PORTAL_ADMIN_USERNAME') TAPIS_TENANT_BASEURL = os.environ.get('TAPIS_TENANT_BASEURL') @@ -708,3 +704,27 @@ STAFF_VPN_IP_PREFIX = os.environ.get("STAFF_VPN_IP_PREFIX", "129.114") USER_PROJECTS_LIMIT = os.environ.get("USER_PROJECTS_LIMIT", 500) + +# Onboarding +PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "settings": { + "project_sql_id": 34076, # project id for DesignSafe-Corral + "rt_queue": "DesignSafe-ci", + }, + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "settings": {}, + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "settings": { + "credentials_systems": ["cloud.data", "designsafe.storage.default"], + "create_path_systems": [ + {"system_id": "designsafe.storage.default", "path": "{username}"} + ], + }, + }, +] diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index 0e00f424eb..b45a0afaf6 100644 --- a/designsafe/settings/test_settings.py +++ b/designsafe/settings/test_settings.py @@ -44,7 +44,7 @@ INSTALLED_APPS = ( - + 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -110,6 +110,7 @@ 'designsafe.apps.search', 'designsafe.apps.geo', 'designsafe.apps.rapid', + 'designsafe.apps.onboarding', #haystack integration # 'haystack' @@ -548,11 +549,6 @@ AGAVE_SUPER_TOKEN = 'example_com_client_token' AGAVE_STORAGE_SYSTEM = 'storage.example.com' -TAPIS_SYSTEMS_TO_CONFIGURE = [ - {"system_id": AGAVE_STORAGE_SYSTEM, "path": "{username}", "create_path": True}, - {"system_id": "cloud.data", "path": "/ ", "create_path": False}, -] - # Tapis Client Configuration PORTAL_ADMIN_USERNAME = '' TAPIS_TENANT_BASEURL = 'https://designsafe.tapis.io' @@ -629,6 +625,20 @@ }, } +# Channels +WSGI_APPLICATION = 'designsafe.wsgi.application' +ASGI_APPLICATION = 'designsafe.asgi.application' +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(os.environ.get('WS_BACKEND_HOST'), + os.environ.get('WS_BACKEND_PORT'))], + }, + }, +} + + PORTAL_DATA_DEPOT_MANAGERS = { 'agave': 'designsafe.apps.api.agave.filemanager.private_data.PrivateDataFileManager', 'shared': 'designsafe.apps.api.agave.filemanager.shared_data.SharedDataFileManager', @@ -775,3 +785,26 @@ FEDORA_USERNAME = '' FEDORA_PASSWORD = '' FEDORA_CONTAINER= 'designsafe-publications-dev' + +# Onboarding +PORTAL_USER_ACCOUNT_SETUP_STEPS = [ + { + 'step': 'designsafe.apps.onboarding.steps.test_steps.MockStep', + 'settings': { + 'key': 'value' + } + } +] + +# TAS Authentication. +TAS_URL = 'https://test.com' +TAS_CLIENT_KEY = 'test' +TAS_CLIENT_SECRET = 'test' + +# Redmine Tracker Authentication. +RT_URL = 'test' +RT_HOST = 'https://test.com' +RT_UN = 'test' +RT_PW = 'test' +RT_QUEUE = 'test' +RT_TAG = 'test_tag' diff --git a/designsafe/static/scripts/dashboard/components/dashboard/dashboard.component.html b/designsafe/static/scripts/dashboard/components/dashboard/dashboard.component.html index 64adf97513..fefc8cf223 100644 --- a/designsafe/static/scripts/dashboard/components/dashboard/dashboard.component.html +++ b/designsafe/static/scripts/dashboard/components/dashboard/dashboard.component.html @@ -114,7 +114,7 @@

Notifications {{$ctrl. {{note.datetime | date}}
- {{note.message}} + {{note.event_type === 'interactive_session_ready' ? 'Ready to view' : note.message}}
diff --git a/designsafe/static/scripts/ng-designsafe/providers/notifications-provider.js b/designsafe/static/scripts/ng-designsafe/providers/notifications-provider.js index 00bb898e7b..2137a05237 100644 --- a/designsafe/static/scripts/ng-designsafe/providers/notifications-provider.js +++ b/designsafe/static/scripts/ng-designsafe/providers/notifications-provider.js @@ -121,7 +121,7 @@ function NotificationService( * @param {Object} msg */ function processToastr(e, msg) { - if (msg.event_type === 'job' || msg.event_type === 'interactive_session_ready') { + if (msg.event_type !== 'data_depot') { return; } diff --git a/designsafe/templates/base.j2 b/designsafe/templates/base.j2 index b6726de732..e33cfad357 100644 --- a/designsafe/templates/base.j2 +++ b/designsafe/templates/base.j2 @@ -149,7 +149,9 @@ lastName: "{{ request.user.last_name }}", email: "{{ request.user.email }}", institution: "{{ request.user.profile.institution }}", - homedir: "{{tas_homedir}}" + homedir: "{{tas_homedir}}", + isStaff: {{ request.user.is_staff|yesno:"true,false" }}, + setupComplete: {{ request.user.profile.setup_complete|yesno:"true,false" }}, }; diff --git a/designsafe/templates/includes/header.html b/designsafe/templates/includes/header.html index 88926234a0..938f898d84 100644 --- a/designsafe/templates/includes/header.html +++ b/designsafe/templates/includes/header.html @@ -50,6 +50,9 @@
  • Tools & Applications
  • Manage Account
  • My Tickets
  • + {% if user.is_staff %} +
  • Onboarding Admin
  • + {% endif %} {% if user|has_group:'Rapid Admin' %}
  • Rapid Admin
  • {% endif %} diff --git a/designsafe/urls.py b/designsafe/urls.py index 5b177213e9..8205038581 100644 --- a/designsafe/urls.py +++ b/designsafe/urls.py @@ -127,6 +127,12 @@ url(r'^register/$', RedirectView.as_view( pattern_name='designsafe_accounts:register', permanent=True), name='register'), + # onboarding + url(r'^onboarding/', include(('designsafe.apps.onboarding.urls', 'designsafe.apps.onboarding'), + namespace='designsafe_onboarding')), + path('api/onboarding/', include('designsafe.apps.onboarding.api.urls', namespace='designsafe_onboarding_api')), + + # dashboard url(r'^dashboard/', include(('designsafe.apps.dashboard.urls', 'designsafe.apps.dashboard'), namespace='designsafe_dashboard')), diff --git a/designsafe/utils/system_access.py b/designsafe/utils/system_access.py deleted file mode 100644 index 286301b051..0000000000 --- a/designsafe/utils/system_access.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -.. :module:: designsafe.utils.system_access - :synopsis: Utilities to register keys with key service and with Tapis -""" -import logging -import requests -from django.conf import settings - - -logger = logging.getLogger(__name__) - - -def create_system_credentials( # pylint: disable=too-many-arguments - client, - username, - public_key, - private_key, - system_id, - skipCredentialCheck=False, # pylint: disable=invalid-name -) -> int: - """ - Set an RSA key pair as the user's auth credential on a Tapis system. - """ - logger.info(f"Creating user credential for {username} on Tapis system {system_id}") - data = {"privateKey": private_key, "publicKey": public_key} - client.systems.createUserCredential( - systemId=system_id, - userName=username, - skipCredentialCheck=skipCredentialCheck, - **data, - ) - - -def register_public_key( - username, publicKey, system_id # pylint: disable=invalid-name -) -> int: - """ - Push a public key to the Key Service API. - """ - url = "https://api.tacc.utexas.edu/keys/v2/" + username - headers = {"Authorization": f"Bearer {settings.KEY_SERVICE_TOKEN}"} - data = {"key_value": publicKey, "tags": [{"name": "system", "value": system_id}]} - response = requests.post(url, json=data, headers=headers, timeout=60) - response.raise_for_status() - return response.status_code diff --git a/webpack.config.js b/webpack.config.js index 622f8cf565..e9dfcd4942 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -192,6 +192,15 @@ module.exports = (env) => { minify: false, } ), + new HtmlWebpackPlugin( + { + chunks: ['onboarding'], + inject : false, + template : './designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.j2', + filename: '../../apps/onboarding/templates/designsafe/apps/onboarding/index.html', + minify: false, + } + ), new webpack.ProvidePlugin({ jQuery: 'jquery', $: 'jquery',