Skip to content

919 Add DOI support #1160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions backend/app/models/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
49 changes: 49 additions & 0 deletions backend/app/routers/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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")
Expand Down
48 changes: 48 additions & 0 deletions backend/app/routers/doi.py
Original file line number Diff line number Diff line change
@@ -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()
33 changes: 29 additions & 4 deletions docs/docs/devs/dataset-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 repository username>"
DATACITE_PASSWORD="<DataCite repository password>"
DATACITE_PREFIX="<DataCite repository 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.
5 changes: 3 additions & 2 deletions frontend/src/actions/dataset.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface Config {
defaultExtractionJobs: number;
defaultMetadataDefintionPerPage: number;
defaultVersionPerPage: number;
enableDOI: boolean;
}

const config: Config = <Config>{};
Expand Down Expand Up @@ -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;
15 changes: 15 additions & 0 deletions frontend/src/components/datasets/Dataset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,21 @@ export const Dataset = (): JSX.Element => {
) : (
<></>
)}
<br />
{dataset.doi && dataset.doi !== undefined ? (
<div>
<Typography variant="h5" gutterBottom>
DOI
</Typography>
<Typography>
<Link href={`https://doi.org/${dataset.doi}`}>
https://doi.org/{dataset.doi}
</Link>
</Typography>
</div>
) : (
<></>
)}
</>
<DatasetDetails details={dataset} myRole={datasetRole.role} />
</Grid>
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/components/datasets/OtherMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,13 +28,15 @@ 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;
datasetName?: string;
};

export const OtherMenu = (props: ActionsMenuProps): JSX.Element => {
let doiActionText;
const { datasetId, datasetName } = props;

// use history hook to redirect/navigate between routes
Expand All @@ -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));

Expand Down Expand Up @@ -87,6 +92,14 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => {
};

const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
const [publishDOI, setPublishDOI] = React.useState<boolean>(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<any>) => {
setAnchorEl(event.currentTarget);
Expand Down Expand Up @@ -124,13 +137,17 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => {
}}
actionLevel={"error"}
/>
<ActionModal
<ActionModalWithCheckbox
actionOpen={freezeDatasetConfirmOpen}
actionTitle="Are you ready to release this version of the dataset?"
actionText="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."
actionText={doiActionText}
displayCheckbox={config.enableDOI}
checkboxLabel="Generate a DOI for this version of the dataset."
checkboxSelected={publishDOI}
setCheckboxSelected={setPublishDOI}
actionBtnName="Release"
handleActionBtnClick={() => {
freezeDataset(datasetId);
freezeDataset(datasetId, publishDOI);
setFreezeDatasetConfirmOpen(false);
}}
handleActionCancel={() => {
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/components/dialog/ActionModalWithCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -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<ActionModalProps> = (
props: ActionModalProps
) => {
const {
actionOpen,
actionTitle,
actionText,
displayCheckbox,
checkboxLabel,
checkboxSelected,
setCheckboxSelected,
actionBtnName,
handleActionBtnClick,
handleActionCancel,
actionLevel,
} = props;

return (
<Dialog open={actionOpen} onClose={handleActionCancel} maxWidth={"sm"}>
<DialogTitle id="confirmation-dialog-title">{actionTitle}</DialogTitle>
<DialogContent>
<DialogContentText>{actionText}</DialogContentText>
<br hidden={!displayCheckbox} />
<FormControlLabel
value={"end"}
control={
<Checkbox
defaultChecked={checkboxSelected}
onChange={() => {
setCheckboxSelected(!checkboxSelected);
}}
/>
}
sx={{ display: displayCheckbox ? "block" : "none" }}
label={checkboxLabel}
/>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={handleActionCancel}
color={actionLevel ?? "primary"}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handleActionBtnClick}
color={actionLevel ?? "primary"}
>
{actionBtnName}
</Button>
</DialogActions>
</Dialog>
);
};
1 change: 1 addition & 0 deletions frontend/src/openapi/v2/models/DatasetFreezeOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading