Skip to content
Merged
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
10 changes: 10 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
public_visualization,
status,
thumbnails,
licenses,
users,
visualization,
)
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -273,6 +282,7 @@ async def startup_beanie():
VisualizationDataDB,
ThumbnailDB,
FolderFileViewList,
LicenseDB,
],
recreate_views=True,
)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions backend/app/models/licenses.py
Original file line number Diff line number Diff line change
@@ -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
]
65 changes: 62 additions & 3 deletions backend/app/routers/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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),
Expand Down
114 changes: 114 additions & 0 deletions backend/app/routers/licenses.py
Original file line number Diff line number Diff line change
@@ -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")
52 changes: 52 additions & 0 deletions backend/app/tests/test_license.py
Original file line number Diff line number Diff line change
@@ -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/[email protected]",
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
Loading