diff --git a/backend/app/main.py b/backend/app/main.py index dbfe83374..028d3ca3c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,6 +48,7 @@ public_visualization, status, thumbnails, + licenses, users, visualization, ) @@ -62,6 +63,8 @@ from motor.motor_asyncio import AsyncIOMotorClient from pydantic import BaseConfig +from app.models.licenses import LicenseDB + logger = logging.getLogger(__name__) app = FastAPI( @@ -229,6 +232,12 @@ tags=["thumbnails"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + licenses.router, + prefix="/licenses", + tags=["licenses"], + dependencies=[Depends(get_current_username)], +) api_router.include_router(status.router, prefix="/status", tags=["status"]) api_router.include_router(keycloak.router, prefix="/auth", tags=["auth"]) app.include_router(api_router, prefix=settings.API_V2_STR) @@ -273,6 +282,7 @@ async def startup_beanie(): VisualizationDataDB, ThumbnailDB, FolderFileViewList, + LicenseDB, ], recreate_views=True, ) diff --git a/backend/app/models/datasets.py b/backend/app/models/datasets.py index 18c858c5f..3e001ce91 100644 --- a/backend/app/models/datasets.py +++ b/backend/app/models/datasets.py @@ -47,6 +47,8 @@ class DatasetDB(Document, DatasetBase): user_views: int = 0 downloads: int = 0 thumbnail_id: Optional[PydanticObjectId] = None + standard_license: bool = True + license_id: Optional[str] = None class Settings: name = "datasets" diff --git a/backend/app/models/licenses.py b/backend/app/models/licenses.py new file mode 100644 index 000000000..5c85da818 --- /dev/null +++ b/backend/app/models/licenses.py @@ -0,0 +1,80 @@ +from datetime import datetime +from typing import List, Optional + +from beanie import Document, PydanticObjectId +from pydantic import BaseModel, Field + +from app.models.authorization import Provenance +from app.models.users import UserOut + + +class LicenseBase(BaseModel): + name: str + description: str = None + url: str = None + version: Optional[str] = None + holders: Optional[str] = None + expiration_date: Optional[datetime] = None + + +class LicenseIn(LicenseBase): + pass + + +class LicenseDB(Document, LicenseBase, Provenance): + class Settings: + name = "licenses" + + +class LicenseOut(LicenseDB): + class Config: + fields = {"id": "id"} + + +# Define a model for standard license options +class LicenseOption(BaseModel): + id: str + description: str = None + url: str = None + + +# Define standard license options +standard_licenses = [ + LicenseOption( + id="CC BY", + description="This license enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, so long as attribution is given to the creator. The license allows for commercial use. CC BY includes the following elements:\n BY: credit must be given to the creator.", + url="https://creativecommons.org/licenses/by/4.0/", + ), + LicenseOption( + id="CC BY-SA", + description="This license enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, so long as attribution is given to the creator. The license allows for commercial use. If you remix, adapt, or build upon the material, you must license the modified material under identical terms. CC BY-SA includes the following elements:\n BY: credit must be given to the creator. \nSA: Adaptations must be shared under the same terms.", + url="https://creativecommons.org/licenses/by-sa/4.0/", + ), + LicenseOption( + id="CC BY-NC", + description="This license enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for noncommercial purposes only, and only so long as attribution is given to the creator. CC BY-NC includes the following elements:\nBY: credit must be given to the creator.\nNC: Only noncommercial uses of the work are permitted.", + url="https://creativecommons.org/licenses/by-nc/4.0/", + ), + LicenseOption( + id="CC BY-NC-SA", + description="This license enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for noncommercial purposes only, and only so long as attribution is given to the creator. If you remix, adapt, or build upon the material, you must license the modified material under identical terms. CC BY-NC-SA includes the following elements:\nBY: credit must be given to the creator.\nNC: Only noncommercial uses of the work are permitted.\nSA: Adaptations must be shared under the same terms.", + url="https://creativecommons.org/licenses/by-nc-sa/4.0/", + ), + LicenseOption( + id="CC BY-ND", + description="This license enables reusers to copy and distribute the material in any medium or format in unadapted form only, and only so long as attribution is given to the creator. The license allows for commercial use. CC BY-ND includes the following elements:\nBY: credit must be given to the creator.\nND: No derivatives or adaptations of the work are permitted.", + url="https://creativecommons.org/licenses/by-nd/4.0/", + ), + LicenseOption( + id="CC BY-NC-ND", + description="This license enables reusers to copy and distribute the material in any medium or format in unadapted form only, for noncommercial purposes only, and only so long as attribution is given to the creator. CC BY-NC-ND includes the following elements:\nBY: credit must be given to the creator.\nNC: Only noncommercial uses of the work are permitted.\nND: No derivatives or adaptations of the work are permitted.", + url="https://creativecommons.org/licenses/by-nc-nd/4.0/", + ), + LicenseOption( + id="CCO Public Domain Dedication", + description="CC0 (aka CC Zero) is a public dedication tool, which enables creators to give up their copyright and put their works into the worldwide public domain. CC0 enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, with no conditions.", + url="https://creativecommons.org/publicdomain/zero/1.0/", + ), + LicenseOption(id="Custom", description="Create your own custom license", url="") + # Add more standard license options as needed +] diff --git a/backend/app/routers/datasets.py b/backend/app/routers/datasets.py index 2d92284e8..4b0c1af32 100644 --- a/backend/app/routers/datasets.py +++ b/backend/app/routers/datasets.py @@ -53,6 +53,53 @@ from rocrate.model.person import Person from rocrate.rocrate import ROCrate +from app import dependencies +from app.config import settings +from app.deps.authorization_deps import Authorization, CheckStatus +from app.keycloak_auth import ( + get_token, + get_user, + get_current_user, +) +from app.models.authorization import AuthorizationDB, RoleType +from app.models.datasets import ( + DatasetBase, + DatasetIn, + DatasetDB, + DatasetOut, + DatasetPatch, + DatasetDBViewList, + DatasetStatus, +) +from app.models.files import ( + FileOut, + FileDB, + FileDBViewList, + LocalFileIn, + StorageType, +) +from app.models.folder_and_file import FolderFileViewList +from app.models.folders import ( + FolderOut, + FolderIn, + FolderDB, + FolderDBViewList, + FolderPatch, +) +from app.models.metadata import MetadataDB +from app.models.pages import Paged, _get_page_query, _construct_page_metadata +from app.models.thumbnails import ThumbnailDB +from app.models.users import UserOut +from app.rabbitmq.listeners import submit_dataset_job +from app.routers.authentication import get_admin +from app.routers.authentication import get_admin_mode +from app.routers.files import add_file_entry, remove_file_entry, add_local_file_entry +from app.search.connect import ( + delete_document_by_id, +) +from app.search.index import index_dataset, index_file +from app.models.licenses import standard_licenses + router = APIRouter() security = HTTPBearer() @@ -179,10 +226,21 @@ async def _get_folder_hierarchy( @router.post("", response_model=DatasetOut) async def save_dataset( dataset_in: DatasetIn, + license_id: str, user=Depends(get_current_user), es: Elasticsearch = Depends(dependencies.get_elasticsearchclient), ): - dataset = DatasetDB(**dataset_in.dict(), creator=user) + standard_license = False + standard_license_ids = [license.id for license in standard_licenses] + if license_id in standard_license_ids: + standard_license = True + + dataset = DatasetDB( + **dataset_in.dict(), + creator=user, + license_id=str(license_id), + standard_license=standard_license, + ) await dataset.insert() # Create authorization entry @@ -392,9 +450,10 @@ async def delete_dataset( FolderDB.dataset_id == PydanticObjectId(dataset_id) ).delete() await AuthorizationDB.find( - AuthorizationDB.dataset_id == ObjectId(dataset_id) + AuthorizationDB.dataset_id == PydanticObjectId(dataset_id) ).delete() return {"deleted": dataset_id} + await delete_license(dataset.license_id) raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") @@ -431,7 +490,7 @@ async def get_dataset_folders( limit: int = 10, allow: bool = Depends(Authorization("viewer")), ): - if (await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if (dataset_db := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: if authenticated or public: query = [ FolderDBViewList.dataset_id == ObjectId(dataset_id), diff --git a/backend/app/routers/licenses.py b/backend/app/routers/licenses.py new file mode 100644 index 000000000..c4a85ad72 --- /dev/null +++ b/backend/app/routers/licenses.py @@ -0,0 +1,114 @@ +from datetime import datetime +from typing import List + +from beanie import PydanticObjectId +from fastapi import HTTPException, Depends, APIRouter + +from app.keycloak_auth import get_current_user, get_user + +from app.models.licenses import LicenseOut, LicenseIn, LicenseDB, LicenseBase +from app.routers.authentication import get_admin, get_admin_mode + +from app.models.licenses import LicenseOption + +from app.models.licenses import standard_licenses + +router = APIRouter() + + +@router.post("", response_model=LicenseOut) +async def save_license( + license_in: LicenseIn, + user=Depends(get_current_user), +): + if license_in.holders == None: + license_in = user + license_db = LicenseDB(**license_in.dict(), creator=user.email) + await license_db.insert() + return license_db.dict() + + +@router.get("/{license_id}", response_model=LicenseOut) +async def get_license(license_id: str): + if (license := await LicenseDB.get(PydanticObjectId(license_id))) is not None: + return license.dict() + raise HTTPException( + status_code=404, detail=f"License not found for id {license_id}" + ) + + +# Endpoint to retrieve standard license options +@router.get("/standard_licenses/all", response_model=List[LicenseOption]) +def get_standard_licenses(): + return standard_licenses + + +@router.get("/standard_licenses/{license_id}", response_model=str) +def get_standard_license_url(license_id: str): + for license in standard_licenses: + if license.id == license_id: + # Return the URL if the license ID is found + return license.url + + # If license ID is not found, raise HTTP 404 error + raise HTTPException(status_code=404, detail="Standard License ID not found") + + +@router.put("/{license_id}", response_model=LicenseOut) +async def edit_license( + license_id: str, + license_info: LicenseBase, + user_id=Depends(get_user), + admin=Depends(get_admin), + admin_mode: bool = Depends(get_admin_mode), +): + if (license := await LicenseDB.get(PydanticObjectId(license_id))) is not None: + if license.creator != user_id and not (admin and admin_mode): + raise HTTPException( + status_code=403, + detail=f"User {user_id} doesn't have permission to edit license {license_id}", + ) + license_dict = dict(license_info) if license_info is not None else {} + + if ( + len(license_dict["name"]) == 0 + or len(license_dict["description"]) == 0 + or len(license_dict["url"]) == 0 + or len(license_dict["holders"]) == 0 + ): + raise HTTPException( + status_code=400, + detail="License name/description/url/holders can't be null or empty", + ) + return + + license.modified = datetime.utcnow() + license.holders = license_dict["holders"] + license.description = license_dict["description"] + license.url = license_dict["url"] + license.version = license_dict["version"] + license.expiration_date = license_dict["expiration_date"] + license.name = license_dict["name"] + await license.replace() + + return license.dict() + raise HTTPException(status_code=404, detail=f"License {license_id} not found") + + +@router.delete("/{license_id}", response_model=LicenseOut) +async def delete_license( + license_id: str, + user_id=Depends(get_user), + admin=Depends(get_admin), + admin_mode: bool = Depends(get_admin_mode), +): + if (license := await LicenseDB.get(PydanticObjectId(license_id))) is not None: + if license.creator != user_id and not (admin and admin_mode): + raise HTTPException( + status_code=403, + detail=f"User {user_id} doesn't have permission to delete license {license_id}", + ) + await license.delete() + return license.dict() # TODO: Do we need to return what we just deleted? + else: + raise HTTPException(status_code=404, detail=f"License {license_id} not found") diff --git a/backend/app/tests/test_license.py b/backend/app/tests/test_license.py new file mode 100644 index 000000000..159065d04 --- /dev/null +++ b/backend/app/tests/test_license.py @@ -0,0 +1,52 @@ +from fastapi.testclient import TestClient + +from app.config import settings +from app.tests.utils import ( + create_dataset, + create_user, + user_example, +) + + +license_example = { + "name": "test license", + "description": "test description", + "url": "test url", + "holders": " test holders", +} + + +def test_license(client: TestClient, headers: dict): + # create + response = client.post( + f"{settings.API_V2_STR}/licenses/?user=test@test.org", + headers=headers, + json=license_example, + ) + assert response.status_code == 200 + license_id = response.json().get("id") + + # get + response = client.get( + f"{settings.API_V2_STR}/licenses/{license_id}", headers=headers + ) + assert response.status_code == 200 + + # edit + license_info = response.json() + license_info["version"] = "1.1" + license_info["description"] = "abc" + response = client.put( + f"{settings.API_V2_STR}/licenses/{license_id}", + headers=headers, + json=license_info, + ) + assert response.status_code == 200 + assert response.json()["version"] == "1.1" + assert response.json()["description"] == "abc" + + # delete + response = client.delete( + f"{settings.API_V2_STR}/licenses/{license_id}", headers=headers + ) + assert response.status_code == 200 diff --git a/backend/app/tests/utils.py b/backend/app/tests/utils.py index 589cfdff6..59b10ed8b 100644 --- a/backend/app/tests/utils.py +++ b/backend/app/tests/utils.py @@ -139,8 +139,11 @@ def create_group(client: TestClient, headers: dict): def create_dataset(client: TestClient, headers: dict): """Creates a test dataset and returns the JSON.""" + license_id = "CC BY" response = client.post( - f"{settings.API_V2_STR}/datasets", headers=headers, json=dataset_example + f"{settings.API_V2_STR}/datasets/?license_id={license_id}", + headers=headers, + json=dataset_example, ) assert response.status_code == 200 assert response.json().get("id") is not None diff --git a/frontend/src/actions/dataset.js b/frontend/src/actions/dataset.js index 28dd7bf3e..4ef298091 100644 --- a/frontend/src/actions/dataset.js +++ b/frontend/src/actions/dataset.js @@ -201,6 +201,40 @@ export function fetchDatasetAbout(id) { }; } +export const RECEIVE_DATASET_LICENSE = "RECEIVE_DATASET_LICENSE"; + +export function fetchDatasetLicense(license_id) { + return (dispatch) => { + return V2.LicensesService.getLicenseApiV2LicensesLicenseIdGet(license_id).then((json) => { + dispatch({ + type: RECEIVE_DATASET_LICENSE, + license: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchDatasetLicense(license_id))); + }); + }; +} + +export const UPDATE_DATASET_LICENSE = "UPDATE_DATASET_LICENSE"; + +export function updateDatasetLicense(licenseId, formData) { + return (dispatch) => { + return V2.LicensesService.editLicenseApiV2LicensesLicenseIdPut(licenseId, formData).then((json) => { + dispatch({ + type: UPDATE_DATASET_LICENSE, + license: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, updateDatasetLicense(licenseId, formData))); + }); + }; +} + export const RECEIVE_DATASETS = "RECEIVE_DATASETS"; export function fetchDatasets(skip = 0, limit = 21, mine = false) { @@ -222,22 +256,51 @@ export function fetchDatasets(skip = 0, limit = 21, mine = false) { export const CREATE_DATASET = "CREATE_DATASET"; -export function datasetCreated(formData) { +export function datasetCreated(formData, licenseId, licenseFormData) { return (dispatch) => { - return V2.DatasetsService.saveDatasetApiV2DatasetsPost(formData) - .then((dataset) => { - dispatch({ - type: CREATE_DATASET, - dataset: dataset, - receivedAt: Date.now(), + if (licenseFormData) { + // If licenseFormData is present, save the license first + return V2.LicensesService.saveLicenseApiV2LicensesPost(licenseFormData) + .then((license) => { + licenseId = license.id; + // After saving the license, save the dataset + return V2.DatasetsService.saveDatasetApiV2DatasetsPost(licenseId, formData); + }) + .then((dataset) => { + dispatch({ + type: CREATE_DATASET, + dataset: dataset, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, datasetCreated(formData, licenseId, licenseFormData))); }); - }) - .catch((reason) => { - dispatch(handleErrors(reason, datasetCreated(formData))); - }); + } else { + // If licenseFormData is not present, directly save the dataset + return V2.DatasetsService.saveDatasetApiV2DatasetsPost(licenseId, formData) + .then((dataset) => { + dispatch({ + type: CREATE_DATASET, + dataset: dataset, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, datasetCreated(formData, licenseId, licenseFormData))); + }); + } }; } +export function licenseCreated(formData) { + try { + return V2.LicensesService.saveLicenseApiV2LicensesPost(formData); + } catch (reason) { + (handleErrors(reason, licenseCreated(formData))); + } +} + export const RESET_CREATE_DATASET = "RESET_CREATE_DATASET"; export function resetDatsetCreated() { diff --git a/frontend/src/components/datasets/ChooseLicenseModal.tsx b/frontend/src/components/datasets/ChooseLicenseModal.tsx new file mode 100644 index 000000000..1bd986848 --- /dev/null +++ b/frontend/src/components/datasets/ChooseLicenseModal.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { + Box, + Button, Dialog, + DialogContent, + DialogTitle, + FormControl, + Link +} from "@mui/material"; +import { V2 } from "../../openapi"; +import { LicenseOption } from "../../openapi/v2"; +import {CreateLicenseModal} from "./CreateLicenseModal"; +import {fetchStandardLicenses} from "../../utils/licenses"; +import Typography from "@mui/material/Typography"; + +type ChooseLicenseModalProps = { + selectedLicense: any; + setSelectedLicense: any; + setLicenseRequestForm: any; + handleBack: any; + handleNext: any +}; + +export const ChooseLicenseModal: React.FC = ( + props: ChooseLicenseModalProps +) => { + const { selectedLicense, setSelectedLicense, setLicenseRequestForm, handleBack, handleNext } = props; + const [standardLicenses, setStandardLicenses] = useState([]); + const [licenseModalOpen, setLicenseModalOpen] = useState(false); + const fetchStandardLicensesData = async () => { + try { + const data = await fetchStandardLicenses(); // Call your function to fetch licenses + setStandardLicenses(data); // Update state with the fetched data + } catch (error) { + console.error('Error fetching licenses', error); + } + }; + + useEffect(() => { + fetchStandardLicensesData(); + }, []); + + useEffect(() => { + setSelectedLicense(); + }, []); + + const handleChange = (event: React.ChangeEvent) => { + setSelectedLicense(event.target.id); + if (event.target.id == "Custom") + setLicenseModalOpen(true); + else + setLicenseRequestForm(null); + }; + + const handleCloseModal = () => { + setLicenseModalOpen(false); + setSelectedLicense(null); // Reset selected license + }; + + // @ts-ignore + return ( + + {standardLicenses.map((license) => ( +
+
+ +
+
+
+ {license.url && + {`${license.id}`}/ + } + {!license.url && {license.id}} +
+
+ {license.description.split("\n").map((line, index) => ( + + {index > 0 ?
  • {line}
  • : line} +
    +
    + ))} +
    +
    +
    + ))} + + Create custom license + + + + + + + + +
    + ); +}; diff --git a/frontend/src/components/datasets/CreateDataset.tsx b/frontend/src/components/datasets/CreateDataset.tsx index ce66c69ae..f702739fb 100644 --- a/frontend/src/components/datasets/CreateDataset.tsx +++ b/frontend/src/components/datasets/CreateDataset.tsx @@ -19,10 +19,12 @@ import { postDatasetMetadata, } from "../../actions/metadata"; import { MetadataIn } from "../../openapi/v2"; -import { datasetCreated, resetDatsetCreated } from "../../actions/dataset"; +import {datasetCreated, licenseCreated, resetDatsetCreated} from "../../actions/dataset"; import { useNavigate } from "react-router-dom"; import Layout from "../Layout"; import { ErrorModal } from "../errors/ErrorModal"; +import {V2} from "../../openapi"; +import {ChooseLicenseModal} from "./ChooseLicenseModal"; export const CreateDataset = (): JSX.Element => { const dispatch = useDispatch(); @@ -36,8 +38,9 @@ export const CreateDataset = (): JSX.Element => { datasetId: string | undefined, metadata: MetadataIn ) => dispatch(postDatasetMetadata(datasetId, metadata)); - const createDataset = (formData: FormData) => - dispatch(datasetCreated(formData)); + const createDataset = (formData: FormData, licenseId: string| undefined, licenseFormData: FormData) => + dispatch(datasetCreated(formData, licenseId, licenseFormData)); + const newDataset = useSelector( (state: RootState) => state.dataset.newDataset ); @@ -52,8 +55,10 @@ export const CreateDataset = (): JSX.Element => { const [errorOpen, setErrorOpen] = useState(false); const [datasetRequestForm, setdatasetRequestForm] = useState({}); + const [licenseRequestForm, setLicenseRequestForm] = useState({}); const [metadataRequestForms, setMetadataRequestForms] = useState({}); const [allowSubmit, setAllowSubmit] = React.useState(false); + const [selectedLicense, setSelectedLicense] = useState(""); const history = useNavigate(); @@ -123,7 +128,7 @@ export const CreateDataset = (): JSX.Element => { // finish button post dataset; dataset ID triggers metadata posting const handleFinish = () => { // create dataset - createDataset(datasetRequestForm); + createDataset(datasetRequestForm, selectedLicense, licenseRequestForm); }; useEffect(() => { @@ -164,6 +169,18 @@ export const CreateDataset = (): JSX.Element => { + + Standard License Options + + + You can choose a license from the standard options or create your own + + + + + + + {/*step 2 Metadata*/} Required Metadata diff --git a/frontend/src/components/datasets/CreateLicenseModal.tsx b/frontend/src/components/datasets/CreateLicenseModal.tsx new file mode 100644 index 000000000..6f84f617e --- /dev/null +++ b/frontend/src/components/datasets/CreateLicenseModal.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import {Container, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box} from "@mui/material"; +import licenseSchema from "../../schema/licenseSchema.json"; +import {FormProps} from "@rjsf/core"; +import {ClowderRjsfErrorList} from "../styledComponents/ClowderRjsfErrorList"; +import Form from "@rjsf/material-ui"; +import {V2} from "../../openapi"; // Import LicenseOut type + + +type CreateLicenseModalProps = { + setLicenseModalOpen: any; + setLicenseRequestForm: any; + handleNext: any; +} + +export const CreateLicenseModal = (props: CreateLicenseModalProps)=> { + const { setLicenseModalOpen, setLicenseRequestForm, handleNext } = props; + + const addLicense = (formData: FormData) => { + setLicenseRequestForm(formData); + handleNext(); + }; + + + return ( + +
    ["schema"]} + onSubmit={({ formData }) => { + addLicense(formData); + // close modal + setLicenseModalOpen(false); + }} + ErrorList={ClowderRjsfErrorList} + > + + + + +
    +
    + ); +}; diff --git a/frontend/src/components/datasets/Dataset.tsx b/frontend/src/components/datasets/Dataset.tsx index 9c41052f7..4b2cbf832 100644 --- a/frontend/src/components/datasets/Dataset.tsx +++ b/frontend/src/components/datasets/Dataset.tsx @@ -9,19 +9,22 @@ import { Tab, Tabs, Typography, + Link, + IconButton, DialogTitle, DialogContent, Dialog } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; import { useParams, useSearchParams } from "react-router-dom"; import { RootState } from "../../types/data"; import { useDispatch, useSelector } from "react-redux"; import { - fetchDatasetAbout, + fetchDatasetAbout, fetchDatasetLicense, fetchFoldersFilesInDataset as fetchFoldersFilesInDatasetAction, } from "../../actions/dataset"; import { fetchFolderPath } from "../../actions/folder"; import { a11yProps, TabPanel } from "../tabs/TabComponent"; import FilesTable from "../files/FilesTable"; -import { MetadataIn } from "../../openapi/v2"; +import {LicenseOption, MetadataIn} from "../../openapi/v2"; import { DisplayMetadata } from "../metadata/DisplayMetadata"; import { DisplayListenerMetadata } from "../metadata/DisplayListenerMetadata"; import { EditMetadata } from "../metadata/EditMetadata"; @@ -44,11 +47,15 @@ import ShareIcon from "@mui/icons-material/Share"; import BuildIcon from "@mui/icons-material/Build"; import { ExtractionHistoryTab } from "../listeners/ExtractionHistoryTab"; import { SharingTab } from "../sharing/SharingTab"; +import RoleChip from "../auth/RoleChip"; import { TabStyle } from "../../styles/Styles"; import { ErrorModal } from "../errors/ErrorModal"; import { Visualization } from "../visualizations/Visualization"; import VisibilityIcon from "@mui/icons-material/Visibility"; import config from "../../app.config"; +import {EditLicenseModal} from "./EditLicenseModal"; +import {V2} from "../../openapi"; +import {fetchStandardLicenses, fetchStandardLicenseUrl} from "../../utils/licenses"; import { authCheck } from "../../utils/common"; export const Dataset = (): JSX.Element => { @@ -86,6 +93,9 @@ export const Dataset = (): JSX.Element => { ); const listDatasetAbout = (datasetId: string | undefined) => dispatch(fetchDatasetAbout(datasetId)); + const listDatasetLicense = (licenseId: string | undefined) => + dispatch(fetchDatasetLicense(licenseId)); + const listDatasetMetadata = (datasetId: string | undefined) => dispatch(fetchDatasetMetadata(datasetId)); const getMetadatDefinitions = ( @@ -114,6 +124,8 @@ export const Dataset = (): JSX.Element => { const [currPageNum, setCurrPageNum] = useState(1); + const [editLicenseOpen, setEditLicenseOpen] = useState(false); + const [limit] = useState(config.defaultFolderFilePerPage); const pageMetadata = useSelector( @@ -123,6 +135,17 @@ export const Dataset = (): JSX.Element => { (state: RootState) => state.dataset.foldersAndFiles.data ); const adminMode = useSelector((state: RootState) => state.user.adminMode); + const license = useSelector((state: RootState) => state.dataset.license); + const [standardLicenseUrl, setStandardLicenseUrl] = useState(""); + const fetchStandardLicenseUrlData = async (license_id: string) => { + try { + const data = await fetchStandardLicenseUrl(license_id); // Call your function to fetch licenses + setStandardLicenseUrl(data); // Update state with the fetched data + } catch (error) { + console.error('Error fetching license url', error); + } + }; + useEffect(() => { fetchFoldersFilesInDataset( @@ -132,9 +155,14 @@ export const Dataset = (): JSX.Element => { limit ); listDatasetAbout(datasetId); + if (about.standard_license && about.license_id) { + fetchStandardLicenseUrlData(about.license_id); + } + if (!about.standard_license && about.license_id) + listDatasetLicense(about.license_id); getFolderPath(folderId); getMetadatDefinitions(null, 0, 100); - }, [searchParams, adminMode]); + }, [searchParams, adminMode, about.license_id]); // for breadcrumb useEffect(() => { @@ -167,6 +195,16 @@ export const Dataset = (): JSX.Element => { setSelectedTabIndex(newTabIndex); }; + const [openEditLicenseModal, setOpenEditLicenseModal] = useState(false); + + const handleOpenEditLicenseModal = () => { + setOpenEditLicenseModal(true); // Open the modal + }; + + const handleCloseEditLicenseModal = (save: boolean) => { + setOpenEditLicenseModal(false); // Close the modal + }; + const handlePageChange = (_: ChangeEvent, value: number) => { const newSkip = (value - 1) * limit; setCurrPageNum(value); @@ -211,6 +249,8 @@ export const Dataset = (): JSX.Element => { setEnableAddMetadata(false); }; + // @ts-ignore + // @ts-ignore return ( {/*Error Message dialogue*/} @@ -357,55 +397,54 @@ export const Dataset = (): JSX.Element => { {enableAddMetadata && datasetRole.role !== undefined && datasetRole.role !== "viewer" ? ( - <> - - - + - - ) : ( + + + ) : ( <> + updateMetadata={updateDatasetMetadata} + deleteMetadata={deleteDatasetMetadata} + resourceType="dataset" + resourceId={datasetId} + publicView={false}/> - {datasetRole.role !== undefined && - datasetRole.role !== "viewer" ? ( - - ) : ( - <> - )} + {datasetRole.role !== undefined && + datasetRole.role !== "viewer" ? ( + + ) : ( + <> + )} - )} + )} { - + + + License + + {about.standard_license && about.license_id !== undefined ? ( + + + {about.license_id}/ + + + ) : ( + <> + )} + {!about.standard_license && license!== undefined && license.name !== undefined ? ( +
    + + {license.name} + { + setEditLicenseOpen(true); + }} + > + + + + { + setOpenEditLicenseModal(false); + }} + fullWidth={true} + maxWidth="md" + aria-labelledby="form-dialog" + > + Edit license + + + + +
    + ) : ( + <> + )}
    diff --git a/frontend/src/components/datasets/EditLicenseModal.tsx b/frontend/src/components/datasets/EditLicenseModal.tsx new file mode 100644 index 000000000..bd96b8c03 --- /dev/null +++ b/frontend/src/components/datasets/EditLicenseModal.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import {Container, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box} from '@mui/material'; +import licenseSchema from "../../schema/licenseSchema.json"; +import {FormProps} from "@rjsf/core"; +import {ClowderRjsfErrorList} from "../styledComponents/ClowderRjsfErrorList"; +import Form from "@rjsf/material-ui"; +import {useDispatch, useSelector} from "react-redux"; +import {ClowderRjsfTextWidget} from "../styledComponents/ClowderRjsfTextWidget"; +import {ClowderRjsfTextAreaWidget} from "../styledComponents/ClowderRjsfTextAreaWidget"; +import {ClowderRjsfSelectWidget} from "../styledComponents/ClowderRjsfSelectWidget"; +import {RootState} from "../../types/data"; +import {updateDatasetLicense} from "../../actions/dataset"; // Import LicenseOut type + + +type EditLicenseModalProps = { + setEditLicenseOpen: any; +} + +export const EditLicenseModal = (props: EditLicenseModalProps)=> { + const { setEditLicenseOpen } = props; + + const dispatch = useDispatch(); + + const editLicense = (formData: FormData) => + dispatch(updateDatasetLicense(license.id, formData)); + + const license = useSelector((state: RootState) => state.dataset.license); + + const onSave = async () => { + // Handle save operation here + }; + + + return ( + +
    ["schema"]} + //uiSchema={licenseSchema["uiSchema"] as FormProps["uiSchema"]} + formData={license} + onSubmit={({ formData }) => { + editLicense(formData); + // close modal + setEditLicenseOpen(false); + }} + ErrorList={ClowderRjsfErrorList} + > + + + + + +
    + ); +} diff --git a/frontend/src/openapi/v2/index.ts b/frontend/src/openapi/v2/index.ts index 1932a28e2..0aeaafe35 100644 --- a/frontend/src/openapi/v2/index.ts +++ b/frontend/src/openapi/v2/index.ts @@ -44,6 +44,10 @@ export type { GroupIn } from './models/GroupIn'; export type { GroupOut } from './models/GroupOut'; export type { HTTPValidationError } from './models/HTTPValidationError'; export type { LegacyEventListenerIn } from './models/LegacyEventListenerIn'; +export type { LicenseBase } from './models/LicenseBase'; +export type { LicenseIn } from './models/LicenseIn'; +export type { LicenseOption } from './models/LicenseOption'; +export type { LicenseOut } from './models/LicenseOut'; export type { LocalFileIn } from './models/LocalFileIn'; export type { Member } from './models/Member'; export type { MetadataAgent } from './models/MetadataAgent'; @@ -87,6 +91,7 @@ export { FilesService } from './services/FilesService'; export { FoldersService } from './services/FoldersService'; export { GroupsService } from './services/GroupsService'; export { JobsService } from './services/JobsService'; +export { LicensesService } from './services/LicensesService'; export { ListenersService } from './services/ListenersService'; export { LoginService } from './services/LoginService'; export { MetadataService } from './services/MetadataService'; diff --git a/frontend/src/openapi/v2/models/DatasetOut.ts b/frontend/src/openapi/v2/models/DatasetOut.ts index 30c691705..aca7f3b52 100644 --- a/frontend/src/openapi/v2/models/DatasetOut.ts +++ b/frontend/src/openapi/v2/models/DatasetOut.ts @@ -28,4 +28,6 @@ export type DatasetOut = { user_views?: number; downloads?: number; thumbnail_id?: string; + standard_license?: boolean; + license_id?: string; } diff --git a/frontend/src/openapi/v2/models/LicenseBase.ts b/frontend/src/openapi/v2/models/LicenseBase.ts new file mode 100644 index 000000000..5bcc95772 --- /dev/null +++ b/frontend/src/openapi/v2/models/LicenseBase.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type LicenseBase = { + name: string; + description?: string; + url?: string; + version?: string; + holders?: string; + expiration_date?: string; +} diff --git a/frontend/src/openapi/v2/models/LicenseIn.ts b/frontend/src/openapi/v2/models/LicenseIn.ts new file mode 100644 index 000000000..a7b814244 --- /dev/null +++ b/frontend/src/openapi/v2/models/LicenseIn.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type LicenseIn = { + name: string; + description?: string; + url?: string; + version?: string; + holders?: string; + expiration_date?: string; +} diff --git a/frontend/src/openapi/v2/models/LicenseOption.ts b/frontend/src/openapi/v2/models/LicenseOption.ts new file mode 100644 index 000000000..b632a2a9c --- /dev/null +++ b/frontend/src/openapi/v2/models/LicenseOption.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type LicenseOption = { + id: string; + description?: string; + url?: string; +} diff --git a/frontend/src/openapi/v2/models/LicenseOut.ts b/frontend/src/openapi/v2/models/LicenseOut.ts new file mode 100644 index 000000000..bc65f3bc4 --- /dev/null +++ b/frontend/src/openapi/v2/models/LicenseOut.ts @@ -0,0 +1,29 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Document Mapping class. + * + * Fields: + * + * - `id` - MongoDB document ObjectID "_id" field. + * Mapped to the PydanticObjectId class + * + * Inherited from: + * + * - Pydantic BaseModel + * - [UpdateMethods](https://roman-right.github.io/beanie/api/interfaces/#aggregatemethods) + */ +export type LicenseOut = { + creator: string; + created?: string; + modified?: string; + name: string; + description?: string; + url?: string; + version?: string; + holders?: string; + expiration_date?: string; + id?: string; +} diff --git a/frontend/src/openapi/v2/services/DatasetsService.ts b/frontend/src/openapi/v2/services/DatasetsService.ts index 5f14b189e..586b26b6d 100644 --- a/frontend/src/openapi/v2/services/DatasetsService.ts +++ b/frontend/src/openapi/v2/services/DatasetsService.ts @@ -51,16 +51,21 @@ export class DatasetsService { /** * Save Dataset + * @param licenseId * @param requestBody * @returns DatasetOut Successful Response * @throws ApiError */ public static saveDatasetApiV2DatasetsPost( + licenseId: string, requestBody: DatasetIn, ): CancelablePromise { return __request({ method: 'POST', path: `/api/v2/datasets`, + query: { + 'license_id': licenseId, + }, body: requestBody, mediaType: 'application/json', errors: { diff --git a/frontend/src/openapi/v2/services/LicensesService.ts b/frontend/src/openapi/v2/services/LicensesService.ts new file mode 100644 index 000000000..5c107d26d --- /dev/null +++ b/frontend/src/openapi/v2/services/LicensesService.ts @@ -0,0 +1,131 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { LicenseBase } from '../models/LicenseBase'; +import type { LicenseIn } from '../models/LicenseIn'; +import type { LicenseOption } from '../models/LicenseOption'; +import type { LicenseOut } from '../models/LicenseOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class LicensesService { + + /** + * Save License + * @param requestBody + * @returns LicenseOut Successful Response + * @throws ApiError + */ + public static saveLicenseApiV2LicensesPost( + requestBody: LicenseIn, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/licenses`, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get License + * @param licenseId + * @returns LicenseOut Successful Response + * @throws ApiError + */ + public static getLicenseApiV2LicensesLicenseIdGet( + licenseId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/licenses/${licenseId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Edit License + * @param licenseId + * @param requestBody + * @param datasetId + * @returns LicenseOut Successful Response + * @throws ApiError + */ + public static editLicenseApiV2LicensesLicenseIdPut( + licenseId: string, + requestBody: LicenseBase, + datasetId?: string, + ): CancelablePromise { + return __request({ + method: 'PUT', + path: `/api/v2/licenses/${licenseId}`, + query: { + 'dataset_id': datasetId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Delete License + * @param licenseId + * @param datasetId + * @returns LicenseOut Successful Response + * @throws ApiError + */ + public static deleteLicenseApiV2LicensesLicenseIdDelete( + licenseId: string, + datasetId?: string, + ): CancelablePromise { + return __request({ + method: 'DELETE', + path: `/api/v2/licenses/${licenseId}`, + query: { + 'dataset_id': datasetId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Standard Licenses + * @returns LicenseOption Successful Response + * @throws ApiError + */ + public static getStandardLicensesApiV2LicensesStandardLicensesAllGet(): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/licenses/standard_licenses/all`, + }); + } + + /** + * Get Standard License Url + * @param licenseId + * @returns string Successful Response + * @throws ApiError + */ + public static getStandardLicenseUrlApiV2LicensesStandardLicensesLicenseIdGet( + licenseId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/licenses/standard_licenses/${licenseId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/public/CC BY-NC-ND.png b/frontend/src/public/CC BY-NC-ND.png new file mode 100644 index 000000000..26a9301e2 Binary files /dev/null and b/frontend/src/public/CC BY-NC-ND.png differ diff --git a/frontend/src/public/CC BY-NC-SA.png b/frontend/src/public/CC BY-NC-SA.png new file mode 100644 index 000000000..ecc986a0b Binary files /dev/null and b/frontend/src/public/CC BY-NC-SA.png differ diff --git a/frontend/src/public/CC BY-NC.png b/frontend/src/public/CC BY-NC.png new file mode 100644 index 000000000..c87807483 Binary files /dev/null and b/frontend/src/public/CC BY-NC.png differ diff --git a/frontend/src/public/CC BY-ND.png b/frontend/src/public/CC BY-ND.png new file mode 100644 index 000000000..00fe697f5 Binary files /dev/null and b/frontend/src/public/CC BY-ND.png differ diff --git a/frontend/src/public/CC BY-SA.png b/frontend/src/public/CC BY-SA.png new file mode 100644 index 000000000..9a277277a Binary files /dev/null and b/frontend/src/public/CC BY-SA.png differ diff --git a/frontend/src/public/CC BY.png b/frontend/src/public/CC BY.png new file mode 100644 index 000000000..d07b4f14b Binary files /dev/null and b/frontend/src/public/CC BY.png differ diff --git a/frontend/src/public/CCO Public Domain Dedication.png b/frontend/src/public/CCO Public Domain Dedication.png new file mode 100644 index 000000000..e6f082481 Binary files /dev/null and b/frontend/src/public/CCO Public Domain Dedication.png differ diff --git a/frontend/src/reducers/dataset.ts b/frontend/src/reducers/dataset.ts index 8b344b5dd..9bf4abe2e 100644 --- a/frontend/src/reducers/dataset.ts +++ b/frontend/src/reducers/dataset.ts @@ -4,6 +4,8 @@ import { FOLDER_ADDED, FOLDER_UPDATED, RECEIVE_DATASET_ABOUT, + RECEIVE_DATASET_LICENSE, + UPDATE_DATASET_LICENSE, RECEIVE_DATASET_ROLES, RECEIVE_DATASETS, RECEIVE_FOLDERS_FILES_IN_DATASET, @@ -31,7 +33,7 @@ import { DatasetOut as Dataset, DatasetRoles, FileOut, - FolderOut, + FolderOut, LicenseOut, Paged, PageMetadata, UserOut, @@ -51,6 +53,7 @@ const defaultState: DatasetState = { newFiles: [], newFolder: {}, roles: {}, + license: {} }; const dataset = (state = defaultState, action: DataAction) => { @@ -99,6 +102,10 @@ const dataset = (state = defaultState, action: DataAction) => { }); case RECEIVE_DATASET_ABOUT: return Object.assign({}, state, { about: action.about }); + case RECEIVE_DATASET_LICENSE: + return Object.assign({}, state, { license: action.license }); + case UPDATE_DATASET_LICENSE: + return Object.assign({}, state, { license: action.license }); case RECEIVE_DATASET_ROLE: return Object.assign({}, state, { datasetRole: action.role }); case RECEIVE_DATASET_ROLES: @@ -141,7 +148,6 @@ const dataset = (state = defaultState, action: DataAction) => { }), }, }); - default: return state; } diff --git a/frontend/src/schema/licenseSchema.json b/frontend/src/schema/licenseSchema.json new file mode 100644 index 000000000..5cac9b4cf --- /dev/null +++ b/frontend/src/schema/licenseSchema.json @@ -0,0 +1,35 @@ +{ + "schema": { + "type": "object", + "required": [ + "name", "description", "url", "holders" + ], + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": ["string"], + "title": "Description" + }, + "url": { + "type": ["string"], + "title": "Url" + }, + "holders": { + "type": ["string"], + "title": "Holders" + }, + "version": { + "type": ["string", "null"], + "title": "Version" + }, + "expiration_date": { + "type": ["string", "null"], + "format": "date-time", + "title": "Expiration Date" + } + } + } +} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 6f51343ec..b825b9705 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -138,3 +138,30 @@ mark { .show-more-less-clickable { color: #f7941e; } + +.license-item { + max-width: 800px; + cursor: pointer; + margin-top: 0; + margin-bottom: 15px; + display: flex; +} +.radio-checkbox { + margin-right: .5em; + min-width: 17px; + align-content: center; +} +.header { + font-weight: 700; + font-size: 1.28571429em; +} +.description { + color: rgba(0,0,0,.6); + font-size: smaller; + line-height: 1.4285em; +} +.logo { + width: 100px; + height: auto; +} + diff --git a/frontend/src/types/action.ts b/frontend/src/types/action.ts index be9487084..9ee0537c5 100644 --- a/frontend/src/types/action.ts +++ b/frontend/src/types/action.ts @@ -9,7 +9,7 @@ import { FileOut as FileSummary, FileVersion, FolderOut, - GroupOut as Group, + GroupOut as Group, LicenseOut, MetadataDefinitionOut as MetadataDefinition, MetadataOut as Metadata, Paged, @@ -50,6 +50,16 @@ interface RECEIVE_DATASET_ABOUT { about: Dataset; } +interface RECEIVE_DATASET_LICENSE { + type: "RECEIVE_DATASET_LICENSE"; + license: LicenseOut; +} + +interface UPDATE_DATASET_LICENSE { + type: "UPDATE_DATASET_LICENSE"; + license: LicenseOut; +} + interface RECEIVE_DATASET_ROLE { role: AuthorizationBase; type: "RECEIVE_DATASET_ROLE"; @@ -728,4 +738,6 @@ export type DataAction = | RESET_PUBLIC_VIS_DATA_PRESIGNED_URL | RECEIVE_FOLDERS_FILES_IN_DATASET | RECEIVE_PUBLIC_FOLDERS_FILES_IN_DATASET - | FOLDER_UPDATED; + | FOLDER_UPDATED + | RECEIVE_DATASET_LICENSE + | UPDATE_DATASET_LICENSE; diff --git a/frontend/src/types/data.ts b/frontend/src/types/data.ts index ff6eaf11f..fbd50ef6f 100644 --- a/frontend/src/types/data.ts +++ b/frontend/src/types/data.ts @@ -6,7 +6,7 @@ import { FileOut, FileVersion, FolderOut, - GroupOut, + GroupOut, LicenseOut, MetadataDefinitionOut, MetadataOut as Metadata, Paged, @@ -128,6 +128,7 @@ export interface DatasetState { about: DatasetOut; datasetRole: AuthorizationBase; roles: DatasetRoles; + license: LicenseOut; } export interface PublicDatasetState { @@ -140,6 +141,7 @@ export interface PublicDatasetState { publicDatasetRole: AuthorizationBase; publicRoles: DatasetRoles; publicFoldersAndFiles: Paged; + license: LicenseOut; } export interface ListenerState { diff --git a/frontend/src/utils/licenses.js b/frontend/src/utils/licenses.js new file mode 100644 index 000000000..0fb93d91e --- /dev/null +++ b/frontend/src/utils/licenses.js @@ -0,0 +1,19 @@ +import {V2} from "../openapi"; + +export async function fetchStandardLicenses() { + try { + return await V2.LicensesService.getStandardLicensesApiV2LicensesStandardLicensesAllGet(); + } catch (reason) { + console.error("Failed to standard licenses option: ", reason); + return {}; + } +} + +export async function fetchStandardLicenseUrl(licenseId) { + try { + return await V2.LicensesService.getStandardLicenseUrlApiV2LicensesStandardLicensesLicenseIdGet(licenseId); + } catch (reason) { + console.error("Failed to fetch standard license url: ", reason); + return {}; + } +}