Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,10 @@ class Settings(BaseSettings):
# defautl listener heartbeat time interval in seconds 5 minutes
listener_heartbeat_interval = 5 * 60

# DOI datacite details
DOI_ENABLED = True
DATACITE_TEST_URL = "https://api.test.datacite.org/dois"
DATACITE_URL = "https://api.datacite.org/dois"


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
47 changes: 47 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,52 @@ 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",
"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,
},
}
}
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 +589,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
51 changes: 51 additions & 0 deletions backend/app/routers/doi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import os

import requests
from requests.auth import HTTPBasicAuth


class DataCiteClient:
def __init__(self, test_mode=False):
self.auth = HTTPBasicAuth(
os.getenv("DATACITE_USERNAME"), os.getenv("DATACITE_PASSWORD")
)
self.headers = {"Content-Type": "application/vnd.api+json"}
self.base_url = (
"https://api.test.datacite.org/"
if test_mode
else "https://api.datacite.org/"
)

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()
9 changes: 9 additions & 0 deletions backend/app/tests/test_datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def test_get_one(client: TestClient, headers: dict):
assert response.json().get("id") is not None


def test_create_doi(client: TestClient, headers: dict):
dataset_id = create_dataset(client, headers).get("id")
response = client.get(
f"{settings.API_V2_STR}/datasets/{dataset_id}", headers=headers
)
assert response.status_code == 200
assert response.json().get("id") is not None


def test_delete(client: TestClient, headers: dict):
dataset_id = create_dataset(client, headers).get("id")
response = client.delete(
Expand Down
12 changes: 8 additions & 4 deletions docs/docs/devs/dataset-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,12 @@ 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.

- **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
2 changes: 2 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 @@ -100,5 +101,6 @@ config["defaultFeeds"] = 5;
config["defaultExtractionJobs"] = 5;
config["defaultMetadataDefintionPerPage"] = 5;
config["defaultVersionPerPage"] = 3;
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
17 changes: 12 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 Down Expand Up @@ -46,8 +47,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 +90,7 @@ export const OtherMenu = (props: ActionsMenuProps): JSX.Element => {
};

const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
const [publishDOI, setPublishDOI] = React.useState<boolean>(false);

const handleOptionClick = (event: React.MouseEvent<any>) => {
setAnchorEl(event.currentTarget);
Expand Down Expand Up @@ -124,13 +128,16 @@ 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="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. 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."
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
82 changes: 82 additions & 0 deletions frontend/src/components/dialog/ActionModalWithCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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;
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,
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 />
<FormControlLabel
value={"end"}
control={
<Checkbox
defaultChecked={checkboxSelected}
onChange={() => {
setCheckboxSelected(!checkboxSelected);
}}
/>
}
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
1 change: 1 addition & 0 deletions frontend/src/openapi/v2/models/DatasetOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading