-
+ {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 (
-