From 15958d0394499df106cc4f3d9e6fa354ce64bebf Mon Sep 17 00:00:00 2001 From: van-go <35277477+van-go@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:39:03 -0600 Subject: [PATCH 01/12] Refactor ReviewAuthors component to use separate citation components --- .../ReviewAuthors.jsx | 108 +++++++++++++++--- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx index 2ad2edf3b..eaf4ca47d 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx @@ -15,29 +15,104 @@ import ReorderUserList from '../../utils/ReorderUserList/ReorderUserList'; import ProjectMembersList from '../../utils/ProjectMembersList/ProjectMembersList'; import { useSelector } from 'react-redux'; +const ACMCitation = ({ project, authors }) => { + const authorString = authors.map(a => `${a.first_name} ${a.last_name}`).join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const createdDate = new Date(project.created).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + return ( +
+ {`${authorString}. ${project.title}. `} Digital Rocks Portal {` (${createdDate}). ${projectUrl}`}
+ ); +}; + +const APACitation = ({ project, authors }) => { + const authorString = authors + .map((a) => `${a.last_name}, ${a.first_name.charAt(0)}.`) + .join(', '); + const projectUrl = `https://www.digitalrocksportal.org`; + const createdDateObj = new Date(project.created); + const createdDate = `${createdDateObj.getFullYear()}, ${createdDateObj.toLocaleString('en-US', { month: 'long' })} ${createdDateObj.getDate()}`; + const accessDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + + return ( +
{`${authorString} (${createdDate}). ${project.title}. Retrieved ${accessDate}, from ${projectUrl}`}
+ ); +}; + +const BibTeXCitation = ({ project, authors }) => { + const authorString = authors.map(a => `${a.last_name}, ${a.first_name}`).join(' and '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const year = new Date(project.created).getFullYear(); + + return ( +
{`@misc{dataset,
+  author = {${authorString}},
+  title = {${project.title}},
+  year = {${year}},
+  publisher = {Digital Rocks Portal},
+  doi = {},
+  howpublished = {\\url{${projectUrl}}}
+}`}
+ ); +}; + const MLACitation = ({ project, authors }) => { - let authorString; + const authorString = authors.map(a => `${a.last_name}, ${a.first_name}`).join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const createdDate = new Date(project.created).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + const accessDate = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + - if (authors.length === 1) { - authorString = `${authors[0].last_name}, ${authors[0].first_name}`; - } else if (authors.length === 2) { - authorString = `${authors[0].last_name}, ${authors[0].first_name}, and ${authors[1].last_name}, ${authors[1].first_name}`; - } else { - authorString = `${authors[0].last_name}, ${authors[0].first_name}, et al`; - } + return ( +
{`${authorString}. "${project.title}."`} Digital Rocks Portal, {` Digital Rocks Portal, ${createdDate}, ${projectUrl} Accessed ${accessDate}.`}
+ ); +}; - const mlaText = `${authorString}. "${project.title}." Digital Rocks Portal `; +const IEEECitation = ({ project, authors }) => { + const authorString = authors.map(a => `${a.first_name[0]}. ${a.last_name}`).join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const date = new Date(project.created); + const year = date.getFullYear(); + const day = date.getDate(); + const month = date.toLocaleString('en-GB', { month: 'short' }); return ( - <> -

MLA

-
-
{mlaText}
-
- +
{`[1] ${authorString}, "${project.title}",`} Digital Rocks Portal, {` ${year}. [Online]. Available: ${projectUrl}. [Accessed: ${day}-${month}-${year}]`}
); }; +const Citations = ({ project, authors }) => ( +
+

ACM ref

+
+ +
+ +

APA

+
+ +
+ +

BibTeX

+
+ +
+ +

MLA

+
+ +
+ +

IEEE

+
+ +
+
+); + + + const ReviewAuthors = ({ project, onAuthorsUpdate }) => { const [authors, setAuthors] = useState([]); const [members, setMembers] = useState([]); @@ -102,7 +177,8 @@ const ReviewAuthors = ({ project, onAuthorsUpdate }) => { > {authors.length > 0 && project && (
- + + {canEdit && ( <> Date: Tue, 12 Nov 2024 13:38:15 -0600 Subject: [PATCH 02/12] Refactor DataFilesFormModal to fix project URL regex and improve code readability --- .../DataFiles/DataFilesModals/DataFilesFormModal.jsx | 11 +++++------ client/src/redux/sagas/_custom/drp.sagas.js | 7 ++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx index 1cbd2afc2..173463ec7 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx @@ -16,20 +16,19 @@ const DataFilesFormModal = () => { const location = useLocation(); const reloadPage = (updatedPath = '') => { - // Updated regex to capture the URL up until the last project segment let projectUrl = location.pathname.replace( - /(\/projects\/[^/]+\/[^/]+)\/?.*/, + /(\/projects\/[^/]+\/[^/]+\/?.*)/, '$1' ); - + if (projectUrl.endsWith('/')) { projectUrl = projectUrl.slice(0, -1); } - - const path = updatedPath ? `${projectUrl}/${updatedPath}` : `${projectUrl}`; + + const path = updatedPath ? `${projectUrl}/${updatedPath}` : projectUrl; history.replace(path); }; - + const { form, selectedFile, formName, additionalData, useReloadCallback } = useSelector((state) => state.files.modalProps.dynamicform); const isOpen = useSelector((state) => state.files.modals.dynamicform); diff --git a/client/src/redux/sagas/_custom/drp.sagas.js b/client/src/redux/sagas/_custom/drp.sagas.js index dee4f5f24..7c2777084 100644 --- a/client/src/redux/sagas/_custom/drp.sagas.js +++ b/client/src/redux/sagas/_custom/drp.sagas.js @@ -49,12 +49,13 @@ function* executeOperation( isEdit && file.path === params.path ? `${path}/${file.path.split('/').pop()}` : path; - + + // Check if the file name has changed. If not, keep the same path. const reloadPath = isEdit && file.name !== values.name - ? newPath.replace(file.name, values.name) + ? newPath.replace(`/${file.name}`, `/${values.name}`) : newPath; - + yield call(reloadCallback, reloadPath); } From 830db8e68f52e7f8ab441d67e8b242c5193d071c Mon Sep 17 00:00:00 2001 From: Jake Rosenberg Date: Fri, 8 Nov 2024 12:03:48 -0600 Subject: [PATCH 03/12] Task/WC-93: Add toolbar button and modal to display the tree for DRP projects (#998) * add toolbar button and modal to display the tree for DRP projects * scroll modal when it overflows the screen size --- .../DataFilesModals/DataFilesModals.jsx | 2 + .../DataFilesProjectTreeModal.jsx | 45 ++++ .../DataFilesProjectTreeModal.module.scss | 4 + .../DataFilesProjectFileListingAddon.jsx | 17 +- .../ProjectTreeView.jsx | 199 ++++++++++++++++++ .../ReviewProjectStructure.jsx | 165 +-------------- .../drp/utils/hooks/useDrpDatasetModals.js | 20 +- client/tsconfig.json | 2 + client/vite.config.ts | 1 + 9 files changed, 292 insertions(+), 163 deletions(-) create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss create mode 100644 client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView.jsx diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx index 8399ea17f..d726c9a14 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx @@ -19,6 +19,7 @@ import DataFilesDownloadMessageModal from './DataFilesDownloadMessageModal'; import './DataFilesModals.scss'; import DataFilesFormModal from './DataFilesFormModal'; import DataFilesPublicationRequestModal from './DataFilesPublicationRequestModal'; +import DataFilesProjectTreeModal from './DataFilesProjectTreeModal'; export default function DataFilesModals() { return ( @@ -41,6 +42,7 @@ export default function DataFilesModals() { + ); diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx new file mode 100644 index 000000000..ad5644b8e --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx @@ -0,0 +1,45 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectTreeModal.module.scss'; +import { ProjectTreeView } from '_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView'; + +const DataFilesProjectTreeModal = () => { + const { projectId } = useSelector((state) => state.projects.metadata); + + const isOpen = useSelector((state) => state.files.modals.projectTree); + const props = useSelector((state) => state.files.modalProps['projectTree']); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectTree', props: {} }, + }); + }, []); + + const dispatch = useDispatch(); + + return ( + <> + + + Project Tree + + + {' '} + + + + + ); +}; + +export default DataFilesProjectTreeModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss new file mode 100644 index 000000000..d34c1fbe3 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss @@ -0,0 +1,4 @@ +.modal-body { + overflow: auto; + max-height: 80vh; +} \ No newline at end of file diff --git a/client/src/components/_custom/drp/DataFilesProjectFileListingAddon/DataFilesProjectFileListingAddon.jsx b/client/src/components/_custom/drp/DataFilesProjectFileListingAddon/DataFilesProjectFileListingAddon.jsx index 9b5735a3c..328f14df8 100644 --- a/client/src/components/_custom/drp/DataFilesProjectFileListingAddon/DataFilesProjectFileListingAddon.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectFileListingAddon/DataFilesProjectFileListingAddon.jsx @@ -15,8 +15,12 @@ const DataFilesProjectFileListingAddon = ({ rootSystem, system }) => { const dispatch = useDispatch(); - const { createSampleModal, createOriginDataModal, createAnalysisDataModal } = - useDrpDatasetModals(projectId, portalName); + const { + createSampleModal, + createOriginDataModal, + createAnalysisDataModal, + createTreeModal, + } = useDrpDatasetModals(projectId, portalName); const createPublicationRequestModal = () => { dispatch({ @@ -142,6 +146,15 @@ const DataFilesProjectFileListingAddon = ({ rootSystem, system }) => { )} )} + <> + | + + {canRequestPublication && ( <> | diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView.jsx new file mode 100644 index 000000000..7a559451c --- /dev/null +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView.jsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Button, + ShowMore, + Section, + Icon, +} from '_common'; +import { TreeItem, TreeView } from '@material-ui/lab'; +import styles from './DataFilesProjectPublishWizard.module.scss'; +import DataDisplay from '../../utils/DataDisplay/DataDisplay'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFileListing } from 'hooks/datafiles'; +import useDrpDatasetModals from '../../utils/hooks/useDrpDatasetModals'; +import { fetchUtil } from 'utils/fetchUtil'; + +export const ProjectTreeView = ({ projectId, readOnly = false }) => { + const [expandedNodes, setExpandedNodes] = useState([]); + + const dispatch = useDispatch(); + const portalName = useSelector((state) => state.workbench.portalName); + + const [tree, setTree] = useState([]); + + const { dynamicFormModal, previewModal, metadata } = useSelector((state) => ({ + dynamicFormModal: state.files.modals.dynamicform, + previewModal: state.files.modals.preview, + metadata: state.projects.metadata, + })); + + const fetchTree = useCallback(async () => { + if (projectId) { + try { + const response = await fetchUtil({ + url: `api/${portalName.toLowerCase()}/tree`, + params: { + project_id: projectId, + }, + }); + setTree(response); + } catch (error) { + console.error('Error fetching tree data:', error); + setTree([]); + } + } + }, [portalName, projectId]); + + useEffect(() => { + // workaround to get updated data after modal closes + if (!dynamicFormModal || !previewModal) { + fetchTree(); + } + }, [dynamicFormModal, previewModal, fetchTree]); + + const { params } = useFileListing('FilesListing'); + + useEffect(() => { + if (tree && tree.length > 0) { + setExpandedNodes([tree[0].id]); + } + }, []); + + const handleNodeToggle = (event, nodeIds) => { + // Update the list of expanded nodes + setExpandedNodes(nodeIds); + }; + + const { createSampleModal, createOriginDataModal, createAnalysisDataModal } = + useDrpDatasetModals(projectId, portalName, false); + + const onEditData = (node) => { + const dataType = node.metadata.data_type; + // reconstruct editFile to mimic SelectedFile object + const editFile = { + id: node.id, + uuid: node.uuid, + metadata: node.metadata, + name: node.metadata.name, + system: params.system, + type: 'dir', + path: node.path, + }; + switch (dataType) { + case 'sample': + createSampleModal('EDIT_SAMPLE_DATA', editFile); + break; + case 'digital_dataset': + createOriginDataModal('EDIT_ORIGIN_DATASET', editFile); + break; + case 'analysis_data': + createAnalysisDataModal('EDIT_ANALYSIS_DATASET', editFile); + break; + case 'file': + // Dispatch an action to toggle the modal for previewing the file + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'preview', + props: { + api: params.api, + scheme: params.scheme, + system: params.system, + path: node.path, + name: node.name, + href: `tapis://${params.system}/${node.path}`, + length: node.length, + metadata: node.metadata, + useReloadCallback: false, + }, + }, + }); + break; + default: + break; + } + }; + + const formatDatatype = (data_type) => + data_type + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + const renderTree = (node) => ( + <> +
+
+ + {node.label ?? node.name} + {node.metadata.data_type && ( + + {formatDatatype(node.metadata.data_type)} + + )} +
+ } + classes={{ + label: styles['tree-label'], + }} + onLabelClick={() => handleNodeToggle} + > + {expandedNodes.includes(node.id) && node.id !== 'NODE_ROOT' && ( +
+ {(!readOnly || node.metadata.data_type === 'file') && ( + + )} +
+ {node.metadata.description} + +
+
+ )} + {Array.isArray(node.fileObjs) && + node.fileObjs.map((fileObj) => renderTree(fileObj))} + {Array.isArray(node.children) && + node.children.map((child) => renderTree(child))} + + +
+ + ); + + return ( + tree && + tree.length > 0 && ( + } + defaultExpandIcon={} + expanded={expandedNodes} + onNodeToggle={handleNodeToggle} + > + {tree.map((node) => renderTree(node))} + + ) + ); +}; \ No newline at end of file diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewProjectStructure.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewProjectStructure.jsx index 66f1c31f6..8d8edef5e 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewProjectStructure.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewProjectStructure.jsx @@ -1,47 +1,17 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, - ShowMore, - LoadingSpinner, - SectionMessage, - SectionTableWrapper, - DescriptionList, - Section, - SectionContent, - Expand, - Icon, -} from '_common'; -import { TreeItem, TreeView } from '@material-ui/lab'; +import React, { useEffect, useState, useCallback } from 'react'; +import { Button, SectionTableWrapper, Section } from '_common'; import styles from './DataFilesProjectPublishWizard.module.scss'; -import DataDisplay from '../../utils/DataDisplay/DataDisplay'; import { useDispatch, useSelector } from 'react-redux'; import { useFileListing } from 'hooks/datafiles'; -import useDrpDatasetModals from '../../utils/hooks/useDrpDatasetModals'; +import { ProjectTreeView } from './ProjectTreeView'; -const ReviewProjectStructure = ({ projectTree }) => { +export const ReviewProjectStructure = ({ projectTree }) => { const dispatch = useDispatch(); - const [expandedNodes, setExpandedNodes] = useState([]); - - const portalName = useSelector((state) => state.workbench.portalName); const { projectId } = useSelector((state) => state.projects.metadata); const { params } = useFileListing('FilesListing'); - useEffect(() => { - if (projectTree && projectTree.length > 0) { - setExpandedNodes([projectTree[0].id]); - } - }, []); - - const handleNodeToggle = (event, nodeIds) => { - // Update the list of expanded nodes - setExpandedNodes(nodeIds); - }; - - const { createSampleModal, createOriginDataModal, createAnalysisDataModal } = - useDrpDatasetModals(projectId, portalName, false); - const canEdit = useSelector((state) => { const { members } = state.projects.metadata; const { username } = state.authenticatedUser.user; @@ -63,122 +33,6 @@ const ReviewProjectStructure = ({ projectTree }) => { }); }; - const onEditData = (node) => { - const dataType = node.metadata.data_type; - // reconstruct editFile to mimic SelectedFile object - const editFile = { - id: node.id, - uuid: node.uuid, - metadata: node.metadata, - name: node.metadata.name, - system: params.system, - type: 'dir', - path: node.path, - }; - switch (dataType) { - case 'sample': - createSampleModal('EDIT_SAMPLE_DATA', editFile); - break; - case 'digital_dataset': - createOriginDataModal('EDIT_ORIGIN_DATASET', editFile); - break; - case 'analysis_data': - createAnalysisDataModal('EDIT_ANALYSIS_DATASET', editFile); - break; - case 'file': - // Dispatch an action to toggle the modal for previewing the file - dispatch({ - type: 'DATA_FILES_TOGGLE_MODAL', - payload: { - operation: 'preview', - props: { - api: params.api, - scheme: params.scheme, - system: params.system, - path: node.path, - name: node.name, - href: `tapis://${params.system}/${node.path}`, - length: node.length, - metadata: node.metadata, - useReloadCallback: false, - }, - }, - }); - break; - default: - break; - } - }; - - const formatDatatype = (data_type) => - data_type - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - - const renderTree = (node) => ( - <> -
-
- - {node.label ?? node.name} - {node.metadata.data_type && ( - - {formatDatatype(node.metadata.data_type)} - - )} -
- } - classes={{ - label: styles['tree-label'], - }} - onLabelClick={() => handleNodeToggle} - > - {expandedNodes.includes(node.id) && node.id !== 'NODE_ROOT' && ( -
- {(canEdit || node.metadata.data_type === 'file') && ( - - )} -
- {node.metadata.description} - -
-
- )} - {Array.isArray(node.fileObjs) && - node.fileObjs.map((fileObj) => renderTree(fileObj))} - {Array.isArray(node.children) && - node.children.map((child) => renderTree(child))} - - -
- - ); - return ( { - {projectTree && projectTree.length > 0 && ( - } - defaultExpandIcon={} - expanded={expandedNodes} - onNodeToggle={handleNodeToggle} - > - {projectTree.map((node) => renderTree(node))} - - )} +
); diff --git a/client/src/components/_custom/drp/utils/hooks/useDrpDatasetModals.js b/client/src/components/_custom/drp/utils/hooks/useDrpDatasetModals.js index 0b9cb9657..6ce6f46ae 100644 --- a/client/src/components/_custom/drp/utils/hooks/useDrpDatasetModals.js +++ b/client/src/components/_custom/drp/utils/hooks/useDrpDatasetModals.js @@ -129,7 +129,25 @@ const useDrpDatasetModals = ( } ); - return { createSampleModal, createOriginDataModal, createAnalysisDataModal }; + const createTreeModal = useCallback( + async ({ readOnly = false }) => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'projectTree', + props: { readOnly }, + }, + }); + }, + [dispatch] + ); + + return { + createSampleModal, + createOriginDataModal, + createAnalysisDataModal, + createTreeModal, + }; }; export default useDrpDatasetModals; diff --git a/client/tsconfig.json b/client/tsconfig.json index 88baee95c..09e3d31a3 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,6 +5,8 @@ "paths": { "_common/*": ["components/_common/*"], "_common": ["components/_common"], + "_custom/*": ["components/_custom/*"], + "_custom": ["components/_custom"], "utils/*": ["utils/*"] }, "useDefineForClassFields": true, diff --git a/client/vite.config.ts b/client/vite.config.ts index db5a4e6ca..540d9fcf2 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ resolve: { alias: { _common: resolve(__dirname, 'src/components/_common'), + _custom: resolve(__dirname, 'src/components/_custom'), hooks: resolve(__dirname, 'src/hooks'), utils: resolve(__dirname, 'src/utils'), styles: resolve(__dirname, 'src/styles'), From 193dd8beac36516bfc1a4cefb84477d48011d405 Mon Sep 17 00:00:00 2001 From: Shayan Khan Date: Fri, 8 Nov 2024 13:39:08 -0600 Subject: [PATCH 04/12] DRP Publications and fixes (#1002) * Publication flow * Refactored code, improved request pub flow * remove incorrect return statement * added versioning support + bug fixes * front end linting * bug fixes with trash, prevent copying of trashed files * settings changes, review system only visible to reviewers * refactor to use project tree to get samples --- client/src/components/DataFiles/DataFiles.jsx | 12 + .../DataFilesPublicationsList.jsx | 122 +++++++++ .../DataFilesPublicationsList.module.scss | 17 ++ .../DataFilesPublicationsList.scss | 22 ++ .../DataFilesSidebar/DataFilesSidebar.jsx | 18 +- ...esProjectFileListingMetadataTitleAddon.jsx | 31 +-- .../DataFilesProjectPublish.jsx | 4 +- .../ProjectDescription.jsx | 4 + .../SubmitPublicationRequest.jsx | 21 +- .../SubmitPublicationReview.jsx | 105 +++++--- .../DataFilesProjectReview.jsx | 27 +- client/src/redux/reducers/index.js | 2 + .../redux/reducers/publications.reducers.js | 143 ++++++++++ client/src/redux/sagas/index.js | 2 + client/src/redux/sagas/publications.sagas.js | 202 ++++++++++++++ server/portal/apps/__init__.py | 5 +- server/portal/apps/_custom/drp/constants.py | 4 +- server/portal/apps/_custom/drp/models.py | 7 +- server/portal/apps/_custom/drp/views.py | 29 ++- server/portal/apps/projects/tasks.py | 2 + server/portal/apps/projects/views.py | 9 +- .../workspace_operations/graph_operations.py | 18 +- .../project_meta_operations.py | 6 +- .../project_publish_operations.py | 246 ++++++++++++++++++ .../shared_workspace_operations.py | 78 ++++-- .../migrations/0003_publication.py | 27 ++ server/portal/apps/publications/models.py | 22 +- server/portal/apps/publications/urls.py | 4 + server/portal/apps/publications/views.py | 201 ++++++++++++-- server/portal/apps/users/views.py | 7 + server/portal/libs/agave/operations.py | 3 +- server/portal/settings/settings.py | 12 + server/portal/settings/settings_default.py | 22 +- 33 files changed, 1299 insertions(+), 135 deletions(-) create mode 100644 client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx create mode 100644 client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.module.scss create mode 100644 client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss create mode 100644 client/src/redux/reducers/publications.reducers.js create mode 100644 client/src/redux/sagas/publications.sagas.js create mode 100644 server/portal/apps/publications/migrations/0003_publication.py diff --git a/client/src/components/DataFiles/DataFiles.jsx b/client/src/components/DataFiles/DataFiles.jsx index 6a5766422..e2c440425 100644 --- a/client/src/components/DataFiles/DataFiles.jsx +++ b/client/src/components/DataFiles/DataFiles.jsx @@ -24,6 +24,7 @@ import DataFilesModals from './DataFilesModals/DataFilesModals'; import DataFilesProjectsList from './DataFilesProjectsList/DataFilesProjectsList'; import DataFilesProjectFileListing from './DataFilesProjectFileListing/DataFilesProjectFileListing'; import { useSystemRole } from './DataFilesProjectMembers/_cells/SystemRoleSelector'; +import DataFilesPublicationsList from './DataFilesPublicationsList/DataFilesPublicationsList'; const DefaultSystemRedirect = () => { const systems = useSelector( @@ -56,6 +57,11 @@ const DataFilesSwitch = React.memo(() => { const { DataFilesProjectPublish, DataFilesProjectReview } = useAddonComponents({ portalName }); + const systems = useSelector( + (state) => state.systems.storage.configuration.filter((s) => !s.hidden), + shallowEqual + ); + return ( {DataFilesProjectPublish && ( @@ -92,6 +98,12 @@ const DataFilesSwitch = React.memo(() => { exact path={`${path}/tapis/projects/:system`} render={({ match: { params } }) => { + const system = systems.find((s) => s.system === params.system); + + if (system.publicationProject) { + return ; + } + return ; }} /> diff --git a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx new file mode 100644 index 000000000..a7844bf4e --- /dev/null +++ b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx @@ -0,0 +1,122 @@ +import React, { useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { Link, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch, shallowEqual } from 'react-redux'; +import queryStringParser from 'query-string'; +import { + InfiniteScrollTable, + SectionMessage, + SectionTableWrapper, +} from '_common'; +import styles from './DataFilesPublicationsList.module.scss'; +import './DataFilesPublicationsList.scss'; +import Searchbar from '_common/Searchbar'; +import { formatDate, formatDateTimeFromValue } from 'utils/timeFormat'; + +const DataFilesPublicationsList = ({ rootSystem }) => { + const { error, loading, publications } = useSelector( + (state) => state.publications.listing + ); + + const query = queryStringParser.parse(useLocation().search); + + const systems = useSelector( + (state) => state.systems.storage.configuration.filter((s) => !s.hidden), + shallowEqual + ); + + const selectedSystem = systems.find( + (s) => s.scheme === 'projects' && s.publicationProject === true + ); + + const infiniteScrollCallback = useCallback(() => {}); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch({ + type: 'PUBLICATIONS_GET_PUBLICATIONS', + payload: { + queryString: query.query_string, + }, + }); + }, [dispatch, query.query_string]); + + const columns = [ + { + Header: 'Publication Title', + accessor: 'title', + Cell: (el) => ( + + {el.value} + + ), + }, + { + Header: 'Principal Investigator', + accessor: 'authors', + Cell: (el) => ( + + {el.value.length > 0 + ? `${el.value[0].first_name} ${el.value[0].last_name}` + : ''} + + ), + }, + { + Header: 'Keywords', + accessor: 'keywords', + }, + { + Header: 'Publication Date', + accessor: 'publication_date', + Cell: (el) => ( + {el.value ? formatDate(new Date(el.value)) : ''} + ), + }, + ]; + + const noDataText = query.query_string + ? `No Publications match your search term.` + : `No Publications available.`; + + if (error) { + return ( +
+ + There was a problem retrieving Publications. + +
+ ); + } + + return ( + + +
+ +
+
+ ); +}; + +export default DataFilesPublicationsList; diff --git a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.module.scss b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.module.scss new file mode 100644 index 000000000..28e23efaf --- /dev/null +++ b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.module.scss @@ -0,0 +1,17 @@ +.root { + /* As a flex child */ + flex-grow: 1; + + /* WARNING: Mimicked on: History, Allocation, DataFiles, DataFilesProjectsList, DataFilesProjectFileListing, PublicData */ + padding-top: 1.75rem; /* ~28px (22.5px * design * 1.2 design-to-app ratio) */ + padding-left: 1.5em; /* ~24px (20px * design * 1.2 design-to-app ratio) */ +} + +/* NOTE: Mimicked on: DataFiles, DataFilesProjectsList, DataFilesProjectFileListing */ +.root-placeholder { + flex-grow: 1; + + display: flex; + align-items: center; + justify-content: center; +} diff --git a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss new file mode 100644 index 000000000..a87507046 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss @@ -0,0 +1,22 @@ +.publications-listing { + /* title */ + th:nth-child(1), + td:nth-child(1) { + width: 40%; + } + /* authors */ + th:nth-child(2), + td:nth-child(2) { + width: 20%; + } + /* keywords */ + th:nth-child(3), + td:nth-child(3) { + width: 25%; + } + /* date */ + th:nth-child(4), + td:nth-child(4) { + width: 15%; + } +} diff --git a/client/src/components/DataFiles/DataFilesSidebar/DataFilesSidebar.jsx b/client/src/components/DataFiles/DataFilesSidebar/DataFilesSidebar.jsx index a6123b3bd..fe9903d99 100644 --- a/client/src/components/DataFiles/DataFilesSidebar/DataFilesSidebar.jsx +++ b/client/src/components/DataFiles/DataFilesSidebar/DataFilesSidebar.jsx @@ -112,19 +112,23 @@ const DataFilesSidebar = ({ readOnly }) => { shallowEqual ); + const user = useSelector((state) => state.authenticatedUser.user); + const match = useRouteMatch(); var sidebarItems = []; systems.forEach((sys) => { if (sys.scheme === 'projects') { - sidebarItems.push({ - to: `${match.path}/${sys.api}/${sys.scheme}/${sys.system}`, - label: sys.name, - iconName: sys.icon || 'my-data', - disabled: false, - hidden: false, - }); + if (!sys.reviewProject || user.groups?.includes('PROJECT_REVIEWER')) { + sidebarItems.push({ + to: `${match.path}/${sys.api}/${sys.scheme}/${sys.system}`, + label: sys.name, + iconName: sys.icon || 'my-data', + disabled: false, + hidden: false, + }); + } } else { sidebarItems.push({ to: `${match.path}/${sys.api}/${sys.scheme}/${ diff --git a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataTitleAddon/DataFilesProjectFileListingMetadataTitleAddon.jsx b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataTitleAddon/DataFilesProjectFileListingMetadataTitleAddon.jsx index 6bcb4c632..a1acd41c6 100644 --- a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataTitleAddon/DataFilesProjectFileListingMetadataTitleAddon.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataTitleAddon/DataFilesProjectFileListingMetadataTitleAddon.jsx @@ -19,21 +19,22 @@ const DataFilesProjectFileListingMetadataTitleAddon = ({ const { loading } = useFileListing('FilesListing'); - const { canEditDataset } = useSelector( - (state) => - state.projects.metadata.members - .filter((member) => - member.user - ? member.user.username === state.authenticatedUser.user.username - : { access: null } - ) - .map((currentUser) => { - return { - canEditDataset: - currentUser.access === 'owner' || currentUser.access === 'edit', - }; - })[0] - ); + const { canEditDataset } = useSelector((state) => { + const userAccess = state.projects.metadata.members + .filter((member) => + member.user + ? member.user.username === state.authenticatedUser.user.username + : { access: null } + ) + .map((currentUser) => { + return { + canEditDataset: + currentUser.access === 'owner' || currentUser.access === 'edit', + }; + })[0]; + + return userAccess || { canEditDataset: false }; + }); const { createSampleModal, createOriginDataModal, createAnalysisDataModal } = useDrpDatasetModals(projectId, portalName); diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublish.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublish.jsx index 058cf1fa3..25dc6d63e 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublish.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublish.jsx @@ -82,7 +82,9 @@ const DataFilesProjectPublish = ({ rootSystem, system }) => { project: metadata, onAuthorsUpdate: handleAuthorsUpdate, }), - SubmitPublicationRequestStep(), + SubmitPublicationRequestStep({ + callbackUrl: `${ROUTES.WORKBENCH}${ROUTES.DATA}/tapis/projects/${rootSystem}/${system}`, + }), ]; const formSubmit = (values) => { diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectDescription.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectDescription.jsx index 836e2a48a..e314d0a3b 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectDescription.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectDescription.jsx @@ -48,6 +48,10 @@ const ProjectDescription = ({ project }) => { projectData['Keywords'] = project.keywords; } + if (project.doi) { + projectData['DOI'] = project.doi; + } + if (project.related_publications?.length > 0) { const relatedPublicationCards = project.related_publications.map( (publication) => { diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationRequest.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationRequest.jsx index 57cba02d7..3ccff7617 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationRequest.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationRequest.jsx @@ -5,18 +5,21 @@ import { SectionTableWrapper, Section, Button } from '_common'; import * as Yup from 'yup'; import styles from './DataFilesProjectPublishWizard.module.scss'; import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; const validationSchema = Yup.object({ reviewInfo: Yup.boolean().oneOf([true], 'Must be checked'), reviewRelatedPublications: Yup.boolean().oneOf([true], 'Must be checked'), }); -const SubmitPublicationRequest = () => { - const { handleChange, handleBlur, values, submitForm } = useFormikContext(); +const SubmitPublicationRequest = ({ callbackUrl }) => { + const { handleChange, handleBlur, values, submitForm, resetForm } = + useFormikContext(); + const history = useHistory(); const [submitDisabled, setSubmitDisabled] = useState(true); - const { loading, error } = useSelector((state) => { + const { loading, error, result } = useSelector((state) => { if ( state.projects.operation && state.projects.operation.name === 'publicationRequest' @@ -29,6 +32,14 @@ const SubmitPublicationRequest = () => { }; }); + useEffect(() => { + if (result && !error && !loading) { + setSubmitDisabled(false); + resetForm(); + history.replace(callbackUrl); + } + }, [result, error, loading]); + useEffect(() => { validationSchema.isValid(values).then((valid) => { setSubmitDisabled(!valid); @@ -92,10 +103,10 @@ const SubmitPublicationRequest = () => { ); }; -export const SubmitPublicationRequestStep = () => ({ +export const SubmitPublicationRequestStep = ({ callbackUrl }) => ({ id: 'submit_publication_request', name: 'Submit Publication Request', - render: , + render: , initialValues: { reviewInfo: false, reviewRelatedPublications: false, diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationReview.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationReview.jsx index 624aa0741..f17b286ac 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationReview.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/SubmitPublicationReview.jsx @@ -1,39 +1,62 @@ import React, { useEffect, useState } from 'react'; -import { FormGroup, Input } from 'reactstrap'; import { useFormikContext } from 'formik'; import { SectionTableWrapper, Section, Button } from '_common'; import * as Yup from 'yup'; import styles from './DataFilesProjectPublishWizard.module.scss'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; -const validationSchema = Yup.object({ - reviewInfo: Yup.boolean().oneOf([true], 'Must be checked'), - reviewRelatedPublications: Yup.boolean().oneOf([true], 'Must be checked'), -}); +const validationSchema = Yup.object({}); -const SubmitPublicationReview = () => { - const { handleChange, handleBlur, values, submitForm } = useFormikContext(); +const SubmitPublicationReview = ({ callbackUrl }) => { + const { submitForm, setFieldValue, resetForm } = useFormikContext(); - const [submitDisabled, setSubmitDisabled] = useState(true); + const { doi } = useSelector((state) => state.projects.metadata); - const { loading, error } = useSelector((state) => { - if ( - state.projects.operation && - state.projects.operation.name === 'publicationRequest' - ) { - return state.projects.operation; - } + const history = useHistory(); + + const [submitDisabled, setSubmitDisabled] = useState(false); + + const { + isApproveLoading, + isRejectLoading, + isApproveSuccess, + isRejectSuccess, + } = useSelector((state) => { + const { name, loading, error, result } = state.publications.operation; return { - loading: false, - error: false, + isApproveLoading: name === 'approve' && loading, + isRejectLoading: name === 'reject' && loading, + isApproveSuccess: name === 'approve' && !loading && !error && result, + isRejectSuccess: name === 'reject' && !loading && !error && result, }; }); useEffect(() => { - validationSchema.isValid(values).then((valid) => { - setSubmitDisabled(!valid); - }); - }, [values]); + if (isApproveSuccess || isRejectSuccess) { + setSubmitDisabled(false); + resetForm(); + history.replace(callbackUrl); + } + }, [isApproveSuccess, isRejectSuccess]); + + const handleApproveAndPublish = () => { + setFieldValue('publicationApproved', true); + setSubmitDisabled(true); + submitForm(); + }; + + const handleReject = () => { + setFieldValue('publicationRejected', true); + setSubmitDisabled(true); + submitForm(); + }; + + const handleVersioning = () => { + setFieldValue('versionApproved', true); + setSubmitDisabled(true); + submitForm(); + }; return ( { curator Maria Esteva before submitting the data for publication.
- + {doi ? ( + + ) : ( + + )} @@ -69,10 +104,10 @@ const SubmitPublicationReview = () => { ); }; -export const SubmitPublicationReviewStep = () => ({ +export const SubmitPublicationReviewStep = ({ callbackUrl }) => ({ id: 'submit_publication_review', name: 'Submit Publication Review', - render: , + render: , initialValues: {}, validationSchema, }); diff --git a/client/src/components/_custom/drp/DataFilesProjectReview/DataFilesProjectReview.jsx b/client/src/components/_custom/drp/DataFilesProjectReview/DataFilesProjectReview.jsx index 9918fd435..6c660720a 100644 --- a/client/src/components/_custom/drp/DataFilesProjectReview/DataFilesProjectReview.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectReview/DataFilesProjectReview.jsx @@ -51,10 +51,33 @@ const DataFilesProjectReview = ({ rootSystem, system }) => { ProjectDescriptionStep({ project: metadata }), ReviewProjectStructureStep({ projectTree: tree }), ReviewAuthorsStep({ project: metadata, onAuthorsUpdate: () => {} }), - SubmitPublicationReviewStep(), + SubmitPublicationReviewStep({ + callbackUrl: `${ROUTES.WORKBENCH}${ROUTES.DATA}/tapis/projects/${rootSystem}`, + }), ]; - const formSubmit = (values) => {}; + const formSubmit = (values) => { + const data = { + ...metadata, + }; + + if (values && values.publicationApproved) { + dispatch({ + type: 'PUBLICATIONS_APPROVE_PUBLICATION', + payload: data, + }); + } else if (values && values.publicationRejected) { + dispatch({ + type: 'PUBLICATIONS_REJECT_PUBLICATION', + payload: data, + }); + } else if (values && values.versionApproved) { + dispatch({ + type: 'PUBLICATIONS_APPROVE_VERSION', + payload: data, + }); + } + }; return ( <> diff --git a/client/src/redux/reducers/index.js b/client/src/redux/reducers/index.js index 62bb5ae48..82514359a 100644 --- a/client/src/redux/reducers/index.js +++ b/client/src/redux/reducers/index.js @@ -25,6 +25,7 @@ import { onboarding } from './onboarding.reducers'; import projects from './projects.reducers'; import { users } from './users.reducers'; import siteSearch from './siteSearch.reducers'; +import publications from './publications.reducers'; export default combineReducers({ jobs, @@ -53,4 +54,5 @@ export default combineReducers({ projects, users, siteSearch, + publications, }); diff --git a/client/src/redux/reducers/publications.reducers.js b/client/src/redux/reducers/publications.reducers.js new file mode 100644 index 000000000..6588f6e09 --- /dev/null +++ b/client/src/redux/reducers/publications.reducers.js @@ -0,0 +1,143 @@ +export const initialState = { + listing: { + publications: [], + error: null, + loading: false, + }, + operation: { + name: '', + loading: false, + error: null, + result: null, + }, +}; + +export default function publications(state = initialState, action) { + switch (action.type) { + case 'PUBLICATIONS_GET_PUBLICATIONS_STARTED': + return { + ...state, + listing: { + ...state.listing, + publications: [], + error: null, + loading: true, + }, + }; + case 'PUBLICATIONS_GET_PUBLICATIONS_SUCCESS': + return { + ...state, + listing: { + publications: action.payload, + error: null, + loading: false, + }, + }; + case 'PUBLICATIONS_GET_PUBLICATIONS_FAILED': + return { + ...state, + listing: { + ...state.listing, + error: action.payload, + loading: false, + }, + }; + case 'PUBLICATIONS_APPROVE_PUBLICATION_STARTED': + return { + ...state, + operation: { + name: 'approve', + loading: true, + error: null, + result: null, + }, + }; + case 'PUBLICATIONS_APPROVE_PUBLICATION_SUCCESS': + return { + ...state, + operation: { + name: 'approve', + loading: false, + error: null, + result: action.payload, + }, + }; + case 'PUBLICATIONS_APPROVE_PUBLICATION_FAILED': + return { + ...state, + operation: { + name: 'approve', + loading: false, + error: action.payload, + result: null, + }, + }; + case 'PUBLICATIONS_REJECT_PUBLICATION_STARTED': + return { + ...state, + operation: { + name: 'reject', + loading: true, + error: null, + result: null, + }, + }; + case 'PUBLICATIONS_REJECT_PUBLICATION_SUCCESS': + return { + ...state, + operation: { + name: 'reject', + loading: false, + error: null, + result: action.payload, + }, + }; + case 'PUBLICATIONS_REJECT_PUBLICATION_FAILED': + return { + ...state, + operation: { + name: 'reject', + loading: false, + error: action.payload, + result: null, + }, + }; + case 'PUBLICATIONS_APPROVE_VERSION_STARTED': + return { + ...state, + operation: { + name: 'approve', + loading: true, + error: null, + result: null, + }, + }; + case 'PUBLICATIONS_APPROVE_VERSION_SUCCESS': + return { + ...state, + operation: { + name: 'approve', + loading: false, + error: null, + result: action.payload, + }, + }; + case 'PUBLICATIONS_APPROVE_VERSION_FAILED': + return { + ...state, + operation: { + name: 'approve', + loading: false, + error: action.payload, + result: null, + }, + }; + case 'PUBLICATIONS_OPERATION_RESET': + return { + ...state, + operation: initialState.operation, + }; + default: + return state; + } +} diff --git a/client/src/redux/sagas/index.js b/client/src/redux/sagas/index.js index 928da69c9..288de701d 100644 --- a/client/src/redux/sagas/index.js +++ b/client/src/redux/sagas/index.js @@ -52,6 +52,7 @@ import { import { watchProjects } from './projects.sagas'; import { watchUsers } from './users.sagas'; import { watchSiteSearch } from './siteSearch.sagas'; +import { watchPublications } from './publications.sagas'; function* watchStartCustomSaga() { yield takeEvery('START_CUSTOM_SAGA', startCustomSaga); @@ -121,5 +122,6 @@ export default function* rootSaga() { watchUsers(), watchSiteSearch(), watchStartCustomSaga(), + watchPublications(), ]); } diff --git a/client/src/redux/sagas/publications.sagas.js b/client/src/redux/sagas/publications.sagas.js new file mode 100644 index 000000000..e4f32db23 --- /dev/null +++ b/client/src/redux/sagas/publications.sagas.js @@ -0,0 +1,202 @@ +import { fetchUtil } from 'utils/fetchUtil'; +import { put, takeLatest, call } from 'redux-saga/effects'; +import queryStringParser from 'query-string'; + +export async function createPublicationRequestUtil(data) { + const result = await fetchUtil({ + url: `/api/publications/publication-request/`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + return result.response; +} + +export function* createPublicationRequest(action) { + yield put({ + type: 'PUBLICATIONS_CREATE_PUBLICATION_REQUEST_STARTED', + }); + try { + const result = yield call(createPublicationRequestUtil, action.payload); + yield put({ + type: 'PUBLICATIONS_CREATE_PUBLICATION_REQUEST_SUCCESS', + payload: result, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_CREATE_PUBLICATION_REQUEST_FAILED', + payload: error, + }); + } +} + +export async function fetchPublicationRequestsUtil(system) { + const result = await fetchUtil({ + url: `/api/publications/publication-request/${system}`, + }); + return result.response; +} + +export function* getPublicationRequests(action) { + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATION_REQUESTS_STARTED', + }); + try { + const publicationRequests = yield call( + fetchPublicationRequestsUtil, + action.payload + ); + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATION_REQUESTS_SUCCESS', + payload: publicationRequests, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATION_REQUESTS_FAILED', + payload: error, + }); + } +} + +export async function approvePublicationUtil(data) { + const result = await fetchUtil({ + url: `/api/publications/publish/`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + return result.response; +} + +export function* approvePublication(action) { + yield put({ + type: 'PUBLICATIONS_APPROVE_PUBLICATION_STARTED', + }); + try { + const result = yield call(approvePublicationUtil, action.payload); + yield put({ + type: 'PUBLICATIONS_APPROVE_PUBLICATION_SUCCESS', + payload: result, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_APPROVE_PUBLICATION_FAILED', + payload: error, + }); + } finally { + yield put({ type: 'PUBLICATIONS_OPERATION_RESET' }); + } +} + +export async function rejectPublicationUtil(data) { + const result = await fetchUtil({ + url: `/api/publications/reject/`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + return result.response; +} + +export function* rejectPublication(action) { + yield put({ + type: 'PUBLICATIONS_REJECT_PUBLICATION_STARTED', + }); + try { + const result = yield call(rejectPublicationUtil, action.payload); + yield put({ + type: 'PUBLICATIONS_REJECT_PUBLICATION_SUCCESS', + payload: result, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_REJECT_PUBLICATION_FAILED', + payload: error, + }); + } finally { + yield put({ type: 'PUBLICATIONS_OPERATION_RESET' }); + } +} + +export async function fetchPublicationsUtil(queryString) { + const q = queryStringParser.stringify({ query_string: queryString }); + + const result = await fetchUtil({ + url: queryString ? `/api/publications?${q}` : '/api/publications', + }); + return result.response; +} + +export function* getPublications(action) { + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATIONS_STARTED', + }); + try { + const result = yield call( + fetchPublicationsUtil, + action.payload.queryString + ); + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATIONS_SUCCESS', + payload: result, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_GET_PUBLICATIONS_FAILED', + payload: error, + }); + } +} + +export async function versionPublicationUtil(data) { + const result = await fetchUtil({ + url: `/api/publications/version/`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + return result.response; +} + +export function* versionPublication(action) { + yield put({ + type: 'PUBLICATIONS_APPROVE_VERSION_STARTED', + }); + try { + const result = yield call(versionPublicationUtil, action.payload); + yield put({ + type: 'PUBLICATIONS_APPROVE_VERSION_SUCCESS', + payload: result, + }); + } catch (error) { + yield put({ + type: 'PUBLICATIONS_APPROVE_VERSION_FAILED', + payload: error, + }); + } finally { + yield put({ type: 'PUBLICATIONS_OPERATION_RESET' }); + } +} + +export function* watchPublications() { + yield takeLatest('PUBLICATIONS_GET_PUBLICATIONS', getPublications); + yield takeLatest('PUBLICATIONS_APPROVE_PUBLICATION', approvePublication); + yield takeLatest('PUBLICATIONS_REJECT_PUBLICATION', rejectPublication); + yield takeLatest('PUBLICATIONS_APPROVE_VERSION', versionPublication); + yield takeLatest( + 'PUBLICATIONS_CREATE_PUBLICATION_REQUEST', + createPublicationRequest + ); + yield takeLatest( + 'PUBLICATIONS_GET_PUBLICATION_REQUESTS', + getPublicationRequests + ); +} diff --git a/server/portal/apps/__init__.py b/server/portal/apps/__init__.py index 36e0a49af..862ffb004 100644 --- a/server/portal/apps/__init__.py +++ b/server/portal/apps/__init__.py @@ -1,5 +1,5 @@ -from portal.apps._custom.drp.models import DrpProjectMetadata, DrpSampleMetadata, DrpOriginDatasetMetadata, DrpAnalysisDatasetMetadata, DrpFileMetadata +from portal.apps._custom.drp.models import DrpProjectMetadata, DrpSampleMetadata, DrpOriginDatasetMetadata, DrpAnalysisDatasetMetadata, DrpFileMetadata, PartialTrashEntity from portal.apps._custom.drp import constants SCHEMA_MAPPING = { @@ -8,5 +8,6 @@ constants.ORIGIN_DATA: DrpOriginDatasetMetadata, constants.DIGITAL_DATASET: DrpOriginDatasetMetadata, constants.ANALYSIS_DATA: DrpAnalysisDatasetMetadata, - constants.FILE: DrpFileMetadata + constants.FILE: DrpFileMetadata, + constants.TRASH: PartialTrashEntity, } \ No newline at end of file diff --git a/server/portal/apps/_custom/drp/constants.py b/server/portal/apps/_custom/drp/constants.py index 481aae583..8a96600d4 100644 --- a/server/portal/apps/_custom/drp/constants.py +++ b/server/portal/apps/_custom/drp/constants.py @@ -9,4 +9,6 @@ ANALYSIS_DATA = "drp.project.analysis_dataset" ORIGIN_DATA = "drp.project.origin_data" -FILE = "drp.project.file" \ No newline at end of file +FILE = "drp.project.file" + +TRASH = "drp.project.trash" \ No newline at end of file diff --git a/server/portal/apps/_custom/drp/models.py b/server/portal/apps/_custom/drp/models.py index 8f6b599be..99662cc6e 100644 --- a/server/portal/apps/_custom/drp/models.py +++ b/server/portal/apps/_custom/drp/models.py @@ -54,6 +54,10 @@ class FileObj(DrpMetadataModel): uuid: Optional[str] = None value: Optional[DrpFileMetadata] = None +class PartialTrashEntity(DrpMetadataModel): + """Model for representing a trash entity.""" + + model_config = ConfigDict(extra="ignore") class PartialEntityWithFiles(DrpMetadataModel): """Model for representing an entity with associated files.""" @@ -116,7 +120,8 @@ class DrpProjectMetadata(DrpMetadataModel): publication_date: Optional[str] = None authors: list[dict] = [] file_objs: list[FileObj] = [] - is_review_project : Optional[bool] = None + is_review_project: Optional[bool] = None + is_published_project: Optional[bool] = None class DrpDatasetMetadata(DrpMetadataModel): """Model for Base DRP Dataset Metadata""" diff --git a/server/portal/apps/_custom/drp/views.py b/server/portal/apps/_custom/drp/views.py index 600dbbd31..0da3c9e86 100644 --- a/server/portal/apps/_custom/drp/views.py +++ b/server/portal/apps/_custom/drp/views.py @@ -8,6 +8,7 @@ import networkx as nx from networkx import shortest_path from portal.apps.projects.workspace_operations.project_meta_operations import get_ordered_value +from portal.apps.projects.workspace_operations.graph_operations import remove_trash_nodes class DigitalRocksSampleView(BaseApiView): def get(self, request): @@ -16,7 +17,20 @@ def get(self, request): full_project_id = f'{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}' - samples = ProjectMetadata.objects.filter(base_project__value__projectId=full_project_id, name=constants.SAMPLE).values('uuid', 'name', 'value') + graph_model = ProjectMetadata.objects.get( + name=constants.PROJECT_GRAPH, base_project__value__projectId=full_project_id + ) + + project_graph = nx.node_link_graph(graph_model.value) + + sample_uuids = [] + + for node_id in list(project_graph.successors('NODE_ROOT')): + node = project_graph.nodes[node_id] + if (node.get('name') == constants.SAMPLE): + sample_uuids.append(node.get('uuid')) + + samples = ProjectMetadata.objects.filter(uuid__in=sample_uuids).values('uuid', 'name', 'value') origin_data = [] @@ -47,18 +61,7 @@ def get(self, request): graph = nx.node_link_graph(graph_model.value) - trash_node_id = None - - for node_id in graph.nodes: - trash_node = graph.nodes[node_id].get('name') == settings.TAPIS_DEFAULT_TRASH_NAME - if trash_node: - trash_node_id = node_id - break - - if trash_node_id: - trash_descendants = nx.descendants(graph, trash_node_id) - nodes_to_remove = {trash_node_id} | trash_descendants - graph.remove_nodes_from(nodes_to_remove) + graph = remove_trash_nodes(graph) for node_id in graph.nodes: diff --git a/server/portal/apps/projects/tasks.py b/server/portal/apps/projects/tasks.py index 2f5d0af12..379b4bc12 100644 --- a/server/portal/apps/projects/tasks.py +++ b/server/portal/apps/projects/tasks.py @@ -16,6 +16,8 @@ import networkx as nx import uuid +# TODO: Cleanup this file + logger = logging.getLogger(__name__) def _transfer_files(user_access_token, source_system_id, review_system_id): diff --git a/server/portal/apps/projects/views.py b/server/portal/apps/projects/views.py index e4185c32e..83d6473d1 100644 --- a/server/portal/apps/projects/views.py +++ b/server/portal/apps/projects/views.py @@ -224,13 +224,12 @@ def patch( project_id_full = f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}" client = request.user.tapis_oauth.client - if metadata is not None: - patch_project_entity(project_id_full, metadata) - workspace_def = update_project(client, project_id, data['title'], data['description']) - if metadata is not None: - workspace_def.update(metadata) + if metadata is not None: + entity = patch_project_entity(project_id_full, metadata) + workspace_def.update(get_ordered_value(entity.name, entity.value)) + workspace_def["projectId"] = project_id return JsonResponse( { diff --git a/server/portal/apps/projects/workspace_operations/graph_operations.py b/server/portal/apps/projects/workspace_operations/graph_operations.py index e1df18c82..8c3ae4d96 100644 --- a/server/portal/apps/projects/workspace_operations/graph_operations.py +++ b/server/portal/apps/projects/workspace_operations/graph_operations.py @@ -3,7 +3,7 @@ from django.db import transaction import uuid import copy - +from django.conf import settings from portal.apps._custom.drp import constants from portal.apps.projects.models.project_metadata import ProjectMetadata @@ -168,4 +168,18 @@ def get_node_from_uuid(project_id: str, uuid: str): for node_id in project_graph.nodes: if project_graph.nodes[node_id]["uuid"] == uuid: return {"id": node_id, **project_graph.nodes[node_id]} - return None \ No newline at end of file + return None + +def remove_trash_nodes(graph: nx.DiGraph): + trash_node_id = None + for node_id in graph.nodes: + trash_node = graph.nodes[node_id].get("name") == constants.TRASH + if trash_node: + trash_node_id = node_id + break + + if trash_node_id: + trash_descendants = nx.descendants(graph, trash_node_id) + nodes_to_remove = {trash_node_id} | trash_descendants + graph.remove_nodes_from(nodes_to_remove) + return graph \ No newline at end of file diff --git a/server/portal/apps/projects/workspace_operations/project_meta_operations.py b/server/portal/apps/projects/workspace_operations/project_meta_operations.py index a6cea1d2f..0429b6789 100644 --- a/server/portal/apps/projects/workspace_operations/project_meta_operations.py +++ b/server/portal/apps/projects/workspace_operations/project_meta_operations.py @@ -155,7 +155,11 @@ def patch_project_entity(project_id, value): entity = ProjectMetadata.get_project_by_id(project_id) schema_model = SCHEMA_MAPPING[entity.name] - patched_metadata = {**value, 'projectId': project_id, 'fileObjs': entity.value.get('fileObjs', [])} + patched_metadata = {**value, + 'projectId': project_id, + 'fileObjs': entity.value.get('fileObjs', []), + 'doi': entity.value.get('doi', None) + } update_node_in_project(project_id, 'NODE_ROOT', None, value.get('title')) diff --git a/server/portal/apps/projects/workspace_operations/project_publish_operations.py b/server/portal/apps/projects/workspace_operations/project_publish_operations.py index e69de29bb..481ea9f16 100644 --- a/server/portal/apps/projects/workspace_operations/project_publish_operations.py +++ b/server/portal/apps/projects/workspace_operations/project_publish_operations.py @@ -0,0 +1,246 @@ +from typing import Optional +from django.conf import settings +import logging +from portal.apps.projects.workspace_operations.shared_workspace_operations import remove_user +from portal.apps.projects.models.project_metadata import ProjectMetadata +import networkx as nx +from celery import shared_task +from portal.apps._custom.drp import constants +from portal.libs.agave.utils import user_account, service_account +from portal.apps.publications.models import Publication, PublicationRequest +from django.db import transaction +from portal.apps.projects.workspace_operations.graph_operations import remove_trash_nodes +from tapipy.errors import NotFoundError, BaseTapyException +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist + +logger = logging.getLogger(__name__) + +def _transfer_files(client, source_system_id, dest_system_id): + + service_client = service_account() + + source_system_files = client.files.listFiles(systemId=source_system_id, path='/') + + # Filter out the trash folder + filtered_files = [file for file in source_system_files if file.name != settings.TAPIS_DEFAULT_TRASH_NAME] + + transfer_elements = [ + { + 'sourceURI': file.url, + 'destinationURI': f'tapis://{dest_system_id}/{file.path}' + } + for file in filtered_files + ] + + transfer = service_client.files.createTransferTask(elements=transfer_elements) + return transfer + +def _check_transfer_status(service_client, transfer_task_id): + transfer_details = service_client.files.getTransferTask(transferTaskId=transfer_task_id) + return transfer_details.status + +def _add_values_to_tree(project_id): + project_meta = ProjectMetadata.get_project_by_id(project_id) + prj_entities = ProjectMetadata.get_entities_by_project_id(project_id) + + entity_map = {entity.uuid: entity for entity in prj_entities} + + publication_tree: nx.DiGraph = nx.node_link_graph(project_meta.project_graph.value) + + publication_tree = remove_trash_nodes(publication_tree) + + for node_id in publication_tree: + uuid = publication_tree.nodes[node_id]["uuid"] + if uuid is not None: + publication_tree.nodes[node_id]["value"] = entity_map[uuid].value + publication_tree.nodes[node_id]["uuid"] = None # Clear the uuid field + + return publication_tree + +def publish_project_callback(review_project_id, published_project_id): + service_client = service_account() + update_and_cleanup_review_project(review_project_id, PublicationRequest.Status.APPROVED) + + # Make system public for listing + service_client.systems.shareSystemPublic(systemId=published_project_id) + +def publication_request_callback(user_access_token, source_workspace_id, review_workspace_id, source_system_id, review_system_id): + service_client = service_account() + user_client = user_account(user_access_token) + portal_admin_username = settings.PORTAL_ADMIN_USERNAME + + publication_reviewers = get_user_model().objects.filter(groups__name=settings.PORTAL_PUBLICATION_REVIEWERS_GROUP_NAME).values_list('username', flat=True) + + with transaction.atomic(): + # Remove admin from source workspace + user_client.systems.unShareSystem(systemId=source_system_id, users=[portal_admin_username]) + user_client.systems.revokeUserPerms(systemId=source_system_id, userName=portal_admin_username, permissions=["READ", "MODIFY", "EXECUTE"]) + user_client.files.deletePermissions(systemId=source_system_id, username=portal_admin_username, path="/") + logger.info(f'Removed service account from workspace {source_workspace_id}') + + # Add reviewers to review workspace + from portal.apps.projects.workspace_operations.shared_workspace_operations import add_user_to_workspace + + for reviewer in publication_reviewers: + add_user_to_workspace( + service_client, + review_workspace_id, + reviewer, + "reader", + f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.{review_workspace_id}", + settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME, + ) + logger.info(f'Added reviewer {reviewer} to review system {review_system_id}') + +@shared_task(bind=True, max_retries=3, queue='default') +def publish_project(self, project_id: str, version: Optional[int] = 1): + + review_system_prefix = settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX + published_system_prefix = settings.PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX + + published_workspace_id = f"{project_id}{f'v{version}' if version and version > 1 else ''}" + published_system_id = f'{published_system_prefix}.{published_workspace_id}' + review_system_id = f'{review_system_prefix}.{project_id}' + + with transaction.atomic(): + + project_meta = ProjectMetadata.get_project_by_id(review_system_id) + publication_tree: nx.DiGraph = nx.node_link_graph(project_meta.project_graph.value) + + published_project = ProjectMetadata.get_project_by_id(published_system_id) + + ProjectMetadata.objects.create( + name=constants.PROJECT_GRAPH, + base_project=published_project, + value=nx.node_link_data(publication_tree), + ) + + doi = 'test_doi' # Replace with actual DOI retrieval logic + + # Update project metadata with datacite doi + source_project_id = f'{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}' + source_project = ProjectMetadata.get_project_by_id(source_project_id) + source_project.value['doi'] = doi + source_project.save() + + pub_tree = nx.node_link_graph(published_project.project_graph.value) + pub_tree.nodes["NODE_ROOT"]["version"] = version + published_project.project_graph.value = nx.node_link_data(pub_tree) + published_project.value['doi'] = doi + published_project.save() + + + pub_metadata = Publication.objects.update_or_create( + project_id=project_id, + defaults={"value": published_project.value, "tree": nx.node_link_data(pub_tree), "version": version}, + ) + + # transfer files + client = service_account() + transfer = _transfer_files(client, review_system_id, published_system_id) + + poll_tapis_file_transfer.apply_async( + args=(transfer.uuid, False), + kwargs={ + 'review_project_id': review_system_id, + 'published_project_id': published_system_id, + }, countdown=30) + +@shared_task(bind=True, max_retries=3, queue='default') +def copy_graph_and_files_for_review_system(self, user_access_token, source_workspace_id, review_workspace_id, source_system_id, review_system_id): + logger.info(f'Starting copy task for system {source_system_id} to system {review_system_id}') + + with transaction.atomic(): + pub_tree = _add_values_to_tree(source_system_id) + + graph_model_value = nx.node_link_data(pub_tree) + review_project = ProjectMetadata.get_project_by_id(review_system_id) + ProjectMetadata.objects.update_or_create( + name=constants.PROJECT_GRAPH, + base_project=review_project, + defaults={"value": graph_model_value}, + ) + + client = user_account(user_access_token) + transfer = _transfer_files(client, source_system_id, review_system_id) + + logger.info(f'Transfer task submmited with id {transfer.uuid}') + + poll_tapis_file_transfer.apply_async( + args=(transfer.uuid, True), + kwargs={ + 'user_access_token': user_access_token, + 'source_workspace_id': source_workspace_id, + 'review_workspace_id': review_workspace_id, + 'source_system_id': source_system_id, + 'review_system_id': review_system_id, + }, countdown=30) + +@shared_task(bind=True, queue='default') +def poll_tapis_file_transfer(self, transfer_task_id, is_review, **kwargs): + logger.info(f'Starting post transfer task for transfer id {transfer_task_id} with arguments: {kwargs}') + + try: + service_client = service_account() + + # Check the transfer status + transfer_status = _check_transfer_status(service_client, transfer_task_id) + + # Handle pending or in-progress transfer + if transfer_status in ['PENDING', 'IN_PROGRESS']: + logger.info(f'Transfer {transfer_task_id} is still pending with status {transfer_status}, retrying in 30 seconds.') + self.apply_async(args=(transfer_task_id, is_review), kwargs=kwargs, countdown=30) + return + + # Handle completed transfer + elif transfer_status == 'COMPLETED': + logger.info(f'Transfer {transfer_task_id} completed successfully with arguments: {kwargs}') + + # Call the callback function with any passed arguments + if is_review: + publication_request_callback(**kwargs) + else: + publish_project_callback(**kwargs) + + else: + logger.error(f'Error processing transfer {transfer_task_id}: Transfer status is {transfer_status}') + raise Exception(f'Transfer {transfer_task_id} failed with status {transfer_status}') + + except Exception as e: + logger.error(f'Error processing transfer {transfer_task_id} with arguments {kwargs}: {e}') + self.retry(exc=e, countdown=30) + +@transaction.atomic +def update_and_cleanup_review_project(review_project_id: str, status: PublicationRequest.Status): + + client = service_account() + + workspace_id = review_project_id.split(f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.")[1] + + # update the publication request + review_project = ProjectMetadata.get_project_by_id(review_project_id) + pub_request = PublicationRequest.objects.get(review_project=review_project, status=PublicationRequest.Status.PENDING) + pub_request.status = status + pub_request.save() + + logger.info(f'Updated publication request for review project {review_project_id} to {status}.') + + # delete the review project and data inside it + reviewers = pub_request.reviewers.all() + + for reviewer in reviewers: + try: + remove_user(client, workspace_id, reviewer.username, review_project_id, settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME) + logger.info(f'Removed reviewer {reviewer.username} from review system {review_project_id}') + except: + logger.error(f'Error removing reviewer {reviewer.username} from review system {review_project_id}') + continue + + client.files.delete(systemId=review_project_id, path='/') + client.systems.deleteSystem(systemId=review_project_id) + review_project_graph = ProjectMetadata.objects.get(name=constants.PROJECT_GRAPH, base_project=review_project) + review_project_graph.delete() + review_project.delete() + + logger.info(f'Deleted review project {review_project_id} and its associated data.') \ No newline at end of file diff --git a/server/portal/apps/projects/workspace_operations/shared_workspace_operations.py b/server/portal/apps/projects/workspace_operations/shared_workspace_operations.py index 7d8c67c6a..347e976eb 100644 --- a/server/portal/apps/projects/workspace_operations/shared_workspace_operations.py +++ b/server/portal/apps/projects/workspace_operations/shared_workspace_operations.py @@ -1,6 +1,7 @@ # from portal.utils.encryption import createKeyPair from portal.libs.agave.utils import service_account from portal.apps.projects.models.project_metadata import ProjectMetadata +from portal.apps._custom.drp import constants from tapipy.tapis import Tapis from typing import Literal from django.db import transaction @@ -8,7 +9,8 @@ from django.contrib.auth import get_user_model from portal.apps.projects.models.metadata import ProjectsMetadata from django.db import models -from portal.apps.projects.workspace_operations.project_meta_operations import create_project_metadata +from portal.apps.projects.workspace_operations.project_meta_operations import create_project_metadata, get_ordered_value +from portal.apps.onboarding.steps.system_access_v3 import create_system_credentials import logging logger = logging.getLogger(__name__) @@ -248,15 +250,17 @@ def change_user_role(client, workspace_id: str, username: str, new_role): set_workspace_permissions(client, username, system_id, new_role) -def remove_user(client, workspace_id: str, username: str): +def remove_user(client, workspace_id: str, username: str, system_id=None, system_name=None): """ Unshare the system and remove all permissions and credentials. """ service_client = service_account() - system_id = f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{workspace_id}" + system_id = system_id or f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{workspace_id}" + system_name = system_name or f"{settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME}" + set_workspace_acls(service_client, - settings.PORTAL_PROJECTS_ROOT_SYSTEM_NAME, + system_name, workspace_id, username, "remove", @@ -270,7 +274,7 @@ def remove_user(client, workspace_id: str, username: str): username=username, path="/") - return get_project(client, workspace_id) + return get_project(client, workspace_id, system_id) def transfer_ownership(client, workspace_id: str, new_owner: str, old_owner: str): @@ -335,7 +339,6 @@ def list_projects(client, root_system_id=None): if root_system: query += f"~(rootDir.like.{root_system['rootDir']}*)" - # use limit as -1 to allow search to corelate with # all projects available to the api user listing = client.systems.getSystems(listType='ALL', @@ -401,32 +404,55 @@ def get_workspace_role(client, workspace_id, username): return None @transaction.atomic -def create_publication_review_shared_workspace(client, source_workspace_id: str, source_system_id: str, review_workspace_id: str, - review_system_id: str, title: str, description=""): +def create_publication_workspace(client, source_workspace_id: str, source_system_id: str, target_workspace_id: str, + target_system_id: str, title: str, description="", is_review=False): portal_admin_username = settings.PORTAL_ADMIN_USERNAME service_client = service_account() - # add admin to the source workspace to allow for file copying - resp = add_user_to_workspace(client, source_workspace_id, portal_admin_username) + # Determine workspace and system-specific settings based on the project type + system_prefix = settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX if is_review else settings.PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX + root_system_name = settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME if is_review else settings.PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME + root_dir = settings.PORTAL_PROJECTS_REVIEW_ROOT_DIR if is_review else settings.PORTAL_PROJECTS_PUBLISHED_ROOT_DIR + if is_review: + # Add admin to the source workspace to allow for file copying + add_user_to_workspace(client, source_workspace_id, portal_admin_username) + + # Retrieve the source project and adjust project data based on review/published status source_project = ProjectMetadata.get_project_by_id(source_system_id) + project_value = get_ordered_value(constants.PROJECT, source_project.value) - review_project = create_project_metadata({**source_project.value, "projectId": review_system_id, "is_review_project": True}) + project_data = { + **project_value, + "project_id": target_system_id, + "is_review_project": is_review, + "is_published_project": not is_review + } - review_project.save() - - create_workspace_dir(review_workspace_id, settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME) - - set_workspace_acls(service_client, - settings.PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME, - review_workspace_id, - portal_admin_username, - "add", - "writer") - - system_id = create_workspace_system(service_client, review_workspace_id, title, description, None, - f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.{review_workspace_id}", - f"{settings.PORTAL_PROJECTS_REVIEW_ROOT_DIR}/{review_workspace_id}") + # Create and save the new project metadata + new_project = create_project_metadata(project_data) + new_project.save() + + # Set up the target workspace directory + create_workspace_dir(target_workspace_id, root_system_name) + + # Configure workspace ACLs + set_workspace_acls(service_client, root_system_name, target_workspace_id, portal_admin_username, "add", "writer") + + query = f"(id.eq.{target_system_id})" + listing = service_client.systems.getSystems(listType='ALL', search=query, select="id,deleted", + showDeleted=True, limit=-1) - return system_id + if listing and listing[0].deleted: + service_client.systems.undeleteSystem(systemId=target_system_id) + # Add back system credentials since the system was previously deleted + create_system_credentials(service_client, portal_admin_username, settings.PORTAL_PROJECTS_PUBLIC_KEY, + settings.PORTAL_PROJECTS_PRIVATE_KEY, target_system_id) + else: + # Create the target workspace system + create_workspace_system( + service_client, target_workspace_id, title, description, None, + f"{system_prefix}.{target_workspace_id}", + f"{root_dir}/{target_workspace_id}" + ) diff --git a/server/portal/apps/publications/migrations/0003_publication.py b/server/portal/apps/publications/migrations/0003_publication.py new file mode 100644 index 000000000..9c5363b3e --- /dev/null +++ b/server/portal/apps/publications/migrations/0003_publication.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-11-01 19:52 + +import django.core.serializers.json +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('publications', '0002_alter_publicationrequest_review_project_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Publication', + fields=[ + ('project_id', models.CharField(editable=False, max_length=100, primary_key=True, serialize=False)), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('is_published', models.BooleanField(default=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('version', models.IntegerField(default=1)), + ('value', models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder, help_text="Value for the project's base metadata, including title/description/users")), + ('tree', models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder, help_text='JSON document containing the serialized publication tree')), + ], + ), + ] diff --git a/server/portal/apps/publications/models.py b/server/portal/apps/publications/models.py index 99d360cb6..a9bba8076 100644 --- a/server/portal/apps/publications/models.py +++ b/server/portal/apps/publications/models.py @@ -8,6 +8,7 @@ from django.db import models from django.utils import timezone from portal.apps.projects.models.project_metadata import ProjectMetadata +from django.core.serializers.json import DjangoJSONEncoder # pylint: disable=invalid-name logger = logging.getLogger(__name__) @@ -30,4 +31,23 @@ class Status(models.TextChoices): last_updated = models.DateTimeField(auto_now=True) def __str__(self): - return f'Review for {self.review_project.project_id}' \ No newline at end of file + return f'Review for {self.review_project.project_id}' + +class Publication(models.Model): + + project_id = models.CharField(max_length=100, primary_key=True, editable=False) + created = models.DateTimeField(default=timezone.now) + is_published = models.BooleanField(default=True) + last_updated = models.DateTimeField(auto_now=True) + version = models.IntegerField(default=1) + value = models.JSONField( + encoder=DjangoJSONEncoder, + help_text=( + "Value for the project's base metadata, including title/description/users" + ), + ) + + tree = models.JSONField( + encoder=DjangoJSONEncoder, + help_text=("JSON document containing the serialized publication tree"), + ) \ No newline at end of file diff --git a/server/portal/apps/publications/urls.py b/server/portal/apps/publications/urls.py index ab73238c5..41c4aee40 100644 --- a/server/portal/apps/publications/urls.py +++ b/server/portal/apps/publications/urls.py @@ -7,4 +7,8 @@ urlpatterns = [ path('publication-request/', views.PublicationRequestView.as_view(), name='publication_request'), path('publication-request//', views.PublicationRequestView.as_view(), name='publication_request_detail'), + path('publish/', views.PublicationPublishView.as_view(), name='publication_publish'), + path('reject/', views.PublicationRejectView.as_view(), name='publication_reject'), + path('version/', views.PublicationVersionView.as_view(), name='publication_version'), + path('', views.PublicationListingView.as_view(), name='publication_listing'), ] \ No newline at end of file diff --git a/server/portal/apps/publications/views.py b/server/portal/apps/publications/views.py index 8cccae9f1..ade977e38 100644 --- a/server/portal/apps/publications/views.py +++ b/server/portal/apps/publications/views.py @@ -11,19 +11,20 @@ from django.utils.decorators import method_decorator from portal.exceptions.api import ApiException from portal.views.base import BaseApiView -from portal.apps.projects.workspace_operations.shared_workspace_operations import create_publication_review_shared_workspace +from portal.apps.projects.workspace_operations.shared_workspace_operations import create_publication_workspace +from portal.apps.projects.workspace_operations.project_publish_operations import copy_graph_and_files_for_review_system, publish_project, update_and_cleanup_review_project from portal.apps.projects.models.metadata import ProjectsMetadata from django.db import transaction -from portal.apps.projects.tasks import copy_graph_and_files from portal.apps.notifications.models import Notification from django.http import HttpResponse -from portal.apps.publications.models import PublicationRequest +from portal.apps.publications.models import Publication, PublicationRequest from portal.apps.projects.models.project_metadata import ProjectMetadata from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth import get_user_model +from portal.libs.agave.utils import service_account - - -LOGGER = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class PublicationRequestView(BaseApiView): @@ -67,27 +68,54 @@ def get(self, request, project_id: str): @method_decorator(login_required, name='dispatch') def post(self, request): - data = json.loads(request.body) + request_body = json.loads(request.body) client = request.user.tapis_oauth.client + service_client = service_account() - source_workspace_id = data['projectId'] + full_project_id = request_body.get('project_id') + + if not full_project_id: + raise ApiException("Missing project ID", status=400) + + source_workspace_id = full_project_id.split(f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.")[1] review_workspace_id = f"{source_workspace_id}" source_system_id = f'{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{source_workspace_id}' review_system_id = f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.{review_workspace_id}" with transaction.atomic(): - # Update authors for the source project - source_project = ProjectMetadata.get_project_by_id(source_system_id) + # Update authors for the source project # TODO: use pydantic to validate data - source_project.value['authors'] = data['authors'] + source_project = ProjectMetadata.get_project_by_id(source_system_id) + source_project.value['authors'] = request_body.get('authors') source_project.save() - system_id = create_publication_review_shared_workspace(client, source_workspace_id, source_system_id, review_workspace_id, - review_system_id, data['title'], data['description']) + create_publication_workspace(client, source_workspace_id, source_system_id, review_workspace_id, + review_system_id, request_body.get('title'), request_body.get('description'), True) + + # Create publication request + review_project = ProjectMetadata.get_project_by_id(review_system_id) + source_project = ProjectMetadata.get_project_by_id(source_system_id) + publication_reviewers = get_user_model().objects.filter(groups__name=settings.PORTAL_PUBLICATION_REVIEWERS_GROUP_NAME) + + publication_request = PublicationRequest( + review_project=review_project, + source_project=source_project, + ) + + publication_request.save() + + for reviewer in publication_reviewers: + try: + publication_request.reviewers.add(reviewer) + except ObjectDoesNotExist: + continue + + publication_request.save() + logger.info(f'Created publication review for system {review_system_id}') # Start task to copy files and metadata - copy_graph_and_files.apply_async(kwargs={ + copy_graph_and_files_for_review_system.apply_async(kwargs={ 'user_access_token': client.access_token.access_token, 'source_workspace_id': source_workspace_id, 'review_workspace_id': review_workspace_id, @@ -106,4 +134,147 @@ def post(self, request): with transaction.atomic(): Notification.objects.create(**event_data) - return HttpResponse('OK') \ No newline at end of file + return JsonResponse({'response': 'OK'}) + +class PublicationListingView(BaseApiView): + + def get(self, request): + + publications = Publication.objects.all() + + publications_data = [ + { + 'id': publication.value.get('projectId'), + 'title': publication.value.get('title'), + 'description': publication.value.get('description'), + 'keywords': publication.value.get('keywords'), + 'authors': publication.value.get('authors'), + 'publication_date': publication.last_updated, + } + for publication in publications + ] + + return JsonResponse({'response': publications_data}, safe=False) + +class PublicationPublishView(BaseApiView): + + def post(self, request): + """view for publishing a project""" + + client = request.user.tapis_oauth.client + request_body = json.loads(request.body) + + full_project_id = request_body.get('project_id') + is_review = request_body.get('is_review_project', False) + + if not full_project_id: + raise ApiException("Missing project ID", status=400) + + if is_review: + project_id = full_project_id.split(f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.")[1] + else: + project_id = full_project_id.split(f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.")[1] + + source_system_id = f'{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.{project_id}' + published_workspace_id = f"{project_id}" + published_system_id = f"{settings.PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX}.{published_workspace_id}" + + create_publication_workspace(client, project_id, source_system_id, published_workspace_id, published_system_id, + request_body.get('title'), request_body.get('description'), False) + + publish_project.apply_async(kwargs={ + 'project_id': project_id, + 'version': 1 + }) + + # Create notification + event_data = { + Notification.EVENT_TYPE: 'default', + Notification.STATUS: Notification.INFO, + Notification.USER: request.user.username, + Notification.MESSAGE: f'{project_id} submitted for publication', + } + + with transaction.atomic(): + Notification.objects.create(**event_data) + + return JsonResponse({'response': 'OK'}) + + +class PublicationVersionView(BaseApiView): + + def post(self, request): + """view for publishing a project""" + + client = request.user.tapis_oauth.client + request_body = json.loads(request.body) + + full_project_id = request_body.get('project_id') + is_review = request_body.get('is_review_project', False) + + if not full_project_id: + raise ApiException("Missing project ID", status=400) + + if is_review: + project_id = full_project_id.split(f"{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.")[1] + else: + project_id = full_project_id.split(f"{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.")[1] + + print('project_id:', project_id) + + publication = Publication.objects.get(project_id=project_id) + version = publication.version + 1 + + print(f"Version: {version}") + + source_system_id = f'{settings.PORTAL_PROJECTS_REVIEW_SYSTEM_PREFIX}.{project_id}' + published_workspace_id = f"{project_id}{f'v{version}' if version and version > 1 else ''}" + published_system_id = f"{settings.PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX}.{published_workspace_id}" + + print(f"Published Workspace ID: {published_workspace_id}") + + create_publication_workspace(client, project_id, source_system_id, published_workspace_id, published_system_id, + request_body.get('title'), request_body.get('description'), False) + + publish_project.apply_async(kwargs={ + 'project_id': project_id, + 'version': version + }) + + # Create notification + event_data = { + Notification.EVENT_TYPE: 'default', + Notification.STATUS: Notification.INFO, + Notification.USER: request.user.username, + Notification.MESSAGE: f'{project_id} submitted for publication', + } + + with transaction.atomic(): + Notification.objects.create(**event_data) + + return JsonResponse({'response': 'OK'}) + +class PublicationRejectView(BaseApiView): + + def post(self, request): + + request_body = json.loads(request.body) + full_project_id = request_body.get('project_id') + + if not full_project_id: + raise ApiException("Missing project ID", status=400) + + update_and_cleanup_review_project(full_project_id, PublicationRequest.Status.REJECTED) + + # Create notification + event_data = { + Notification.EVENT_TYPE: 'default', + Notification.STATUS: Notification.INFO, + Notification.USER: request.user.username, + Notification.MESSAGE: f'{full_project_id} was rejected', + } + + with transaction.atomic(): + Notification.objects.create(**event_data) + + return JsonResponse({'response': 'OK'}) \ No newline at end of file diff --git a/server/portal/apps/users/views.py b/server/portal/apps/users/views.py index 3946233fe..84dac6338 100644 --- a/server/portal/apps/users/views.py +++ b/server/portal/apps/users/views.py @@ -30,6 +30,12 @@ def get(self, request): if request.user.is_authenticated: u = request.user + try: + user = get_user_model().objects.get(username=u.username) + groups = [group.name for group in user.groups.all()] + except ObjectDoesNotExist: + groups = [] + out = { "first_name": u.first_name, "username": u.username, @@ -39,6 +45,7 @@ def get(self, request): "expires_in": u.tapis_oauth.expires_in, }, "isStaff": u.is_staff, + "groups": groups } return JsonResponse(out) diff --git a/server/portal/libs/agave/operations.py b/server/portal/libs/agave/operations.py index c8ff60308..c957b5bd6 100644 --- a/server/portal/libs/agave/operations.py +++ b/server/portal/libs/agave/operations.py @@ -507,7 +507,8 @@ def trash(client, system, path, homeDir, metadata=None): if err.response.status_code != 404: logger.error(f'Unexpected exception listing .trash path in {system}') raise - add_node_to_project(system, 'NODE_ROOT', None, settings.TAPIS_DEFAULT_TRASH_NAME, settings.TAPIS_DEFAULT_TRASH_NAME) + trash_entity = create_entity_metadata(system, constants.TRASH, {}) + add_node_to_project(system, 'NODE_ROOT', trash_entity.uuid, trash_entity.name, settings.TAPIS_DEFAULT_TRASH_NAME) mkdir(client, system, homeDir, settings.TAPIS_DEFAULT_TRASH_NAME) resp = move(client, system, path, system, diff --git a/server/portal/settings/settings.py b/server/portal/settings/settings.py index b2b270599..bdd8f8633 100644 --- a/server/portal/settings/settings.py +++ b/server/portal/settings/settings.py @@ -580,6 +580,18 @@ PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME = settings_custom.\ _PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME +PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX = settings_custom.\ + _PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX + +PORTAL_PROJECTS_PUBLISHED_ROOT_DIR = settings_custom.\ + _PORTAL_PROJECTS_PUBLISHED_ROOT_DIR + +PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME = settings_custom.\ + _PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME + +PORTAL_PUBLICATION_REVIEWERS_GROUP_NAME = settings_custom.\ + _PORTAL_PUBLICATION_REVIEWERS_GROUP_NAME + PORTAL_PROJECTS_PRIVATE_KEY = settings_secret.\ _PORTAL_PROJECTS_PRIVATE_KEY diff --git a/server/portal/settings/settings_default.py b/server/portal/settings/settings_default.py index e1455c97d..eb12d24d9 100644 --- a/server/portal/settings/settings_default.py +++ b/server/portal/settings/settings_default.py @@ -103,7 +103,7 @@ 'siteSearchPriority': 0 }, { - 'name': 'Project', + 'name': 'Projects', 'scheme': 'projects', 'api': 'tapis', 'icon': 'publications', @@ -112,9 +112,20 @@ 'defaultProject': True, 'system': 'cep.project.root', 'rootDir': '/corral-repl/tacc/aci/CEP/projects', + }, + { + 'name': 'Published', + 'scheme': 'projects', + 'api': 'tapis', + 'icon': 'publications', + 'readOnly': True, + 'hideSearchBar': False, + 'system': 'drp.project.published.test', + 'rootDir': '/corral-repl/utexas/pge-nsf/data_pprd/published', + 'publicationProject': True, }, { - 'name': 'Review Projects', + 'name': 'Review', 'scheme': 'projects', 'api': 'tapis', 'icon': 'publications', @@ -122,6 +133,7 @@ 'hideSearchBar': False, 'system': 'drp.project.review.test', 'rootDir': '/corral-repl/utexas/pge-nsf/data_pprd/test', + 'reviewProject': True, } ] @@ -205,6 +217,12 @@ _PORTAL_PROJECTS_REVIEW_ROOT_DIR = '/corral-repl/utexas/pge-nsf/data_pprd/test' _PORTAL_PROJECTS_ROOT_REVIEW_SYSTEM_NAME = 'drp.project.review.test' +_PORTAL_PROJECTS_PUBLISHED_SYSTEM_PREFIX = 'cep.project.published' +_PORTAL_PROJECTS_PUBLISHED_ROOT_DIR = '/corral-repl/utexas/pge-nsf/data_pprd/published' +_PORTAL_PROJECTS_PUBLISHED_ROOT_SYSTEM_NAME = 'drp.project.published.test' + +_PORTAL_PUBLICATION_REVIEWERS_GROUP_NAME = 'PROJECT_REVIEWER' + ######################## # Custom Portal Template Assets # Asset path root is static files output dir. From ee5b1430ac519d07b1d23cc983c3512f1f7924cb Mon Sep 17 00:00:00 2001 From: shayanaijaz Date: Fri, 8 Nov 2024 13:50:38 -0600 Subject: [PATCH 05/12] quick css fix --- .../DataFilesProjectPublishWizard.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/DataFilesProjectPublishWizard.module.scss b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/DataFilesProjectPublishWizard.module.scss index 0e2ebbbd4..4dc5e34c1 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/DataFilesProjectPublishWizard.module.scss +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/DataFilesProjectPublishWizard.module.scss @@ -98,6 +98,6 @@ } .submit-button { - min-width: 200px; + min-width: 200px !important; margin: 20px; } From eb144bdaab6334d9a7bb470cfb90c967b3afb3b0 Mon Sep 17 00:00:00 2001 From: shayanaijaz Date: Mon, 11 Nov 2024 15:08:17 -0600 Subject: [PATCH 06/12] fix keywords not appearing --- .../DataFilesProjectFileListingMetadataAddon.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx index ffd414184..246af5fa9 100644 --- a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx @@ -36,6 +36,10 @@ const DataFilesProjectFileListingMetadataAddon = ({ formattedMetadata.doi = metadata.doi; } + if (metadata.keywords) { + formattedMetadata.keywords = metadata.keywords; + } + return formattedMetadata; }; From c8f4164f40a8225d0b9437a7885e37dce79618e6 Mon Sep 17 00:00:00 2001 From: shayanaijaz Date: Mon, 11 Nov 2024 14:56:18 -0600 Subject: [PATCH 07/12] new columns/info for review and published projects --- client/src/components/DataFiles/DataFiles.jsx | 3 + .../DataFilesModals/DataFilesModals.jsx | 4 +- .../DataFilesProjectDescriptionModal.jsx | 40 +++++ ...taFilesProjectDescriptionModal.module.scss | 0 .../DataFilesPublicationsList.jsx | 41 ++++- .../DataFilesPublicationsList.scss | 17 ++- .../DataFilesReviewProjectList.jsx | 141 ++++++++++++++++++ .../DataFilesReviewProjectList.module.scss | 17 +++ .../DataFilesReviewProjectList.scss | 27 ++++ server/portal/apps/projects/views.py | 8 + 10 files changed, 284 insertions(+), 14 deletions(-) create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.module.scss create mode 100644 client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.jsx create mode 100644 client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.module.scss create mode 100644 client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.scss diff --git a/client/src/components/DataFiles/DataFiles.jsx b/client/src/components/DataFiles/DataFiles.jsx index e2c440425..68dfc97c6 100644 --- a/client/src/components/DataFiles/DataFiles.jsx +++ b/client/src/components/DataFiles/DataFiles.jsx @@ -25,6 +25,7 @@ import DataFilesProjectsList from './DataFilesProjectsList/DataFilesProjectsList import DataFilesProjectFileListing from './DataFilesProjectFileListing/DataFilesProjectFileListing'; import { useSystemRole } from './DataFilesProjectMembers/_cells/SystemRoleSelector'; import DataFilesPublicationsList from './DataFilesPublicationsList/DataFilesPublicationsList'; +import DataFilesReviewProjectList from './DataFilesReviewProjectsList/DataFilesReviewProjectList'; const DefaultSystemRedirect = () => { const systems = useSelector( @@ -102,6 +103,8 @@ const DataFilesSwitch = React.memo(() => { if (system.publicationProject) { return ; + } else if (system.reviewProject) { + return ; } return ; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx index d726c9a14..7f4746499 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx @@ -20,6 +20,7 @@ import './DataFilesModals.scss'; import DataFilesFormModal from './DataFilesFormModal'; import DataFilesPublicationRequestModal from './DataFilesPublicationRequestModal'; import DataFilesProjectTreeModal from './DataFilesProjectTreeModal'; +import DataFilesProjectDescriptionModal from './DataFilesProjectDescriptionModal'; export default function DataFilesModals() { return ( @@ -42,8 +43,9 @@ export default function DataFilesModals() { - + + ); } diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx new file mode 100644 index 000000000..621add801 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectDescriptionModal.module.scss'; + +const DataFilesProjectDescriptionModal = () => { + const dispatch = useDispatch(); + + const isOpen = useSelector((state) => state.files.modals.projectDescription); + const props = useSelector( + (state) => state.files.modalProps.projectDescription + ); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectDescription', props: {} }, + }); + }, []); + + return ( + <> + + + {props?.title} + + +

{props?.description}

+
+
+ + ); +}; + +export default DataFilesProjectDescriptionModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx index a7844bf4e..4bd69ecf4 100644 --- a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx +++ b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.jsx @@ -4,6 +4,7 @@ import { Link, useLocation } from 'react-router-dom'; import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import queryStringParser from 'query-string'; import { + Button, InfiniteScrollTable, SectionMessage, SectionTableWrapper, @@ -41,6 +42,16 @@ const DataFilesPublicationsList = ({ rootSystem }) => { }); }, [dispatch, query.query_string]); + const createProjectDescriptionModal = (title, description) => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'projectDescription', + props: { title, description }, + }, + }); + }; + const columns = [ { Header: 'Publication Title', @@ -54,6 +65,13 @@ const DataFilesPublicationsList = ({ rootSystem }) => { ), }, + { + Header: 'Publication Date', + accessor: 'publication_date', + Cell: (el) => ( + {el.value ? formatDate(new Date(el.value)) : ''} + ), + }, { Header: 'Principal Investigator', accessor: 'authors', @@ -66,15 +84,24 @@ const DataFilesPublicationsList = ({ rootSystem }) => { ), }, { - Header: 'Keywords', - accessor: 'keywords', + Header: 'Description', + accessor: 'description', + Cell: (el) => { + return ( + + ); + }, }, { - Header: 'Publication Date', - accessor: 'publication_date', - Cell: (el) => ( - {el.value ? formatDate(new Date(el.value)) : ''} - ), + Header: 'Keywords', + accessor: 'keywords', }, ]; diff --git a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss index a87507046..c25dafd70 100644 --- a/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss +++ b/client/src/components/DataFiles/DataFilesPublicationsList/DataFilesPublicationsList.scss @@ -4,19 +4,24 @@ td:nth-child(1) { width: 40%; } - /* authors */ + /* date */ th:nth-child(2), td:nth-child(2) { - width: 20%; + width: 10%; } - /* keywords */ + /* author */ th:nth-child(3), td:nth-child(3) { - width: 25%; + width: 15%; } - /* date */ + /* description */ th:nth-child(4), td:nth-child(4) { - width: 15%; + width: 10%; + } + /* keywords */ + th:nth-child(5), + td:nth-child(5) { + width: 25%; } } diff --git a/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.jsx b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.jsx new file mode 100644 index 000000000..ff13e444e --- /dev/null +++ b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.jsx @@ -0,0 +1,141 @@ +import React, { useEffect, useCallback } from 'react'; +import { + Button, + InfiniteScrollTable, + SectionMessage, + SectionTableWrapper, +} from '_common'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useLocation } from 'react-router-dom'; +import Searchbar from '_common/Searchbar'; +import './DataFilesReviewProjectList.scss'; +import styles from './DataFilesReviewProjectList.module.scss'; +import queryStringParser from 'query-string'; +import { formatDate } from 'utils/timeFormat'; + +const DataFilesReviewProjectList = ({ rootSystem }) => { + const { error, loading, projects } = useSelector( + (state) => state.projects.listing + ); + + const query = queryStringParser.parse(useLocation().search); + + const infiniteScrollCallback = useCallback(() => {}); + const dispatch = useDispatch(); + + useEffect(() => { + const actionType = 'PROJECTS_SHOW_SHARED_WORKSPACES'; + dispatch({ + type: actionType, + payload: { + queryString: query.query_string, + rootSystem: rootSystem, + }, + }); + }, [dispatch, query.query_string, rootSystem]); + + const createProjectDescriptionModal = (title, description) => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'projectDescription', + props: { title, description }, + }, + }); + }; + + const columns = [ + { + Header: `Request Title`, + accessor: 'title', + Cell: (el) => ( + + {el.value} + + ), + }, + { + Header: 'Requested Date', + accessor: 'updated', + Cell: (el) => ( + {el.value ? formatDate(new Date(el.value)) : ''} + ), + }, + { + Header: 'Principal Investigator', + accessor: 'authors', + Cell: (el) => ( + + {el.value?.length > 0 + ? `${el.value[0].first_name} ${el.value[0].last_name}` + : ''} + + ), + }, + { + Header: 'Description', + accessor: 'description', + Cell: (el) => { + return ( + + ); + }, + }, + { + Header: 'Keywords', + accessor: 'keywords', + }, + ]; + + const noDataText = query.query_string + ? `No Projects match your search term.` + : `You don't have any requests to review`; + + if (error) { + return ( +
+ + There was a problem retrieving your {sharedWorkspacesDisplayName}. + +
+ ); + } + + return ( + + +
+ +
+
+ ); +}; + +export default DataFilesReviewProjectList; diff --git a/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.module.scss b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.module.scss new file mode 100644 index 000000000..28e23efaf --- /dev/null +++ b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.module.scss @@ -0,0 +1,17 @@ +.root { + /* As a flex child */ + flex-grow: 1; + + /* WARNING: Mimicked on: History, Allocation, DataFiles, DataFilesProjectsList, DataFilesProjectFileListing, PublicData */ + padding-top: 1.75rem; /* ~28px (22.5px * design * 1.2 design-to-app ratio) */ + padding-left: 1.5em; /* ~24px (20px * design * 1.2 design-to-app ratio) */ +} + +/* NOTE: Mimicked on: DataFiles, DataFilesProjectsList, DataFilesProjectFileListing */ +.root-placeholder { + flex-grow: 1; + + display: flex; + align-items: center; + justify-content: center; +} diff --git a/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.scss b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.scss new file mode 100644 index 000000000..21188ad57 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesReviewProjectsList/DataFilesReviewProjectList.scss @@ -0,0 +1,27 @@ +.review-projects-listing { + /* title */ + th:nth-child(1), + td:nth-child(1) { + width: 40%; + } + /* date */ + th:nth-child(2), + td:nth-child(2) { + width: 10%; + } + /* author */ + th:nth-child(3), + td:nth-child(3) { + width: 15%; + } + /* description */ + th:nth-child(4), + td:nth-child(4) { + width: 10%; + } + /* keywords */ + th:nth-child(5), + td:nth-child(5) { + width: 25%; + } +} diff --git a/server/portal/apps/projects/views.py b/server/portal/apps/projects/views.py index 83d6473d1..f2ff4b05d 100644 --- a/server/portal/apps/projects/views.py +++ b/server/portal/apps/projects/views.py @@ -116,6 +116,14 @@ def get(self, request, root_system=None): client = request.user.tapis_oauth.client listing = list_projects(client, root_system) + for project in listing: + try: + project_meta = ProjectMetadata.objects.get(models.Q(value__projectId=project['id'])) + project.update(get_ordered_value(project_meta.name, project_meta.value)) + project["projectId"] = project['id'] + except ProjectMetadata.DoesNotExist: + pass + tapis_project_listing_indexer.delay(listing) return JsonResponse({"status": 200, "response": listing}) From 307bd8bf219114c7d8d40f11b559298efef8ec2d Mon Sep 17 00:00:00 2001 From: shayanaijaz Date: Tue, 12 Nov 2024 12:09:57 -0600 Subject: [PATCH 08/12] Publication UI improvements - Added citation box and citation modal for publications - Removed Last Modified column on publication listing - Changed Created to Published Date - Added data view modal to view related project information --- .../DataFilesListing/DataFilesListing.jsx | 18 ++++ .../DataFilesModals/DataFilesModals.jsx | 4 + .../DataFilesProjectCitationModal.jsx | 44 ++++++++++ .../DataFilesProjectCitationModal.module.scss | 0 .../DataFilesViewDataModal.jsx | 64 ++++++++++++++ .../DataFilesViewDataModal.module.scss | 49 +++++++++++ .../DataFilesProjectFileListing.jsx | 13 ++- ...taFilesProjectFileListingMetadataAddon.jsx | 73 ++++++++++++---- ...rojectFileListingMetadataAddon.module.scss | 10 +++ .../ReviewAuthors.jsx | 84 ++++++++++++++----- .../drp/utils/DataDisplay/DataDisplay.jsx | 50 ++++++++--- .../project_publish_operations.py | 2 + 12 files changed, 362 insertions(+), 49 deletions(-) create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.module.scss create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.jsx create mode 100644 client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.module.scss diff --git a/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx b/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx index 54a828394..0552a10a3 100644 --- a/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx +++ b/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx @@ -166,6 +166,24 @@ const DataFilesListing = ({ width: 0.2, }); } + + if (scheme === 'projects' && rootSystem) { + const projectSystem = systems.find( + (s) => s.scheme === 'projects' && s.system === rootSystem + ); + + if (projectSystem && projectSystem.publicationProject) { + const index = cells.findIndex( + (cell) => cell.Header === 'Last Modified' + ); + cells.splice(index, 1); + ['Name', 'Size'].forEach((header) => { + const column = cells.find((col) => col.Header === header); + column.width += 0.1; + }); + } + } + return cells; }, [api, showViewPath, fileNavCellCallback]); diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx index 7f4746499..e250ac0c8 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx @@ -21,6 +21,8 @@ import DataFilesFormModal from './DataFilesFormModal'; import DataFilesPublicationRequestModal from './DataFilesPublicationRequestModal'; import DataFilesProjectTreeModal from './DataFilesProjectTreeModal'; import DataFilesProjectDescriptionModal from './DataFilesProjectDescriptionModal'; +import DataFilesViewDataModal from './DataFilesViewDataModal'; +import DataFilesProjectCitationModal from './DataFilesProjectCitationModal'; export default function DataFilesModals() { return ( @@ -46,6 +48,8 @@ export default function DataFilesModals() { + + ); } diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx new file mode 100644 index 000000000..602ca07c6 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectCitationModal.module.scss'; +import { Citations } from '_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors'; + +const DataFilesProjectCitationModal = () => { + const dispatch = useDispatch(); + + const isOpen = useSelector((state) => state.files.modals.projectCitation); + const props = useSelector((state) => state.files.modalProps.projectCitation); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectCitation', props: {} }, + }); + }, []); + + return ( + <> + {props?.project && ( + + + Citations + + + + + + )} + + ); +}; + +export default DataFilesProjectCitationModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.jsx new file mode 100644 index 000000000..090d59a22 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.jsx @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesViewDataModal.module.scss'; +import DescriptionList from '_common/DescriptionList'; + +const formatLabel = (key) => + key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +const DataFilesViewDataModal = () => { + const dispatch = useDispatch(); + const isOpen = useSelector((state) => state.files.modals.viewData); + const props = useSelector((state) => state.files.modalProps.viewData); + + const [descriptionListData, setDescriptionListData] = useState({}); + + useEffect(() => { + if (!props?.value) return; + + const values = Array.isArray(props.value) ? props.value : [props.value]; + + const descriptionListFormattedData = values.map((val) => { + const formattedData = Object.entries(val).reduce((acc, [key, value]) => { + acc[formatLabel(key)] = value; + return acc; + }, {}); + return ; + }); + + // Prevents description list from having a header + setDescriptionListData({ '': descriptionListFormattedData }); + }, [props]); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'viewData', props: {} }, + }); + }, [dispatch]); + + return ( + + + {props?.key ? formatLabel(props.key) : ''} + + + + + + ); +}; + +export default DataFilesViewDataModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.module.scss new file mode 100644 index 000000000..27a26df3b --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesViewDataModal.module.scss @@ -0,0 +1,49 @@ +.panel-content { + width: 100%; + overflow-y: scroll; + + /* Cross-browser solution to padding ignored by overflow (in spec-compliant Firefox) */ + /* SEE: https://stackoverflow.com/a/38997047/11817077 */ + padding-bottom: 0; + &::after { + content: ''; + display: block; + height: var(--padding); + } +} + +dl.panel-content { + --buffer-horz: 12px; /* ~10px design * 1.2 design-to-app ratio */ + --buffer-vert: 10px; /* gut feel based loosely on random space from design */ + --border: var(--global-border-width--normal) solid + var(--global-color-primary--light); +} + +dl.panel-content > dt, +dl.panel-content > dd { + padding-left: var(--buffer-horz); + padding-right: var(--buffer-horz); + padding-top: var(--buffer-vert); +} + +dl.panel-content > dt { + border-top: var(--border); +} +dl.panel-content > dt:first-of-type { + border-top: none; +} + +dl.panel-content > dt:nth-of-type(even), +dl.panel-content > dd:nth-of-type(even) { + background-color: var(--global-color-primary--x-light); +} + +/* Remove the colon from top-level labels */ +dl.panel-content > dt::after { + display: none; +} + +.modal-body { + overflow: auto; + max-height: 80vh; +} diff --git a/client/src/components/DataFiles/DataFilesProjectFileListing/DataFilesProjectFileListing.jsx b/client/src/components/DataFiles/DataFilesProjectFileListing/DataFilesProjectFileListing.jsx index be22cfe3c..25467417d 100644 --- a/client/src/components/DataFiles/DataFilesProjectFileListing/DataFilesProjectFileListing.jsx +++ b/client/src/components/DataFiles/DataFilesProjectFileListing/DataFilesProjectFileListing.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { Button, @@ -15,6 +15,11 @@ import styles from './DataFilesProjectFileListing.module.scss'; const DataFilesProjectFileListing = ({ rootSystem, system, path }) => { const dispatch = useDispatch(); const { fetchListing } = useFileListing('FilesListing'); + const systems = useSelector( + (state) => state.systems.storage.configuration.filter((s) => !s.hidden), + shallowEqual + ); + const [isPublicationSystem, setIsPublicationSystem] = useState(false); // logic to render addonComponents for DRP const portalName = useSelector((state) => state.workbench.portalName); @@ -39,6 +44,11 @@ const DataFilesProjectFileListing = ({ rootSystem, system, path }) => { fetchListing({ api: 'tapis', scheme: 'projects', system, path }); }, [system, path, fetchListing]); + useEffect(() => { + const system = systems.find((s) => s.system === rootSystem); + setIsPublicationSystem(system?.publicationProject); + }, [systems, rootSystem]); + const metadata = useSelector((state) => state.projects.metadata); const folderMetadata = useSelector( (state) => state.files.folderMetadata?.FilesListing @@ -152,6 +162,7 @@ const DataFilesProjectFileListing = ({ rootSystem, system, path }) => { folderMetadata={folderMetadata} metadata={metadata} path={path} + showCitation={isPublicationSystem} /> ) : ( diff --git a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx index 246af5fa9..32950382a 100644 --- a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.jsx @@ -4,6 +4,9 @@ import styles from './DataFilesProjectFileListingMetadataAddon.module.scss'; import { useFileListing } from 'hooks/datafiles'; import DataDisplay from '../utils/DataDisplay/DataDisplay'; import { formatDate } from 'utils/timeFormat'; +import { MLACitation } from '../DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors'; +import { Button } from '_common'; +import { useDispatch } from 'react-redux'; const excludeKeys = [ 'name', @@ -18,29 +21,53 @@ const DataFilesProjectFileListingMetadataAddon = ({ folderMetadata, metadata, path, + showCitation, }) => { + const dispatch = useDispatch(); + const { loading } = useFileListing('FilesListing'); - const getProjectMetadata = (metadata) => { + const getProjectMetadata = ({ + publication_date, + created, + license, + doi, + keywords, + }) => { const dateOptions = { month: 'long', day: 'numeric', year: 'numeric' }; - const formattedMetadata = { - created: new Date(metadata.created).toLocaleDateString( - 'en-US', - dateOptions - ), - license: metadata.license ?? 'None', + return { + publication_date: new Date( + publication_date || created + ).toLocaleDateString('en-US', dateOptions), + license: license ?? 'None', + ...(doi && { doi }), + ...(keywords && { keywords }), }; + }; - if (metadata.doi) { - formattedMetadata.doi = metadata.doi; - } - - if (metadata.keywords) { - formattedMetadata.keywords = metadata.keywords; - } + const getProjectModalMetadata = (metadata) => { + const fields = [ + 'related_publications', + 'related_software', + 'related_datasets', + ]; + return fields.reduce((formattedMetadata, field) => { + if (metadata[field] && metadata[field].length > 0) { + formattedMetadata[field] = metadata[field]; + } + return formattedMetadata; + }, {}); + }; - return formattedMetadata; + const createProjectCitationModal = (project) => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'projectCitation', + props: { project }, + }, + }); }; return ( @@ -57,11 +84,27 @@ const DataFilesProjectFileListingMetadataAddon = ({ ) : ( <> + {showCitation && ( +
+

Cite This Data:

+ +
+ +
+
+ )} {metadata.description} ))} diff --git a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.module.scss b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.module.scss index 47d675087..e4923b491 100644 --- a/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.module.scss +++ b/client/src/components/_custom/drp/DataFilesProjectFileListingMetadataAddon/DataFilesProjectFileListingMetadataAddon.module.scss @@ -11,3 +11,13 @@ padding: 0; margin-bottom: -3px; } + +.citation-box { + padding: 10px; + margin-bottom: 20px; + background-color: var(--global-color-primary--x-light); +} + +.citation-button { + margin-top: 5px; +} diff --git a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx index eaf4ca47d..49572392c 100644 --- a/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx +++ b/client/src/components/_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ReviewAuthors.jsx @@ -16,13 +16,22 @@ import ProjectMembersList from '../../utils/ProjectMembersList/ProjectMembersLis import { useSelector } from 'react-redux'; const ACMCitation = ({ project, authors }) => { - const authorString = authors.map(a => `${a.first_name} ${a.last_name}`).join(', '); - const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; - const createdDate = new Date(project.created).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + const authorString = authors + .map((a) => `${a.first_name} ${a.last_name}`) + .join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId + .split('-') + .pop()}`; + const createdDate = new Date(project.created).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); return (
- {`${authorString}. ${project.title}. `} Digital Rocks Portal {` (${createdDate}). ${projectUrl}`}
+ {`${authorString}. ${project.title}. `} Digital Rocks Portal{' '} + {` (${createdDate}). ${projectUrl}`}{' '} +
); }; @@ -32,8 +41,15 @@ const APACitation = ({ project, authors }) => { .join(', '); const projectUrl = `https://www.digitalrocksportal.org`; const createdDateObj = new Date(project.created); - const createdDate = `${createdDateObj.getFullYear()}, ${createdDateObj.toLocaleString('en-US', { month: 'long' })} ${createdDateObj.getDate()}`; - const accessDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + const createdDate = `${createdDateObj.getFullYear()}, ${createdDateObj.toLocaleString( + 'en-US', + { month: 'long' } + )} ${createdDateObj.getDate()}`; + const accessDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); return (
{`${authorString} (${createdDate}). ${project.title}. Retrieved ${accessDate}, from ${projectUrl}`}
@@ -41,8 +57,12 @@ const APACitation = ({ project, authors }) => { }; const BibTeXCitation = ({ project, authors }) => { - const authorString = authors.map(a => `${a.last_name}, ${a.first_name}`).join(' and '); - const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const authorString = authors + .map((a) => `${a.last_name}, ${a.first_name}`) + .join(' and '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId + .split('-') + .pop()}`; const year = new Date(project.created).getFullYear(); return ( @@ -57,38 +77,60 @@ const BibTeXCitation = ({ project, authors }) => { ); }; -const MLACitation = ({ project, authors }) => { - const authorString = authors.map(a => `${a.last_name}, ${a.first_name}`).join(', '); - const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; - const createdDate = new Date(project.created).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); - const accessDate = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); - +export const MLACitation = ({ project, authors }) => { + const authorString = authors + .map((a) => `${a.last_name}, ${a.first_name}`) + .join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId + .split('-') + .pop()}`; + const createdDate = new Date(project.created).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + const accessDate = new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); return ( -
{`${authorString}. "${project.title}."`} Digital Rocks Portal, {` Digital Rocks Portal, ${createdDate}, ${projectUrl} Accessed ${accessDate}.`}
+
+ {`${authorString}. "${project.title}."`} Digital Rocks Portal,{' '} + {` Digital Rocks Portal, ${createdDate}, ${projectUrl} Accessed ${accessDate}.`} +
); }; const IEEECitation = ({ project, authors }) => { - const authorString = authors.map(a => `${a.first_name[0]}. ${a.last_name}`).join(', '); - const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId.split('-').pop()}`; + const authorString = authors + .map((a) => `${a.first_name[0]}. ${a.last_name}`) + .join(', '); + const projectUrl = `https://www.digitalrocksportal.org/projects/${project.projectId + .split('-') + .pop()}`; const date = new Date(project.created); const year = date.getFullYear(); const day = date.getDate(); const month = date.toLocaleString('en-GB', { month: 'short' }); return ( -
{`[1] ${authorString}, "${project.title}",`} Digital Rocks Portal, {` ${year}. [Online]. Available: ${projectUrl}. [Accessed: ${day}-${month}-${year}]`}
+
+ {`[1] ${authorString}, "${project.title}",`}{' '} + Digital Rocks Portal,{' '} + {` ${year}. [Online]. Available: ${projectUrl}. [Accessed: ${day}-${month}-${year}]`} +
); }; -const Citations = ({ project, authors }) => ( +export const Citations = ({ project, authors }) => (

ACM ref

- +

APA

@@ -111,8 +153,6 @@ const Citations = ({ project, authors }) => (
); - - const ReviewAuthors = ({ project, onAuthorsUpdate }) => { const [authors, setAuthors] = useState([]); const [members, setMembers] = useState([]); diff --git a/client/src/components/_custom/drp/utils/DataDisplay/DataDisplay.jsx b/client/src/components/_custom/drp/utils/DataDisplay/DataDisplay.jsx index ab3622edb..f807ccd12 100644 --- a/client/src/components/_custom/drp/utils/DataDisplay/DataDisplay.jsx +++ b/client/src/components/_custom/drp/utils/DataDisplay/DataDisplay.jsx @@ -1,9 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Section, SectionContent, LoadingSpinner } from '_common'; +import { Section, SectionContent, LoadingSpinner, Button } from '_common'; import { useLocation, Link } from 'react-router-dom'; import styles from './DataDisplay.module.scss'; import { useFileListing } from 'hooks/datafiles'; +import { useDispatch } from 'react-redux'; + +// Function to format the dict key from snake_case to Label Case i.e. data_type -> Data Type +const formatLabel = (key) => + key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); const processSampleAndOriginData = (data, path) => { // use the path to get sample and origin data names @@ -49,28 +57,48 @@ const processSampleAndOriginData = (data, path) => { return sampleAndOriginMetadata; }; -const DataDisplay = ({ data, path, excludeKeys }) => { - const location = useLocation(); +const processModalViewableData = (data) => { + const createViewDataModal = (key, value) => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { + operation: 'viewData', + props: { key, value }, + }, + }); + }; + + const dispatch = useDispatch(); + + return Object.entries(data).map(([key, value]) => ({ + label: formatLabel(key), + value: ( + + ), + })); +}; - // Function to format the dict key from snake_case to Label Case i.e. data_type -> Data Type - const formatLabel = (key) => - key - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); +const DataDisplay = ({ data, path, excludeKeys, modalData }) => { + const location = useLocation(); //filter out empty values and unwanted keys - const processedData = Object.entries(data) + let processedData = Object.entries(data) .filter(([key, value]) => value !== '' && !excludeKeys.includes(key)) .map(([key, value]) => ({ label: formatLabel(key), - value: typeof value === 'string' ? formatLabel(value) : value, + value: typeof value === 'string' ? value : value, })); if (path) { processedData.unshift(...processSampleAndOriginData(data, path)); } + if (modalData) { + processedData.push(...processModalViewableData(modalData)); + } + // Divide processed data into chunks for two-column layout display const chunkSize = Math.ceil(processedData.length / 2); const chunks = []; diff --git a/server/portal/apps/projects/workspace_operations/project_publish_operations.py b/server/portal/apps/projects/workspace_operations/project_publish_operations.py index 481ea9f16..fc5579acb 100644 --- a/server/portal/apps/projects/workspace_operations/project_publish_operations.py +++ b/server/portal/apps/projects/workspace_operations/project_publish_operations.py @@ -122,12 +122,14 @@ def publish_project(self, project_id: str, version: Optional[int] = 1): source_project_id = f'{settings.PORTAL_PROJECTS_SYSTEM_PREFIX}.{project_id}' source_project = ProjectMetadata.get_project_by_id(source_project_id) source_project.value['doi'] = doi + source_project.value['publication_date'] = published_project.created source_project.save() pub_tree = nx.node_link_graph(published_project.project_graph.value) pub_tree.nodes["NODE_ROOT"]["version"] = version published_project.project_graph.value = nx.node_link_data(pub_tree) published_project.value['doi'] = doi + published_project.value['publication_date'] = published_project.created published_project.save() From f090f03e637eb817c742b63a4724846701369159 Mon Sep 17 00:00:00 2001 From: van-go <35277477+van-go@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:48:23 -0600 Subject: [PATCH 09/12] refactor: remove unnecessary comment in drp.sagas.js --- client/src/redux/sagas/_custom/drp.sagas.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/redux/sagas/_custom/drp.sagas.js b/client/src/redux/sagas/_custom/drp.sagas.js index 7c2777084..f18b4719c 100644 --- a/client/src/redux/sagas/_custom/drp.sagas.js +++ b/client/src/redux/sagas/_custom/drp.sagas.js @@ -50,7 +50,7 @@ function* executeOperation( ? `${path}/${file.path.split('/').pop()}` : path; - // Check if the file name has changed. If not, keep the same path. + // Check if the file name has changed. If not, keep the same path const reloadPath = isEdit && file.name !== values.name ? newPath.replace(`/${file.name}`, `/${values.name}`) From 2a3983402e9b41c8bd3522b16fab6501585f7498 Mon Sep 17 00:00:00 2001 From: van-go <35277477+van-go@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:52:49 -0600 Subject: [PATCH 10/12] Refactor path generation logic in DataFilesFormModal --- .../DataFiles/DataFilesModals/DataFilesFormModal.jsx | 6 +++++- client/src/redux/sagas/_custom/drp.sagas.js | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx index b13617b93..f245c804f 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx @@ -25,7 +25,11 @@ const DataFilesFormModal = () => { projectUrl = projectUrl.slice(0, -1); } - const path = updatedPath ? `${projectUrl}/${updatedPath}` : projectUrl; + // Avoid appending updatedPath if it's already part of projectUrl + const path = updatedPath && !projectUrl.endsWith(updatedPath) + ? `${projectUrl}/${updatedPath}` + : projectUrl; + history.replace(path); }; diff --git a/client/src/redux/sagas/_custom/drp.sagas.js b/client/src/redux/sagas/_custom/drp.sagas.js index f18b4719c..7a6908d81 100644 --- a/client/src/redux/sagas/_custom/drp.sagas.js +++ b/client/src/redux/sagas/_custom/drp.sagas.js @@ -53,7 +53,7 @@ function* executeOperation( // Check if the file name has changed. If not, keep the same path const reloadPath = isEdit && file.name !== values.name - ? newPath.replace(`/${file.name}`, `/${values.name}`) + ? newPath.replace(new RegExp(`/${file.name}$`), `/${values.name}`) : newPath; yield call(reloadCallback, reloadPath); From 27dd865b9a487d4ef50bf8b263479942d16894d3 Mon Sep 17 00:00:00 2001 From: van-go <35277477+van-go@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:58:59 -0600 Subject: [PATCH 11/12] Refactor path generation logic in DataFilesFormModal and executeOperation function in drp.sagas.js --- .../DataFilesModals/DataFilesFormModal.jsx | 13 +++++++------ client/src/redux/sagas/_custom/drp.sagas.js | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx index f245c804f..dcf830a75 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx @@ -20,19 +20,20 @@ const DataFilesFormModal = () => { /(\/projects\/[^/]+\/[^/]+\/?.*)/, '$1' ); - + if (projectUrl.endsWith('/')) { projectUrl = projectUrl.slice(0, -1); } - + // Avoid appending updatedPath if it's already part of projectUrl - const path = updatedPath && !projectUrl.endsWith(updatedPath) - ? `${projectUrl}/${updatedPath}` - : projectUrl; + const path = + updatedPath && !projectUrl.endsWith(updatedPath) + ? `${projectUrl}/${updatedPath}` + : projectUrl; history.replace(path); }; - + const { form, selectedFile, formName, additionalData, useReloadCallback } = useSelector((state) => state.files.modalProps.dynamicform); const isOpen = useSelector((state) => state.files.modals.dynamicform); diff --git a/client/src/redux/sagas/_custom/drp.sagas.js b/client/src/redux/sagas/_custom/drp.sagas.js index 7a6908d81..d84bff65b 100644 --- a/client/src/redux/sagas/_custom/drp.sagas.js +++ b/client/src/redux/sagas/_custom/drp.sagas.js @@ -49,13 +49,13 @@ function* executeOperation( isEdit && file.path === params.path ? `${path}/${file.path.split('/').pop()}` : path; - + // Check if the file name has changed. If not, keep the same path const reloadPath = isEdit && file.name !== values.name ? newPath.replace(new RegExp(`/${file.name}$`), `/${values.name}`) : newPath; - + yield call(reloadCallback, reloadPath); } From f72eb9cc88cb8829e40fec2621bf704afff088bd Mon Sep 17 00:00:00 2001 From: van-go <35277477+van-go@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:33:26 -0600 Subject: [PATCH 12/12] Refactor reloadPage function to simplify path handling in DataFilesFormModal --- .../DataFilesModals/DataFilesFormModal.jsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx index dcf830a75..f454eac98 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx @@ -15,22 +15,21 @@ const DataFilesFormModal = () => { const history = useHistory(); const location = useLocation(); - const reloadPage = (updatedPath = '') => { + const reloadPage = (updatedPath = '') => { let projectUrl = location.pathname.replace( /(\/projects\/[^/]+\/[^/]+\/?.*)/, '$1' ); - + if (projectUrl.endsWith('/')) { projectUrl = projectUrl.slice(0, -1); } - - // Avoid appending updatedPath if it's already part of projectUrl - const path = - updatedPath && !projectUrl.endsWith(updatedPath) - ? `${projectUrl}/${updatedPath}` - : projectUrl; - + + // Replace the last segment with the updatedPath + const path = updatedPath + ? projectUrl.replace(/[^/]+$/, updatedPath).replace(/\/\//g, '/') + : projectUrl; + history.replace(path); };