diff --git a/backend/app/config.py b/backend/app/config.py index 2f3041853..6bbea81ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,5 +88,9 @@ class Settings(BaseSettings): # defautl listener heartbeat time interval in seconds 5 minutes listener_heartbeat_interval = 5 * 60 + # DataCite details + DOI_ENABLED = True + DATACITE_API_URL = "https://api.test.datacite.org/" + settings = Settings() diff --git a/backend/app/models/datasets.py b/backend/app/models/datasets.py index bfecfb1af..5a3bc743a 100644 --- a/backend/app/models/datasets.py +++ b/backend/app/models/datasets.py @@ -44,6 +44,7 @@ class DatasetBaseCommon(DatasetBase): origin_id: Optional[PydanticObjectId] = None standard_license: bool = True license_id: Optional[str] = None + doi: Optional[str] = None class DatasetPatch(BaseModel): diff --git a/backend/app/routers/datasets.py b/backend/app/routers/datasets.py index d5f4b3de3..7569d3bd1 100644 --- a/backend/app/routers/datasets.py +++ b/backend/app/routers/datasets.py @@ -52,6 +52,7 @@ from app.models.users import UserOut from app.rabbitmq.listeners import submit_dataset_job from app.routers.authentication import get_admin, get_admin_mode +from app.routers.doi import DataCiteClient from app.routers.files import add_file_entry, add_local_file_entry from app.routers.licenses import delete_license from app.search.connect import delete_document_by_id @@ -482,9 +483,54 @@ async def delete_dataset( raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") +@router.post("/{dataset_id}/doi", response_model=DatasetOut) +async def mint_doi( + dataset_id: str, + user=Depends(get_current_user), + fs: Minio = Depends(dependencies.get_fs), + es: Elasticsearch = Depends(dependencies.get_elasticsearchclient), + allow: bool = Depends(Authorization(RoleType.OWNER)) and settings.DOI_ENABLED, +): + if (dataset := await DatasetFreezeDB.get(PydanticObjectId(dataset_id))) is not None: + metadata = { + "data": { + "type": "dois", + "event": "publish", + "attributes": { + "prefix": os.getenv("DATACITE_PREFIX"), + "url": f"{settings.frontend_url}/datasets/{dataset_id}", + "titles": [{"title": dataset.name}], + "creators": [ + { + "name": dataset.creator.first_name + + " " + + dataset.creator.last_name + } + ], + "publisher": "DataCite e.V.", + "publicationYear": datetime.datetime.now().year, + "types": {"resourceTypeGeneral": "Dataset"}, + }, + } + } + dataCiteClient = DataCiteClient() + response = dataCiteClient.create_doi(metadata) + print("doi created:", response.get("data").get("id")) + dataset.doi = response.get("data").get("id") + dataset.modified = datetime.datetime.utcnow() + await dataset.save() + + # TODO: if we ever index freeze datasets + # await index_dataset(es, DatasetOut(**dataset_db), update=True) + return dataset.dict() + else: + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + + @router.post("/{dataset_id}/freeze", response_model=DatasetFreezeOut) async def freeze_dataset( dataset_id: str, + publish_doi: bool = False, user=Depends(get_current_user), fs: Minio = Depends(dependencies.get_fs), es: Elasticsearch = Depends(dependencies.get_elasticsearchclient), @@ -545,6 +591,9 @@ async def freeze_dataset( # TODO thumbnails, visualizations + if publish_doi: + return await mint_doi(frozen_dataset.id) + return frozen_dataset.dict() raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") diff --git a/backend/app/routers/doi.py b/backend/app/routers/doi.py new file mode 100644 index 000000000..dc0c17dee --- /dev/null +++ b/backend/app/routers/doi.py @@ -0,0 +1,48 @@ +import os + +import requests +from app.config import settings +from requests.auth import HTTPBasicAuth + + +class DataCiteClient: + def __init__(self): + self.auth = HTTPBasicAuth( + os.getenv("DATACITE_USERNAME"), os.getenv("DATACITE_PASSWORD") + ) + self.headers = {"Content-Type": "application/vnd.api+json"} + self.base_url = settings.DATACITE_API_URL + + def create_doi(self, metadata): + url = f"{self.base_url}dois" + response = requests.post( + url, auth=self.auth, headers=self.headers, json=metadata + ) + return response.json() + + def get_all_dois(self): + url = f"{self.base_url}dois" + response = requests.get(url, auth=self.auth, headers=self.headers) + return response.json() + + def get_doi(self, doi): + url = f"{self.base_url}dois/{doi}" + response = requests.get(url, auth=self.auth, headers=self.headers) + return response.json() + + def update_doi(self, doi, metadata): + url = f"{self.base_url}dois/{doi}" + response = requests.put( + url, auth=self.auth, headers=self.headers, json=metadata + ) + return response.json() + + def delete_doi(self, doi): + url = f"{self.base_url}dois/{doi}" + response = requests.delete(url, auth=self.auth, headers=self.headers) + return response.status_code == 204 + + def get_doi_activity_status(self, doi): + url = f"{self.base_url}events?doi={doi}" + response = requests.get(url, auth=self.auth, headers=self.headers) + return response.json() diff --git a/docs/docs/devs/dataset-versioning.md b/docs/docs/devs/dataset-versioning.md index 5ad91381e..bf4fc5790 100644 --- a/docs/docs/devs/dataset-versioning.md +++ b/docs/docs/devs/dataset-versioning.md @@ -128,8 +128,33 @@ for client consumption. These views include: - providing users with greater control over dataset management - **Forbidden Modifications**: Prevent modifications to a released dataset. -## Future Enhancements +## Digital Object Identifier (DOI) Integration +Currently, the feature to generate DOI through [DataCite](https://datacite.org/) is integrated with Clowder. The user is provided this option +when they release a dataset. Clowder then talks with the DataCite API to mint a DOI for the released dataset and +submits some metadata about the dataset like its title, URL, and creator details. The generated DOI is displayed in the +dataset page in the Details section. + +### DOI Configuration Details +The following configuration changes need to be made to integrate DOI generation with Clowder using DataCite: + +In the backend module, the following configurations should be set: +```python +DOI_ENABLED = True # Enable or disable DOI generation +DATACITE_API_URL = "https://api.test.datacite.org/" # DataCite API URL (production URL is https://api.datacite.org/) +``` + +Also, following environment variables should be set when running the backend module: +```shell +DATACITE_USERNAME="" +DATACITE_PASSWORD="" +DATACITE_PREFIX="" +``` + +In the frontend module, the following configuration should be set: +```javascript +config["enableDOI"] = true; // Enable or disable DOI generation +``` -- **Mint DOI**: Integrate DOI support to allow minting Digital Object Identifiers (DOIs) for each dataset version, - ensuring unique and persistent - identification ([Issue #919](https://github.com/clowder-framework/clowder2/issues/919)). +## Future Enhancements +- **Add support for CrossRef when generate DOI**: Currently, Clowder supports DataCite for minting DOIs. We might need to + integrate CrossRef to provide users with more options, as some users may already have an account with CrossRef. diff --git a/frontend/src/actions/dataset.js b/frontend/src/actions/dataset.js index 53cb01d57..44d460a0a 100644 --- a/frontend/src/actions/dataset.js +++ b/frontend/src/actions/dataset.js @@ -186,10 +186,11 @@ export function updateDataset(datasetId, formData) { export const FREEZE_DATASET = "FREEZE_DATASET"; -export function freezeDataset(datasetId) { +export function freezeDataset(datasetId, publishDOI = false) { return (dispatch) => { return V2.DatasetsService.freezeDatasetApiV2DatasetsDatasetIdFreezePost( - datasetId + datasetId, + publishDOI ) .then((json) => { dispatch({ diff --git a/frontend/src/app.config.ts b/frontend/src/app.config.ts index 7c325852c..c8b6d1c18 100644 --- a/frontend/src/app.config.ts +++ b/frontend/src/app.config.ts @@ -33,6 +33,7 @@ interface Config { defaultExtractionJobs: number; defaultMetadataDefintionPerPage: number; defaultVersionPerPage: number; + enableDOI: boolean; } const config: Config = {}; @@ -101,4 +102,7 @@ config["defaultExtractionJobs"] = 5; config["defaultMetadataDefintionPerPage"] = 5; config["defaultVersionPerPage"] = 3; +// Use this boolean to enable/disable DOI minting feature +config["enableDOI"] = true; + export default config; diff --git a/frontend/src/components/datasets/Dataset.tsx b/frontend/src/components/datasets/Dataset.tsx index d193d0a02..fb5ed3cf5 100644 --- a/frontend/src/components/datasets/Dataset.tsx +++ b/frontend/src/components/datasets/Dataset.tsx @@ -604,6 +604,21 @@ export const Dataset = (): JSX.Element => { ) : ( <> )} +
+ {dataset.doi && dataset.doi !== undefined ? ( +
+ + DOI + + + + https://doi.org/{dataset.doi} + + +
+ ) : ( + <> + )} diff --git a/frontend/src/components/datasets/OtherMenu.tsx b/frontend/src/components/datasets/OtherMenu.tsx index f2f0c4c83..0d6a602e1 100644 --- a/frontend/src/components/datasets/OtherMenu.tsx +++ b/frontend/src/components/datasets/OtherMenu.tsx @@ -10,6 +10,7 @@ import { } from "@mui/material"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { ActionModal } from "../dialog/ActionModal"; +import { ActionModalWithCheckbox } from "../dialog/ActionModalWithCheckbox"; import { datasetDeleted, freezeDataset as freezeDatasetAction, @@ -27,6 +28,7 @@ import { AuthWrapper } from "../auth/AuthWrapper"; import { RootState } from "../../types/data"; import ShareIcon from "@mui/icons-material/Share"; import LocalOfferIcon from "@mui/icons-material/LocalOffer"; +import config from "../../app.config"; type ActionsMenuProps = { datasetId?: string; @@ -34,6 +36,7 @@ type ActionsMenuProps = { }; export const OtherMenu = (props: ActionsMenuProps): JSX.Element => { + let doiActionText; const { datasetId, datasetName } = props; // use history hook to redirect/navigate between routes @@ -46,8 +49,10 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => { const datasetRole = useSelector( (state: RootState) => state.dataset.datasetRole ); - const freezeDataset = (datasetId: string | undefined) => - dispatch(freezeDatasetAction(datasetId)); + const freezeDataset = ( + datasetId: string | undefined, + publishDOI: boolean | undefined + ) => dispatch(freezeDatasetAction(datasetId, publishDOI)); const listGroups = () => dispatch(fetchGroups(0, 21)); @@ -87,6 +92,14 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => { }; const [anchorEl, setAnchorEl] = React.useState(null); + const [publishDOI, setPublishDOI] = React.useState(false); + + doiActionText = + "By proceeding with the release, you will lock in the current content of the dataset, including all associated files, folders, metadata, and visualizations. Once released, these elements will be set as final and cannot be altered. However, you can continue to make edits and improvements on the ongoing version of the dataset."; + if (config.enableDOI) { + doiActionText += + " Optionally, you can also generate a Digital Object Identifier (DOI) by selecting the checkbox below. It will be displayed in the dataset page in the Details section."; + } const handleOptionClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -124,13 +137,17 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => { }} actionLevel={"error"} /> - { - freezeDataset(datasetId); + freezeDataset(datasetId, publishDOI); setFreezeDatasetConfirmOpen(false); }} handleActionCancel={() => { diff --git a/frontend/src/components/dialog/ActionModalWithCheckbox.tsx b/frontend/src/components/dialog/ActionModalWithCheckbox.tsx new file mode 100644 index 000000000..abfbd6c76 --- /dev/null +++ b/frontend/src/components/dialog/ActionModalWithCheckbox.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, +} from "@mui/material"; + +type ActionLevel = "error" | "warning" | "info"; + +type ActionModalProps = { + actionOpen: boolean; + actionTitle: string; + actionText: string; + displayCheckbox: boolean; + checkboxLabel: string; + checkboxSelected: boolean; + publishDOI: boolean; + setCheckboxSelected: (value: boolean) => void; + actionBtnName: string; + handleActionBtnClick: () => void; + handleActionCancel: () => void; + actionLevel?: ActionLevel; +}; + +export const ActionModalWithCheckbox: React.FC = ( + props: ActionModalProps +) => { + const { + actionOpen, + actionTitle, + actionText, + displayCheckbox, + checkboxLabel, + checkboxSelected, + setCheckboxSelected, + actionBtnName, + handleActionBtnClick, + handleActionCancel, + actionLevel, + } = props; + + return ( + + {actionTitle} + + {actionText} + + { + setCheckboxSelected(!checkboxSelected); + }} + /> + } + sx={{ display: displayCheckbox ? "block" : "none" }} + label={checkboxLabel} + /> + + + + + + + ); +}; diff --git a/frontend/src/openapi/v2/models/DatasetFreezeOut.ts b/frontend/src/openapi/v2/models/DatasetFreezeOut.ts index f98189e9b..fbdd5f998 100644 --- a/frontend/src/openapi/v2/models/DatasetFreezeOut.ts +++ b/frontend/src/openapi/v2/models/DatasetFreezeOut.ts @@ -30,6 +30,7 @@ export type DatasetFreezeOut = { origin_id?: string; standard_license?: boolean; license_id?: string; + doi?: string; id?: string; frozen?: boolean; frozen_version_num: number; diff --git a/frontend/src/openapi/v2/models/DatasetOut.ts b/frontend/src/openapi/v2/models/DatasetOut.ts index cfd47b50c..0107b72fb 100644 --- a/frontend/src/openapi/v2/models/DatasetOut.ts +++ b/frontend/src/openapi/v2/models/DatasetOut.ts @@ -30,6 +30,7 @@ export type DatasetOut = { origin_id?: string; standard_license?: boolean; license_id?: string; + doi?: string; id?: string; frozen?: boolean; frozen_version_num?: number; diff --git a/frontend/src/openapi/v2/services/DatasetsService.ts b/frontend/src/openapi/v2/services/DatasetsService.ts index 11db1018b..340f0e73b 100644 --- a/frontend/src/openapi/v2/services/DatasetsService.ts +++ b/frontend/src/openapi/v2/services/DatasetsService.ts @@ -240,6 +240,29 @@ export class DatasetsService { }); } + /** + * Mint Doi + * @param datasetId + * @param allow + * @returns DatasetOut Successful Response + * @throws ApiError + */ + public static mintDoiApiV2DatasetsDatasetIdDoiPost( + datasetId: string, + allow: boolean = true, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/datasets/${datasetId}/doi`, + query: { + 'allow': allow, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** * Get Freeze Datasets * @param datasetId @@ -272,18 +295,21 @@ export class DatasetsService { /** * Freeze Dataset * @param datasetId + * @param publishDoi * @param enableAdmin * @returns DatasetFreezeOut Successful Response * @throws ApiError */ public static freezeDatasetApiV2DatasetsDatasetIdFreezePost( datasetId: string, + publishDoi: boolean = false, enableAdmin: boolean = false, ): CancelablePromise { return __request({ method: 'POST', path: `/api/v2/datasets/${datasetId}/freeze`, query: { + 'publish_doi': publishDoi, 'enable_admin': enableAdmin, }, errors: {