diff --git a/backend/app/deps/authorization_deps.py b/backend/app/deps/authorization_deps.py index ac5997637..fb5f13f70 100644 --- a/backend/app/deps/authorization_deps.py +++ b/backend/app/deps/authorization_deps.py @@ -5,14 +5,41 @@ from app.keycloak_auth import get_current_username from app.models.authorization import RoleType, AuthorizationDB from app.models.datasets import DatasetDB, DatasetStatus -from app.models.files import FileDB -from app.models.groups import GroupDB +from app.models.files import FileOut, FileDB, FileStatus +from app.models.groups import GroupOut, GroupDB from app.models.metadata import MetadataDB from app.models.pyobjectid import PyObjectId from app.routers.authentication import get_admin from app.routers.authentication import get_admin_mode +async def check_public_access( + resource_id: str, + resource_type: str, + role: RoleType, + current_user=Depends(get_current_username), +) -> bool: + has_public_access = False + if role == RoleType.VIEWER: + if resource_type == "dataset": + if ( + dataset := await DatasetDB.get(PydanticObjectId(resource_id)) + ) is not None: + if ( + dataset.status == DatasetStatus.PUBLIC.name + or dataset.status == DatasetStatus.AUTHENTICATED.name + ): + has_public_access = True + elif resource_type == "file": + if (file := await FileDB.get(PydanticObjectId(resource_id))) is not None: + if ( + file.status == FileStatus.PUBLIC.name + or file.status == FileStatus.AUTHENTICATED.name + ): + has_public_access = True + return has_public_access + + async def get_role( dataset_id: str, current_user=Depends(get_current_username), @@ -31,6 +58,11 @@ async def get_role( AuthorizationDB.user_ids == current_user, ), ) + public_access = await check_public_access( + dataset_id, "dataset", RoleType.VIEWER, current_user + ) + if authorization is None and public_access: + return RoleType.VIEWER return authorization.role @@ -55,15 +87,11 @@ async def get_role_by_file( if ( dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) ) is not None: - if dataset.status == DatasetStatus.AUTHENTICATED.name: - auth_dict = { - "creator": dataset.author.email, - "dataset_id": file.dataset_id, - "user_ids": [current_user], - "role": RoleType.VIEWER, - } - authenticated_auth = AuthorizationDB(**auth_dict) - return authenticated_auth + if ( + dataset.status == DatasetStatus.AUTHENTICATED.name + or dataset.status == DatasetStatus.PUBLIC.name + ): + return RoleType.VIEWER else: raise HTTPException( status_code=403, @@ -199,6 +227,7 @@ async def __call__( ) is not None: if ( current_dataset.status == DatasetStatus.AUTHENTICATED.name + or current_dataset.status == DatasetStatus.PUBLIC.name and self.role == "viewer" ): return True @@ -249,7 +278,15 @@ async def __call__( detail=f"User `{current_user} does not have `{self.role}` permission on file {file_id}", ) else: - raise HTTPException(status_code=404, detail=f"File {file_id} not found") + if ( + file.status == FileStatus.PUBLIC.name + or file.status == FileStatus.AUTHENTICATED.name + ) and self.role == RoleType.VIEWER: + return True + else: + raise HTTPException( + status_code=404, detail=f"File {file_id} not found" + ) class MetadataAuthorization: diff --git a/backend/app/main.py b/backend/app/main.py index daf5a41a8..89e5f17e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,22 +30,27 @@ from app.models.users import UserDB, UserAPIKeyDB, ListenerAPIKeyDB from app.models.visualization_config import VisualizationConfigDB from app.models.visualization_data import VisualizationDataDB -from app.routers import folders, groups, status +from app.routers import folders, groups, public_folders, status from app.routers import ( users, authorization, metadata, + public_metadata, files, + public_files, metadata_files, datasets, + public_datasets, metadata_datasets, authentication, keycloak, elasticsearch, + public_elasticsearch, listeners, feeds, jobs, visualization, + public_visualization, thumbnails, ) @@ -113,12 +118,22 @@ tags=["metadata"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + public_metadata.router, + prefix="/public_metadata", + tags=["public_metadata"], +) api_router.include_router( files.router, prefix="/files", tags=["files"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + public_files.router, + prefix="/public_files", + tags=["public_files"], +) api_router.include_router( metadata_files.router, prefix="/files", @@ -132,7 +147,15 @@ dependencies=[Depends(get_current_username)], ) api_router.include_router( - metadata_datasets.router, prefix="/datasets", tags=["metadata"] + public_datasets.router, + prefix="/public_datasets", + tags=["public_datasets"], +) +api_router.include_router( + metadata_datasets.router, + prefix="/datasets", + tags=["metadata"], + dependencies=[Depends(get_current_username)], ) api_router.include_router( folders.router, @@ -140,6 +163,11 @@ tags=["folders"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + public_folders.router, + prefix="/public_folders", + tags=["public_folders"], +) api_router.include_router( listeners.router, prefix="/listeners", @@ -164,6 +192,11 @@ tags=["elasticsearch"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + public_elasticsearch.router, + prefix="/public_elasticsearch", + tags=["public_elasticsearch"], +) api_router.include_router( feeds.router, prefix="/feeds", @@ -182,6 +215,11 @@ tags=["visualizations"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + public_visualization.router, + prefix="/public_visualizations", + tags=["public_visualizations"], +) api_router.include_router( thumbnails.router, prefix="/thumbnails", diff --git a/backend/app/models/datasets.py b/backend/app/models/datasets.py index b898dbb72..2a38e3e8f 100644 --- a/backend/app/models/datasets.py +++ b/backend/app/models/datasets.py @@ -27,6 +27,7 @@ class DatasetStatus(AutoName): class DatasetBase(BaseModel): name: str = "N/A" description: Optional[str] = None + status: str = DatasetStatus.PRIVATE.name class DatasetIn(DatasetBase): @@ -65,7 +66,7 @@ class DatasetDBViewList(View, DatasetBase): modified: datetime = Field(default_factory=datetime.utcnow) auth: List[AuthorizationDB] thumbnail_id: Optional[PydanticObjectId] = None - status: Optional[str] + status: str = DatasetStatus.PRIVATE.name class Settings: source = DatasetDB diff --git a/backend/app/models/files.py b/backend/app/models/files.py index 635ae8ab0..21de9a894 100644 --- a/backend/app/models/files.py +++ b/backend/app/models/files.py @@ -1,7 +1,7 @@ from datetime import datetime from enum import Enum from typing import Optional, List - +from enum import Enum, auto from beanie import Document, View, PydanticObjectId from pydantic import Field, BaseModel @@ -10,6 +10,19 @@ from app.models.users import UserOut +class AutoName(Enum): + def _generate_next_value_(name, start, count, last_values): + return name + + +class FileStatus(AutoName): + PRIVATE = auto() + PUBLIC = auto() + AUTHENTICATED = auto() + DEFAULT = auto() + TRIAL = auto() + + class StorageType(str, Enum): """Depending on the StorageType,the file may need different properties such as local path or URL. Also, some StorageTypes do not support versioning or anonymous sharing.""" @@ -47,6 +60,7 @@ class Settings: class FileBase(BaseModel): name: str = "N/A" + status: str = FileStatus.PRIVATE.name class FileIn(FileBase): diff --git a/backend/app/models/search.py b/backend/app/models/search.py index 8486db264..8a9b46070 100644 --- a/backend/app/models/search.py +++ b/backend/app/models/search.py @@ -46,3 +46,4 @@ class ElasticsearchEntry(BaseModel): bytes: Optional[int] # metadata fields metadata: Optional[List[dict]] = [] + status: Optional[str] diff --git a/backend/app/routers/authorization.py b/backend/app/routers/authorization.py index ef9ddf930..e8d8ce9b1 100644 --- a/backend/app/routers/authorization.py +++ b/backend/app/routers/authorization.py @@ -1,3 +1,5 @@ +import datetime + from beanie import PydanticObjectId from beanie.operators import Or, In from bson import ObjectId @@ -92,7 +94,10 @@ async def get_dataset_role( if ( current_dataset := await DatasetDB.get(PydanticObjectId(dataset_id)) ) is not None: - if current_dataset.status == DatasetStatus.AUTHENTICATED.name: + if ( + current_dataset.status == DatasetStatus.AUTHENTICATED.name + or current_dataset.status == DatasetStatus.PUBLIC.name + ): public_authorization_in = { "dataset_id": PydanticObjectId(dataset_id), "role": RoleType.VIEWER, diff --git a/backend/app/routers/datasets.py b/backend/app/routers/datasets.py index 5caa22537..b74a2766b 100644 --- a/backend/app/routers/datasets.py +++ b/backend/app/routers/datasets.py @@ -47,7 +47,14 @@ DatasetDBViewList, DatasetStatus, ) -from app.models.files import FileOut, FileDB, FileDBViewList, LocalFileIn, StorageType +from app.models.files import ( + FileOut, + FileDB, + FileDBViewList, + LocalFileIn, + StorageType, + FileStatus, +) from app.models.folders import FolderOut, FolderIn, FolderDB, FolderDBViewList from app.models.metadata import MetadataDB from app.models.pyobjectid import PyObjectId @@ -60,7 +67,7 @@ from app.search.connect import ( delete_document_by_id, ) -from app.search.index import index_dataset +from app.search.index import index_dataset, index_file router = APIRouter() security = HTTPBearer() @@ -233,6 +240,7 @@ async def get_datasets( Or( DatasetDBViewList.creator.email == user_id, DatasetDBViewList.auth.user_ids == user_id, + DatasetDBViewList.status == DatasetStatus.PUBLIC.name, DatasetDBViewList.status == DatasetStatus.AUTHENTICATED.name, ), sort=(-DatasetDBViewList.created), @@ -247,11 +255,15 @@ async def get_datasets( async def get_dataset( dataset_id: str, authenticated: bool = Depends(CheckStatus("AUTHENTICATED")), + public: bool = Depends(CheckStatus("PUBLIC")), allow: bool = Depends(Authorization("viewer")), ): - if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: - return dataset.dict() - raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + if authenticated or public or allow: + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + return dataset.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + else: + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") @router.get("/{dataset_id}/files", response_model=List[FileOut]) @@ -259,12 +271,13 @@ async def get_dataset_files( dataset_id: str, folder_id: Optional[str] = None, authenticated: bool = Depends(CheckStatus("AUTHENTICATED")), + public: bool = Depends(CheckStatus("PUBLIC")), user_id=Depends(get_user), skip: int = 0, limit: int = 10, allow: bool = Depends(Authorization("viewer")), ): - if authenticated: + if authenticated or public: query = [ FileDBViewList.dataset_id == ObjectId(dataset_id), ] @@ -321,6 +334,19 @@ async def patch_dataset( dataset.modified = datetime.datetime.utcnow() await dataset.save() + if dataset_info.status is not None: + query = [ + FileDBViewList.dataset_id == ObjectId(dataset_id), + ] + files_views = await FileDBViewList.find(*query).to_list() + for file_view in files_views: + if ( + file := await FileDB.get(PydanticObjectId(file_view.id)) + ) is not None: + file.status = dataset_info.status + await file.save() + await index_file(es, FileOut(**file.dict()), update=True) + # Update entry to the dataset index await index_dataset(es, DatasetOut(**dataset.dict()), update=True) return dataset.dict() @@ -383,12 +409,13 @@ async def get_dataset_folders( parent_folder: Optional[str] = None, user_id=Depends(get_user), authenticated: bool = Depends(CheckStatus("authenticated")), + public: bool = Depends(CheckStatus("PUBLIC")), skip: int = 0, limit: int = 10, allow: bool = Depends(Authorization("viewer")), ): if (await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: - if authenticated: + if authenticated or public: query = [ FolderDBViewList.dataset_id == ObjectId(dataset_id), ] @@ -469,7 +496,12 @@ async def save_file( status_code=401, detail=f"User not found. Session might have expired." ) - new_file = FileDB(name=file.filename, creator=user, dataset_id=dataset.id) + new_file = FileDB( + name=file.filename, + creator=user, + dataset_id=dataset.id, + status=dataset.status, + ) if folder_id is not None: if (folder := await FolderDB.get(PydanticObjectId(folder_id))) is not None: @@ -512,7 +544,12 @@ async def save_files( detail=f"User not found. Session might have expired.", ) - new_file = FileDB(name=file.filename, creator=user, dataset_id=dataset.id) + new_file = FileDB( + name=file.filename, + creator=user, + dataset_id=dataset.id, + status=dataset.status, + ) if folder_id is not None: if ( @@ -524,6 +561,12 @@ async def save_files( status_code=404, detail=f"Folder {folder_id} not found" ) + public = False + authenticated = False + if dataset.status == "PUBLIC": + public = True + if dataset.status == "AUTHENTICATED": + authenticated = True await add_file_entry( new_file, user, @@ -532,6 +575,8 @@ async def save_files( rabbitmq_client, file.file, content_type=file.content_type, + public=public, + authenticated=authenticated, ) files_added.append(new_file.dict()) return files_added diff --git a/backend/app/routers/elasticsearch.py b/backend/app/routers/elasticsearch.py index 6c463b8b0..fdf9f7def 100644 --- a/backend/app/routers/elasticsearch.py +++ b/backend/app/routers/elasticsearch.py @@ -25,6 +25,8 @@ def _add_permissions_clause( "should": [ {"term": {"creator": username}}, {"term": {"user_ids": username}}, + {"term": {"status": "AUTHENTICATED"}}, + {"term": {"status": "PUBLIC"}}, ] } } diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index f71b06377..079fa5dbc 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -93,6 +93,8 @@ async def add_file_entry( rabbitmq_client: BlockingChannel, file: Optional[io.BytesIO] = None, content_type: Optional[str] = None, + public: bool = False, + authenticated: bool = False, ): """Insert FileDB object into MongoDB (makes Clowder ID), then Minio (makes version ID), then update MongoDB with the version ID from Minio. @@ -472,9 +474,12 @@ async def get_file_versions( if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: mongo_versions = [] if file.storage_type == StorageType.MINIO: - async for ver in FileVersionDB.find( - FileVersionDB.file_id == ObjectId(file_id) - ).sort(-FileVersionDB.created).skip(skip).limit(limit): + async for ver in ( + FileVersionDB.find(FileVersionDB.file_id == ObjectId(file_id)) + .sort(-FileVersionDB.created) + .skip(skip) + .limit(limit) + ): mongo_versions.append(FileVersion(**ver.dict())) return mongo_versions diff --git a/backend/app/routers/public_datasets.py b/backend/app/routers/public_datasets.py new file mode 100644 index 000000000..487c3c298 --- /dev/null +++ b/backend/app/routers/public_datasets.py @@ -0,0 +1,353 @@ +import datetime +import hashlib +import io +import os +import shutil +import tempfile +import zipfile +from collections.abc import Mapping, Iterable +from typing import List, Optional + +from beanie import PydanticObjectId +from beanie.operators import Or +from beanie.odm.operators.update.general import Inc +from bson import ObjectId +from bson import json_util +from elasticsearch import Elasticsearch +from fastapi import Form +from fastapi import ( + APIRouter, + HTTPException, + Depends, + Security, + File, + UploadFile, + Request, +) +from app.models.metadata import ( + MongoDBRef, + MetadataAgent, + MetadataIn, + MetadataDB, + MetadataOut, + MetadataPatch, + validate_context, + patch_metadata, + MetadataDelete, + MetadataDefinitionDB, +) +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from minio import Minio +from pika.adapters.blocking_connection import BlockingChannel +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 +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 +from app.models.folders import FolderOut, FolderIn, FolderDB, FolderDBViewList +from app.models.metadata import MetadataDB +from app.models.pyobjectid import PyObjectId +from app.models.users import UserOut +from app.models.thumbnails import ThumbnailDB +from app.rabbitmq.listeners import submit_dataset_job +from app.routers.files import add_file_entry, remove_file_entry +from app.search.connect import ( + delete_document_by_id, +) +from app.search.index import index_dataset + +router = APIRouter() +security = HTTPBearer() + +clowder_bucket = os.getenv("MINIO_BUCKET_NAME", "clowder") + + +async def _get_folder_hierarchy( + folder_id: str, + hierarchy: str, +): + """Generate a string of nested path to folder for use in zip file creation.""" + folder = await FolderDB.get(PydanticObjectId(folder_id)) + hierarchy = folder.name + "/" + hierarchy + if folder.parent_folder is not None: + hierarchy = await _get_folder_hierarchy(folder.parent_folder, hierarchy) + return hierarchy + + +@router.get("", response_model=List[DatasetOut]) +async def get_datasets( + skip: int = 0, + limit: int = 10, +): + query = [DatasetDB.status == DatasetStatus.PUBLIC] + datasets = await DatasetDB.find(*query).skip(skip).limit(limit).to_list() + print(str(datasets)) + return [dataset.dict() for dataset in datasets] + + +@router.get("/{dataset_id}", response_model=DatasetOut) +async def get_dataset( + dataset_id: str, +): + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + return dataset.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + + +@router.get("/{dataset_id}/files", response_model=List[FileOut]) +async def get_dataset_files( + dataset_id: str, + folder_id: Optional[str] = None, + skip: int = 0, + limit: int = 10, +): + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + query = [ + FileDBViewList.dataset_id == ObjectId(dataset_id), + ] + if folder_id is not None: + query.append(FileDBViewList.folder_id == ObjectId(folder_id)) + files = await FileDBViewList.find(*query).skip(skip).limit(limit).to_list() + return [file.dict() for file in files] + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + + +@router.get("/{dataset_id}/folders", response_model=List[FolderOut]) +async def get_dataset_folders( + dataset_id: str, + parent_folder: Optional[str] = None, + skip: int = 0, + limit: int = 10, +): + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + query = [ + FolderDBViewList.dataset_id == ObjectId(dataset_id), + ] + if parent_folder is not None: + query.append(FolderDBViewList.parent_folder == ObjectId(parent_folder)) + else: + query.append(FolderDBViewList.parent_folder == None) + folders = ( + await FolderDBViewList.find(*query).skip(skip).limit(limit).to_list() + ) + return [folder.dict() for folder in folders] + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + + +@router.get("/{dataset_id}/metadata", response_model=List[MetadataOut]) +async def get_dataset_metadata( + dataset_id: str, + listener_name: Optional[str] = Form(None), + listener_version: Optional[float] = Form(None), +): + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + query = [MetadataDB.resource.resource_id == ObjectId(dataset_id)] + + if listener_name is not None: + query.append(MetadataDB.agent.listener.name == listener_name) + if listener_version is not None: + query.append(MetadataDB.agent.listener.version == listener_version) + + metadata = [] + async for md in MetadataDB.find(*query): + if md.definition is not None: + if ( + md_def := await MetadataDefinitionDB.find_one( + MetadataDefinitionDB.name == md.definition + ) + ) is not None: + md.description = md_def.description + metadata.append(md) + return [md.dict() for md in metadata] + else: + raise HTTPException( + status_code=404, detail=f"Dataset {dataset_id} not found" + ) + else: + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + + +@router.get("/{dataset_id}/download", response_model=DatasetOut) +async def download_dataset( + dataset_id: str, + fs: Minio = Depends(dependencies.get_fs), +): + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + current_temp_dir = tempfile.mkdtemp(prefix="rocratedownload") + crate = ROCrate() + + manifest_path = os.path.join(current_temp_dir, "manifest-md5.txt") + bagit_path = os.path.join(current_temp_dir, "bagit.txt") + bag_info_path = os.path.join(current_temp_dir, "bag-info.txt") + tagmanifest_path = os.path.join(current_temp_dir, "tagmanifest-md5.txt") + + with open(manifest_path, "w") as f: + pass # Create empty file so no errors later if the dataset is empty + + with open(bagit_path, "w") as f: + f.write("Bag-Software-Agent: clowder.ncsa.illinois.edu" + "\n") + f.write("Bagging-Date: " + str(datetime.datetime.now()) + "\n") + + with open(bag_info_path, "w") as f: + f.write("BagIt-Version: 0.97" + "\n") + f.write("Tag-File-Character-Encoding: UTF-8" + "\n") + + # Write dataset metadata if found + metadata = await MetadataDB.find( + MetadataDB.resource.resource_id == ObjectId(dataset_id) + ).to_list() + if len(metadata) > 0: + datasetmetadata_path = os.path.join( + current_temp_dir, "_dataset_metadata.json" + ) + metadata_content = json_util.dumps(metadata) + with open(datasetmetadata_path, "w") as f: + f.write(metadata_content) + crate.add_file( + datasetmetadata_path, + dest_path="metadata/_dataset_metadata.json", + properties={"name": "_dataset_metadata.json"}, + ) + + bag_size = 0 # bytes + file_count = 0 + + async for file in FileDB.find(FileDB.dataset_id == ObjectId(dataset_id)): + file_count += 1 + file_name = file.name + if file.folder_id is not None: + hierarchy = await _get_folder_hierarchy(file.folder_id, "") + dest_folder = os.path.join(current_temp_dir, hierarchy.lstrip("/")) + if not os.path.isdir(dest_folder): + os.mkdir(dest_folder) + file_name = hierarchy + file_name + current_file_path = os.path.join( + current_temp_dir, file_name.lstrip("/") + ) + + content = fs.get_object(settings.MINIO_BUCKET_NAME, str(file.id)) + file_md5_hash = hashlib.md5(content.data).hexdigest() + with open(current_file_path, "wb") as f1: + f1.write(content.data) + with open(manifest_path, "a") as mpf: + mpf.write(file_md5_hash + " " + file_name + "\n") + crate.add_file( + current_file_path, + dest_path="data/" + file_name, + properties={"name": file_name}, + ) + content.close() + content.release_conn() + + current_file_size = os.path.getsize(current_file_path) + bag_size += current_file_size + + metadata = await MetadataDB.find( + MetadataDB.resource.resource_id == ObjectId(dataset_id) + ).to_list() + if len(metadata) > 0: + metadata_filename = file_name + "_metadata.json" + metadata_filename_temp_path = os.path.join( + current_temp_dir, metadata_filename + ) + metadata_content = json_util.dumps(metadata) + with open(metadata_filename_temp_path, "w") as f: + f.write(metadata_content) + crate.add_file( + metadata_filename_temp_path, + dest_path="metadata/" + metadata_filename, + properties={"name": metadata_filename}, + ) + + bag_size_kb = bag_size / 1024 + + with open(bagit_path, "a") as f: + f.write("Bag-Size: " + str(bag_size_kb) + " kB" + "\n") + f.write("Payload-Oxum: " + str(bag_size) + "." + str(file_count) + "\n") + f.write("Internal-Sender-Identifier: " + dataset_id + "\n") + f.write("Internal-Sender-Description: " + dataset.description + "\n") + crate.add_file( + bagit_path, dest_path="bagit.txt", properties={"name": "bagit.txt"} + ) + crate.add_file( + manifest_path, + dest_path="manifest-md5.txt", + properties={"name": "manifest-md5.txt"}, + ) + crate.add_file( + bag_info_path, + dest_path="bag-info.txt", + properties={"name": "bag-info.txt"}, + ) + + # Generate tag manifest file + manifest_md5_hash = hashlib.md5( + open(manifest_path, "rb").read() + ).hexdigest() + bagit_md5_hash = hashlib.md5(open(bagit_path, "rb").read()).hexdigest() + bag_info_md5_hash = hashlib.md5( + open(bag_info_path, "rb").read() + ).hexdigest() + + with open(tagmanifest_path, "w") as f: + f.write(bagit_md5_hash + " " + "bagit.txt" + "\n") + f.write(manifest_md5_hash + " " + "manifest-md5.txt" + "\n") + f.write(bag_info_md5_hash + " " + "bag-info.txt" + "\n") + crate.add_file( + tagmanifest_path, + dest_path="tagmanifest-md5.txt", + properties={"name": "tagmanifest-md5.txt"}, + ) + + zip_name = dataset.name + ".zip" + path_to_zip = os.path.join(current_temp_dir, zip_name) + crate.write_zip(path_to_zip) + f = open(path_to_zip, "rb", buffering=0) + zip_bytes = f.read() + stream = io.BytesIO(zip_bytes) + f.close() + try: + shutil.rmtree(current_temp_dir) + except Exception as e: + print("could not delete file") + print(e) + + # Get content type & open file stream + response = StreamingResponse( + stream, + media_type="application/x-zip-compressed", + ) + response.headers["Content-Disposition"] = ( + "attachment; filename=%s" % zip_name + ) + # Increment download count + await dataset.update(Inc({DatasetDB.downloads: 1})) + return response + else: + raise HTTPException( + status_code=404, detail=f"Dataset {dataset_id} not found" + ) + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") diff --git a/backend/app/routers/public_elasticsearch.py b/backend/app/routers/public_elasticsearch.py new file mode 100644 index 000000000..a1c9a85f5 --- /dev/null +++ b/backend/app/routers/public_elasticsearch.py @@ -0,0 +1,42 @@ +import json +from fastapi.routing import APIRouter, Request + +from app.config import settings +from app.search.connect import connect_elasticsearch, search_index + +router = APIRouter() + + +def _add_public_clause(query): + """Append filter to Elasticsearch object that restricts permissions based on the requesting user.""" + # TODO: Add public filter once added + public_clause = {"bool": {"should": [{"term": {"public": True}}]}} + + updated_query = "" + for content in query.decode().split("\n"): + # Query can have multiple clauses separated by \n for things like aggregates, reactivesearch GUI queries + if len(content) == 0: + continue # last line + json_content = json.loads(content) + if "query" in json_content: + json_content["query"] = { + "bool": {"must": [public_clause, json_content["query"]]} + } + updated_query += json.dumps(json_content) + "\n" + return updated_query.encode() + + +@router.put("/search", response_model=str) +async def search(index_name: str, query: str): + es = await connect_elasticsearch() + query = _add_public_clause(query) + return search_index(es, index_name, query) + + +@router.post("/all/_msearch") +async def msearch(request: Request): + es = await connect_elasticsearch() + query = await request.body() + query = _add_public_clause(query) + r = search_index(es, [settings.elasticsearch_index], query) + return r diff --git a/backend/app/routers/public_files.py b/backend/app/routers/public_files.py new file mode 100644 index 000000000..281f107a5 --- /dev/null +++ b/backend/app/routers/public_files.py @@ -0,0 +1,279 @@ +import io +import time +from datetime import datetime, timedelta +from typing import Optional, List +from typing import Union +from fastapi import Form +from app.models.metadata import ( + MongoDBRef, + MetadataAgent, + MetadataIn, + MetadataDB, + MetadataOut, + MetadataPatch, + validate_context, + patch_metadata, + MetadataDelete, + MetadataDefinitionDB, + MetadataDefinitionOut, +) +from beanie import PydanticObjectId +from beanie.odm.operators.update.general import Inc +from bson import ObjectId +from elasticsearch import Elasticsearch, NotFoundError +from fastapi import ( + APIRouter, + HTTPException, + Depends, + Security, + File, + UploadFile, +) +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from minio import Minio +from pika.adapters.blocking_connection import BlockingChannel + +from app import dependencies +from app.config import settings +from app.deps.authorization_deps import FileAuthorization +from app.keycloak_auth import get_current_user, get_token +from app.models.files import ( + FileOut, + FileVersion, + FileDB, + FileVersionDB, +) +from app.models.datasets import ( + DatasetDB, + DatasetStatus, +) +from app.models.metadata import MetadataDB +from app.models.users import UserOut +from app.models.thumbnails import ThumbnailDB +from app.rabbitmq.listeners import submit_file_job, EventListenerJobDB +from app.routers.feeds import check_feed_listeners +from app.routers.utils import get_content_type +from app.search.connect import ( + delete_document_by_id, + insert_record, + update_record, +) +from app.search.index import index_file, index_thumbnail + +router = APIRouter() +security = HTTPBearer() + + +@router.get("/{file_id}/summary", response_model=FileOut) +async def get_file_summary( + file_id: str, +): + if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: + # TODO: Incrementing too often (3x per page view) + # file.views += 1 + # await file.replace() + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + return file.dict() + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + + +@router.get("/{file_id}/version_details", response_model=FileOut) +async def get_file_version_details( + file_id: str, + version_num: Optional[int] = 0, +): + if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: + # TODO: Incrementing too often (3x per page view) + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + file_vers = await FileVersionDB.find_one( + FileVersionDB.file_id == ObjectId(file_id), + FileVersionDB.version_num == version_num, + ) + file_vers_dict = file_vers.dict() + file_vers_details = file.copy() + file_vers_keys = list(file_vers.keys()) + for file_vers_key in file_vers_keys: + file_vers_details[file_vers_key] = file_vers_dict[file_vers_key] + return file_vers_details + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + + +@router.get("/{file_id}/versions", response_model=List[FileVersion]) +async def get_file_versions( + file_id: str, + skip: int = 0, + limit: int = 20, +): + file = await FileDB.get(PydanticObjectId(file_id)) + if file is not None: + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + mongo_versions = [] + async for ver in ( + FileVersionDB.find(FileVersionDB.file_id == ObjectId(file_id)) + .sort(-FileVersionDB.created) + .skip(skip) + .limit(limit) + ): + mongo_versions.append(FileVersion(**ver.dict())) + return mongo_versions + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + + +@router.get("/{file_id}") +async def download_file( + file_id: str, + version: Optional[int] = None, + increment: Optional[bool] = True, + fs: Minio = Depends(dependencies.get_fs), +): + # If file exists in MongoDB, download from Minio + if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + if version is not None: + # Version is specified, so get the minio ID from versions table if possible + file_vers = await FileVersionDB.find_one( + FileVersionDB.file_id == ObjectId(file_id), + FileVersionDB.version_num == version, + ) + if file_vers is not None: + vers = FileVersion(**file_vers.dict()) + content = fs.get_object( + settings.MINIO_BUCKET_NAME, + file_id, + version_id=vers.version_id, + ) + else: + raise HTTPException( + status_code=404, + detail=f"File {file_id} version {version} not found", + ) + else: + # If no version specified, get latest version directly + content = fs.get_object(settings.MINIO_BUCKET_NAME, file_id) + + # Get content type & open file stream + response = StreamingResponse( + content.stream(settings.MINIO_UPLOAD_CHUNK_SIZE) + ) + response.headers["Content-Disposition"] = ( + "attachment; filename=%s" % file.name + ) + if increment: + # Increment download count + await file.update(Inc({FileDB.downloads: 1})) + return response + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + + +@router.get("/{file_id}/thumbnail") +async def download_file_thumbnail( + file_id: str, + fs: Minio = Depends(dependencies.get_fs), +): + # If file exists in MongoDB, download from Minio + if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + if file.thumbnail_id is not None: + content = fs.get_object( + settings.MINIO_BUCKET_NAME, str(file.thumbnail_id) + ) + else: + raise HTTPException( + status_code=404, + detail=f"File {file_id} has no associated thumbnail", + ) + + # Get content type & open file stream + response = StreamingResponse( + content.stream(settings.MINIO_UPLOAD_CHUNK_SIZE) + ) + # TODO: How should filenames be handled for thumbnails? + response.headers["Content-Disposition"] = ( + "attachment; filename=%s" % "thumb" + ) + return response + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + + +@router.get("/{file_id}/metadata", response_model=List[MetadataOut]) +async def get_file_metadata( + file_id: str, + version: Optional[int] = None, + all_versions: Optional[bool] = False, + definition: Optional[str] = Form(None), + listener_name: Optional[str] = Form(None), + listener_version: Optional[float] = Form(None), +): + """Get file metadata.""" + if (file := await FileDB.get(PydanticObjectId(file_id))) is not None: + if ( + dataset := await DatasetDB.get(PydanticObjectId(file.dataset_id)) + ) is not None: + if dataset.status == DatasetStatus.PUBLIC.name: + query = [MetadataDB.resource.resource_id == ObjectId(file_id)] + + # Validate specified version, or use latest by default + if not all_versions: + if version is not None: + if ( + await FileVersionDB.find_one( + FileVersionDB.file_id == ObjectId(file_id), + FileVersionDB.version_num == version, + ) + ) is None: + raise HTTPException( + status_code=404, + detail=f"File version {version} does not exist", + ) + target_version = version + else: + target_version = file.version_num + query.append(MetadataDB.resource.version == target_version) + + if definition is not None: + # TODO: Check if definition exists in database and raise error if not + query.append(MetadataDB.definition == definition) + + if listener_name is not None: + query.append(MetadataDB.agent.extractor.name == listener_name) + if listener_version is not None: + query.append(MetadataDB.agent.extractor.version == listener_version) + + metadata = [] + async for md in MetadataDB.find(*query): + if md.definition is not None: + if ( + md_def := await MetadataDefinitionDB.find_one( + MetadataDefinitionDB.name == md.definition + ) + ) is not None: + md_def = MetadataDefinitionOut(**md_def.dict()) + md.description = md_def.description + metadata.append(md.dict()) + return metadata + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + else: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") diff --git a/backend/app/routers/public_folders.py b/backend/app/routers/public_folders.py new file mode 100644 index 000000000..aff2f0788 --- /dev/null +++ b/backend/app/routers/public_folders.py @@ -0,0 +1,35 @@ +from beanie import PydanticObjectId +from bson import ObjectId +from fastapi import ( + APIRouter, + HTTPException, +) + +from app.models.folders import FolderDB + +router = APIRouter() + + +@router.get("/{folder_id}/path") +async def download_folder( + folder_id: str, +): + folder = await FolderDB.get(PydanticObjectId(folder_id)) + if folder is not None: + path = [] + current_folder_id = folder_id + # TODO switch to $graphLookup + while ( + current_folder := await FolderDB.find_one( + FolderDB.id == ObjectId(current_folder_id) + ) + ) is not None: + folder_info = { + "folder_name": current_folder.name, + "folder_id": str(current_folder.id), + } + path.insert(0, folder_info) + current_folder_id = current_folder.parent_folder + return path + else: + raise HTTPException(status_code=404, detail=f"File {folder_id} not found") diff --git a/backend/app/routers/public_metadata.py b/backend/app/routers/public_metadata.py new file mode 100644 index 000000000..5559ca27e --- /dev/null +++ b/backend/app/routers/public_metadata.py @@ -0,0 +1,95 @@ +from typing import Optional, List + +from beanie import PydanticObjectId +from beanie.odm.operators.find.evaluation import RegEx +from beanie.odm.operators.find.logical import Or +from elasticsearch import Elasticsearch +from fastapi import ( + APIRouter, + HTTPException, + Depends, +) + +from app import dependencies +from app.deps.authorization_deps import MetadataAuthorization +from app.keycloak_auth import get_current_user +from app.models.metadata import ( + MetadataDefinitionIn, + MetadataDefinitionDB, + MetadataDefinitionOut, + MetadataOut, + MetadataPatch, + patch_metadata, + MetadataDB, +) +from app.models.pyobjectid import PyObjectId + +router = APIRouter() + + +@router.get("/definition", response_model=List[MetadataDefinitionOut]) +async def get_metadata_definition_list( + name: Optional[str] = None, + skip: int = 0, + limit: int = 2, +): + if name is None: + defs = await MetadataDefinitionDB.find( + sort=(-MetadataDefinitionDB.created), skip=skip, limit=limit + ).to_list() + else: + defs = await MetadataDefinitionDB.find( + MetadataDefinitionDB.name == name, + sort=(-MetadataDefinitionDB.created), + skip=skip, + limit=limit, + ).to_list() + return [mddef.dict() for mddef in defs] + + +@router.get( + "/definition/{metadata_definition_id}", response_model=MetadataDefinitionOut +) +async def get_metadata_definition( + metadata_definition_id: str, + user=Depends(get_current_user), +): + if ( + mdd := await MetadataDefinitionDB.get(PydanticObjectId(metadata_definition_id)) + ) is not None: + return mdd.dict() + raise HTTPException( + status_code=404, + detail=f"Metadata definition {metadata_definition_id} not found", + ) + + +@router.get( + "/definition/search/{search_term}", response_model=List[MetadataDefinitionOut] +) +async def search_metadata_definition( + search_term: str, + skip: int = 0, + limit: int = 10, + user=Depends(get_current_user), +): + """Search all metadata definition in the db based on text. + + Arguments: + text -- any text matching name or description + skip -- number of initial records to skip (i.e. for pagination) + limit -- restrict number of records to be returned (i.e. for pagination) + """ + + mdds = await MetadataDefinitionDB.find( + Or( + RegEx(field=MetadataDefinitionDB.name, pattern=search_term), + RegEx(field=MetadataDefinitionDB.description, pattern=search_term), + RegEx(field=MetadataDefinitionDB.context, pattern=search_term), + ), + sort=(-MetadataDefinitionDB.created), + skip=skip, + limit=limit, + ).to_list() + + return [mdd.dict() for mdd in mdds] diff --git a/backend/app/routers/public_visualization.py b/backend/app/routers/public_visualization.py new file mode 100644 index 000000000..db818c88a --- /dev/null +++ b/backend/app/routers/public_visualization.py @@ -0,0 +1,152 @@ +from datetime import timedelta +from typing import List, Optional + +from beanie import PydanticObjectId +from bson import ObjectId +from fastapi import APIRouter, HTTPException, Depends +from fastapi import File, UploadFile +from fastapi.security import HTTPBearer +from minio import Minio +from starlette.responses import StreamingResponse + +from app import dependencies +from app.config import settings +from app.keycloak_auth import get_current_user +from app.models.datasets import DatasetDB +from app.models.files import FileDB +from app.models.metadata import MongoDBRef +from app.models.visualization_config import ( + VisualizationConfigOut, + VisualizationConfigDB, + VisualizationConfigIn, +) +from app.models.visualization_data import ( + VisualizationDataOut, + VisualizationDataIn, + VisualizationDataDB, +) +from app.routers.utils import get_content_type + +router = APIRouter() +security = HTTPBearer() + + +@router.get("/{visualization_id}", response_model=VisualizationDataOut) +async def get_visualization(visualization_id: str): + if ( + visualization := await VisualizationDataDB.get( + PydanticObjectId(visualization_id) + ) + ) is not None: + return visualization.dict() + raise HTTPException( + status_code=404, detail=f"Visualization {visualization_id} not found" + ) + + +@router.get("/{visualization_id}/bytes") +async def download_visualization( + visualization_id: str, fs: Minio = Depends(dependencies.get_fs) +): + # If visualization exists in MongoDB, download from Minio + if ( + visualization := await VisualizationDataDB.get( + PydanticObjectId(visualization_id) + ) + ) is not None: + content = fs.get_object(settings.MINIO_BUCKET_NAME, visualization_id) + + # Get content type & open file stream + response = StreamingResponse(content.stream(settings.MINIO_UPLOAD_CHUNK_SIZE)) + response.headers["Content-Disposition"] = ( + "attachment; filename=%s" % visualization.name + ) + return response + else: + raise HTTPException( + status_code=404, detail=f"Visualization {visualization_id} not found" + ) + + +@router.get("/{visualization_id}/url/") +async def download_visualization_url( + visualization_id: str, + expires_in_seconds: Optional[int] = 3600, + external_fs: Minio = Depends(dependencies.get_external_fs), +): + # If visualization exists in MongoDB, download from Minio + if ( + visualization := await VisualizationDataDB.get( + PydanticObjectId(visualization_id) + ) + ) is not None: + if expires_in_seconds is None: + expires = timedelta(seconds=settings.MINIO_EXPIRES) + else: + expires = timedelta(seconds=expires_in_seconds) + + # Generate a signed URL with expiration time + presigned_url = external_fs.presigned_get_object( + bucket_name=settings.MINIO_BUCKET_NAME, + object_name=visualization_id, + expires=expires, + ) + + return {"presigned_url": presigned_url} + else: + raise HTTPException( + status_code=404, detail=f"Visualization {visualization_id} not found" + ) + + +@router.get("/{resource_id}/config", response_model=List[VisualizationConfigOut]) +async def get_resource_visconfig( + resource_id: PydanticObjectId, +): + query = [VisualizationConfigDB.resource.resource_id == ObjectId(resource_id)] + visconfigs = [] + async for vzconfig in VisualizationConfigDB.find(*query): + config_visdata = [] + visdata_query = [VisualizationDataDB.visualization_config_id == vzconfig.id] + async for vis_data in VisualizationDataDB.find(*visdata_query): + config_visdata.append(vis_data.dict()) + visconfig_out = VisualizationConfigOut(**vzconfig.dict()) + visconfig_out.visualization_data = config_visdata + if visconfig_out is not None: + visconfigs.append(visconfig_out) + return [vz.dict() for vz in visconfigs] + + +@router.get("/config/{config_id}", response_model=VisualizationConfigOut) +async def get_visconfig( + config_id: PydanticObjectId, +): + if ( + vis_config := await VisualizationConfigDB.get(PydanticObjectId(config_id)) + ) is not None: + config_visdata = [] + query = [VisualizationDataDB.visualization_config_id == config_id] + async for vis_data in VisualizationDataDB.find(*query): + config_visdata.append(vis_data.dict()) + # TODO + vis_config_out = VisualizationConfigOut(**vis_config.dict()) + vis_config_out.visualization_data = config_visdata + return vis_config_out + else: + raise HTTPException(status_code=404, detail=f"VisConfig {config_id} not found") + + +@router.get("/config/{config_id}/visdata", response_model=List[VisualizationDataOut]) +async def get_visdata_from_visconfig( + config_id: PydanticObjectId, +): + config_visdata = [] + if ( + vis_config := await VisualizationConfigDB.get(PydanticObjectId(config_id)) + ) is not None: + query = [VisualizationDataDB.visualization_config_id == config_id] + async for vis_data in VisualizationDataDB.find(*query): + config_visdata.append(vis_data) + return config_visdata + else: + raise HTTPException(status_code=404, detail=f"VisConfig {config_id} not found") diff --git a/backend/app/search/index.py b/backend/app/search/index.py index 7bfeaffcd..6965e7139 100644 --- a/backend/app/search/index.py +++ b/backend/app/search/index.py @@ -39,6 +39,7 @@ async def index_dataset( MetadataDB.resource.resource_id == ObjectId(dataset.id) ): metadata.append(md.content) + dataset_status = dataset.status # Add en entry to the dataset index doc = ElasticsearchEntry( resource_type="dataset", @@ -50,6 +51,7 @@ async def index_dataset( downloads=dataset.downloads, user_ids=authorized_user_ids, metadata=metadata, + status=dataset_status, ).dict() if update: @@ -66,6 +68,8 @@ async def index_file( file: FileOut, user_ids: Optional[List[str]] = None, update: bool = False, + public: bool = False, + authenticated: bool = False, ): """Create or update an Elasticsearch entry for the file. user_ids is the list of users with permission to at least view the file's dataset, it will be queried if not provided. @@ -86,6 +90,13 @@ async def index_file( MetadataDB.resource.resource_id == ObjectId(file.id) ): metadata.append(md.content) + + status = None + if authenticated: + status = "AUTHENTICATED" + if public: + status = "PUBLIC" + # Add en entry to the file index doc = ElasticsearchEntry( resource_type="file", @@ -100,6 +111,7 @@ async def index_file( folder_id=str(file.folder_id), bytes=file.bytes, metadata=metadata, + status=status, ).dict() if update: try: diff --git a/backend/app/tests/test_elastic_search.py b/backend/app/tests/test_elastic_search.py index 25b0b31b8..1e2ff57a3 100644 --- a/backend/app/tests/test_elastic_search.py +++ b/backend/app/tests/test_elastic_search.py @@ -55,6 +55,49 @@ } } +dummy_public_file_record = { + "name": "public test file", + "creator": "xyz", + "created": datetime.now(), + "download": 0, + "dataset_id": str(ObjectId("63458339aaecb776733354ea")), + "folder_id": None, + "bytes": 123456, + "content_type": "application/json", + "public": "true", + "status": "PUBLIC", +} +updated_dummy_public_file_record = { + "doc": { + "name": "public test file 2", + "creator": "xyz", + "created": datetime.now(), + "download": 0, + "public": "true", + "status": "PUBLIC", + } +} +dummy_public_dataset_record = { + "name": "public test dataset", + "description": "public dataset description", + "creator": "abcd", + "created": datetime.now(), + "modified": 0, + "download": 0, + "status": "PUBLIC", +} +updated_dummy_public_dataset_record = { + "doc": { + "name": "public test dataset 2", + "description": "public dataset description", + "creator": "abcd", + "created": datetime.now(), + "modified": 1, + "download": 0, + "status": "PUBLIC", + } +} + @pytest.mark.asyncio async def test_files(): @@ -134,3 +177,105 @@ async def test_datasets(): ) delete_document_by_id(es, settings.elasticsearch_index, 1) delete_index(es, settings.elasticsearch_index) + + +@pytest.mark.asyncio +async def test_public_files(): + # TODO: Replace this with actual file upload and search, not directly inserting record to ES + es = await connect_elasticsearch() + if es is not None: + create_index( + es, + settings.elasticsearch_index, + settings.elasticsearch_setting, + indexSettings.es_mappings, + ) + insert_record(es, settings.elasticsearch_index, dummy_public_file_record, 1) + time.sleep(1) + dummy_file_query = [] + + user_public_clause = { + "bool": { + "should": [ + {"term": {"creator": "xyz"}}, + {"term": {"status": "PUBLIC"}}, + ] + } + } + # header + dummy_file_query.append({"index": settings.elasticsearch_index}) + # body + # dummy_file_query.append({"query": {"match": {"creator": "xyz"}}}) + dummy_file_query.append({"query": user_public_clause}) + file_query = "" + for each in dummy_file_query: + file_query += "%s \n" % json.dumps(each) + + result = search_index(es, settings.elasticsearch_index, file_query) + assert ( + result.body["responses"][0]["hits"]["hits"][0]["_source"]["name"] + == "public test file" + ) + + # check for update to the record + update_record( + es, settings.elasticsearch_index, updated_dummy_public_file_record, 1 + ) + time.sleep(1) + result = search_index(es, settings.elasticsearch_index, file_query) + assert ( + result.body["responses"][0]["hits"]["hits"][0]["_source"]["name"] + == "public test file 2" + ) + query = {"match": {"name": "public test file 2"}} + delete_document_by_query(es, settings.elasticsearch_index, query) + delete_index(es, settings.elasticsearch_index) + + +@pytest.mark.asyncio +async def test_public_datasets(): + # TODO: Replace this with actual file upload and search, not directly inserting record to ES + es = await connect_elasticsearch() + if es is not None: + create_index( + es, + settings.elasticsearch_index, + settings.elasticsearch_setting, + indexSettings.es_mappings, + ) + insert_record(es, settings.elasticsearch_index, dummy_public_dataset_record, 1) + time.sleep(1) + dummy_dataset_query = [] + # header + dummy_dataset_query.append({"index": settings.elasticsearch_index}) + # body + query = { + "bool": { + "should": [ + {"term": {"creator": "abcd"}}, + {"term": {"status": "PUBLIC"}}, + ] + } + } + dummy_dataset_query.append({"query": query}) + dataset_query = "" + for each in dummy_dataset_query: + dataset_query += "%s \n" % json.dumps(each) + result = search_index(es, settings.elasticsearch_index, dataset_query) + assert ( + result.body["responses"][0]["hits"]["hits"][0]["_source"]["creator"] + == "abcd" + ) + + # check for update to the record + update_record( + es, settings.elasticsearch_index, updated_dummy_public_dataset_record, 1 + ) + time.sleep(1) + result = search_index(es, settings.elasticsearch_index, dataset_query) + assert ( + result.body["responses"][0]["hits"]["hits"][0]["_source"]["name"] + == "public test dataset 2" + ) + delete_document_by_id(es, settings.elasticsearch_index, 1) + delete_index(es, settings.elasticsearch_index) diff --git a/frontend/src/actions/folder.js b/frontend/src/actions/folder.js index 35ce92d21..e3f2b2924 100644 --- a/frontend/src/actions/folder.js +++ b/frontend/src/actions/folder.js @@ -53,6 +53,31 @@ export function fetchFolderPath(folderId) { }; } +export const GET_PUBLIC_FOLDER_PATH = "GET_PUBLIC_FOLDER_PATH"; +export function fetchPublicFolderPath(folderId){ + return (dispatch) => { + if (folderId != null) { + return V2.PublicFoldersService.downloadFolderApiV2PublicFoldersFolderIdPathGet(folderId) + .then(json => { + dispatch({ + type: GET_PUBLIC_FOLDER_PATH, + folderPath: json, + receivedAt: Date.now(), + }); + }) + .catch(reason => { + dispatch(handleErrors(reason, fetchPublicFolderPath(folderId))); + }); + } else { + dispatch({ + type: GET_FOLDER_PATH, + folderPath: [], + receivedAt: Date.now(), + }); + } + }; +} + export const FOLDER_DELETED = "FOLDER_DELETED"; export function folderDeleted(datasetId, folderId) { diff --git a/frontend/src/actions/metadata.js b/frontend/src/actions/metadata.js index 8314de07d..0ec47a10b 100644 --- a/frontend/src/actions/metadata.js +++ b/frontend/src/actions/metadata.js @@ -1,5 +1,29 @@ import { handleErrors } from "./common"; import { V2 } from "../openapi"; +export const RECEIVE_PUBLIC_METADATA_DEFINITIONS = "RECEIVE_PUBLIC_METADATA_DEFINITIONS"; + +export function fetchPublicMetadataDefinitions(name, skip, limit) { + return (dispatch) => { + return V2.PublicMetadataService.getMetadataDefinitionListApiV2PublicMetadataDefinitionGet( + name, + skip, + limit + ) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_METADATA_DEFINITIONS, + publicMetadataDefinitionList: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch( + handleErrors(reason, fetchPublicMetadataDefinitions(name, skip, limit)) + ); + }); + }; +} + export const RECEIVE_METADATA_DEFINITIONS = "RECEIVE_METADATA_DEFINITIONS"; @@ -143,6 +167,26 @@ export function fetchDatasetMetadata(datasetId) { }; } +export const RECEIVE_PUBLIC_DATASET_METADATA = "RECEIVE_PUBLIC_DATASET_METADATA"; + +export function fetchPublicDatasetMetadata(datasetId) { + return (dispatch) => { + return V2.PublicDatasetsService.getDatasetMetadataApiV2PublicDatasetsDatasetIdMetadataGet( + datasetId + ) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_DATASET_METADATA, + publicDatasetMetadataList: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicDatasetMetadata(datasetId))); + }); + }; +} + export const RECEIVE_FILE_METADATA = "RECEIVE_FILE_METADATA"; export function fetchFileMetadata(fileId, version) { @@ -165,6 +209,28 @@ export function fetchFileMetadata(fileId, version) { }; } +export const RECEIVE_PUBLIC_FILE_METADATA = "RECEIVE_PUBLIC_FILE_METADATA"; + +export function fetchPublicFileMetadata(fileId, version) { + return (dispatch) => { + return V2.PublicFilesService.getFileMetadataApiV2PublicFilesFileIdMetadataGet( + fileId, + version, + false + ) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_FILE_METADATA, + publicFileMetadataList: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFileMetadata(fileId, version))); + }); + }; +} + export const POST_DATASET_METADATA = "POST_DATASET_METADATA"; export function postDatasetMetadata(datasetId, metadata) { diff --git a/frontend/src/actions/public_dataset.js b/frontend/src/actions/public_dataset.js new file mode 100644 index 000000000..02224c564 --- /dev/null +++ b/frontend/src/actions/public_dataset.js @@ -0,0 +1,143 @@ +import { V2 } from "../openapi"; +import { + handleErrors, + handleErrorsAuthorization, + handleErrorsInline, + resetFailedReason, +} from "./common"; + +export const RECEIVE_PUBLIC_DATASET_METADATA = "RECEIVE_PUBLIC_DATASET_METADATA"; + +export function fetchPublicDatasetMetadata(datasetId, version) { + return (dispatch) => { + return V2.PublicDatasetsService.getDatasetMetadataApiV2PublicDatasetsDatasetIdMetadataGet( + datasetId, + version, + false + ) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_DATASET_METADATA, + publicDatasetMetadataList: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicDatasetMetadata(datasetId, version))); + }); + }; +} +export const RECEIVE_FILES_IN_PUBLIC_DATASET = "RECEIVE_FILES_IN_PUBLIC_DATASET"; + +export function fetchFilesInPublicDataset(datasetId, folderId, skip, limit) { + return (dispatch) => { + return V2.PublicDatasetsService.getDatasetFilesApiV2PublicDatasetsDatasetIdFilesGet( + datasetId, + folderId, + skip, + limit + ) + .then((json) => { + dispatch({ + type: RECEIVE_FILES_IN_PUBLIC_DATASET, + public_files: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch( + handleErrors(reason, fetchFilesInPublicDataset(datasetId, folderId, skip, limit)) + ); + }); + }; +} + +export const RECEIVE_FOLDERS_IN_PUBLIC_DATASET = "RECEIVE_FOLDERS_IN_PUBLIC_DATASET"; + +export function fetchFoldersInPublicDataset(datasetId, parentFolder, skip, limit) { + return (dispatch) => { + return V2.PublicDatasetsService.getDatasetFoldersApiV2PublicDatasetsDatasetIdFoldersGet( + datasetId, + parentFolder, + skip, + limit + ) + .then((json) => { + dispatch({ + type: RECEIVE_FOLDERS_IN_PUBLIC_DATASET, + publicFolders: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch( + handleErrors(reason, fetchFoldersInPublicDataset(datasetId, parentFolder, skip, limit)) + ); + }); + }; +} + +export const RECEIVE_PUBLIC_DATASET_ABOUT = "RECEIVE_PUBLIC_DATASET_ABOUT"; + +export function fetchPublicDatasetAbout(id) { + return (dispatch) => { + return V2.PublicDatasetsService.getDatasetApiV2PublicDatasetsDatasetIdGet(id) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_DATASET_ABOUT, + public_about: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicDatasetAbout(id))); + }); + }; +} + +export const RECEIVE_PUBLIC_DATASETS = "RECEIVE_PUBLIC_DATASETS"; + +export function fetchPublicDatasets(skip = 0, limit = 21) { + return (dispatch) => { + // TODO: Parameters for dates? paging? + return V2.PublicDatasetsService.getDatasetsApiV2PublicDatasetsGet(skip, limit) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_DATASETS, + public_datasets: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicDatasets(skip, limit))); + }); + }; +} + +export const GET_PUBLIC_FOLDER_PATH = "GET_PUBLIC_FOLDER_PATH"; + +export function fetchPublicFolderPath(folderId) { + return (dispatch) => { + if (folderId != null) { + return V2.PublicFoldersService.downloadFolderApiV2PublicFoldersFolderIdPathGet( + folderId + ) + .then((json) => { + dispatch({ + type: GET_PUBLIC_FOLDER_PATH, + publicFolderPath: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFolderPath(folderId))); + }); + } else { + dispatch({ + type: GET_PUBLIC_FOLDER_PATH, + publicFolderPath: [], + receivedAt: Date.now(), + }); + } + }; +} diff --git a/frontend/src/actions/public_file.js b/frontend/src/actions/public_file.js new file mode 100644 index 000000000..df3de853b --- /dev/null +++ b/frontend/src/actions/public_file.js @@ -0,0 +1,159 @@ +import config from "../app.config"; +import { V2 } from "../openapi"; +import { handleErrors } from "./common"; + +export const RECEIVE_PUBLIC_FILE_METADATA = "RECEIVE_PUBLIC_FILE_METADATA"; + +export function fetchPublicFileMetadata(fileId, version) { + return (dispatch) => { + return V2.PublicFilesService.getFileMetadataApiV2PublicFilesFileIdMetadataGet( + fileId, + version, + false + ) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_FILE_METADATA, + publicFileMetadataList: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFileMetadata(fileId, version))); + }); + }; +} + +export const RECEIVE_PUBLIC_FILE_SUMMARY = "RECEIVE_PUBLIC_FILE_SUMMARY"; + +export function fetchPublicFileSummary(id) { + return (dispatch) => { + return V2.PublicFilesService.getFileSummaryApiV2PublicFilesFileIdSummaryGet(id) + .then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_FILE_SUMMARY, + publicFileSummary: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFileSummary(id))); + }); + }; +} + + +export const RECEIVE_PUBLIC_PREVIEWS = "RECEIVE_PUBLIC_PREVIEWS"; + +export function fetchPublicFilePreviews(id) { + const url = `${config.hostname}/public_files/${id}/getPreviews`; + return (dispatch) => { + return fetch(url, { mode: "cors"}) + .then((response) => { + if (response.status === 200) { + response.json().then((json) => { + dispatch({ + type: RECEIVE_PUBLIC_PREVIEWS, + publicPreviews: json, + receivedAt: Date.now(), + }); + }); + } else { + dispatch(handleErrors(response, fetchPublicFilePreviews(id))); + } + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFilePreviews(id))); + }); + }; +} + + + + + + +// TODO this method will change the selected file version, should get that version first to make sure it exists +export const CHANGE_PUBLIC_SELECTED_VERSION = "CHANGE_PUBLIC_SELECTED_VERSION"; + +export function changePublicSelectedVersion(fileId, selectedVersion) { + return (dispatch) => { + dispatch({ + type: CHANGE_PUBLIC_SELECTED_VERSION, + publicVersion: selectedVersion, + receivedAt: Date.now(), + }); + }; +} + +export const RECEIVE_PUBLIC_VERSIONS = "RECEIVE_PUBLIC_VERSIONS"; + +export function fetchPublicFileVersions(fileId, skip, limit) { + return (dispatch) => { + return V2.PublicFilesService.getFileVersionsApiV2PublicFilesFileIdVersionsGet(fileId, skip, limit) + .then((json) => { + // sort by decending order + const version = json.sort( + (a, b) => new Date(b["created"]) - new Date(a["created"]) + ); + dispatch({ + type: RECEIVE_PUBLIC_VERSIONS, + publicFileVersions: version, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchPublicFileVersions(fileId))); + }); + }; +} + +export const DOWNLOAD_PUBLIC_FILE = "DOWNLOAD_PUBLIC_FILE"; + +export function filePublicDownloaded( + fileId, + filename = "", + fileVersionNum = 0, + autoSave = true +) { + return async (dispatch) => { + if (filename === "") { + filename = `${fileId}.zip`; + } + let endpoint = `${config.hostname}/api/v2/public/files/${fileId}`; + if (fileVersionNum != 0) endpoint = `${endpoint}?version=${fileVersionNum}`; + const response = await fetch(endpoint, { + method: "GET", + mode: "cors", + }); + + if (response.status === 200) { + const blob = await response.blob(); + if (autoSave) { + if (window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveBlob(blob, filename); + } else { + const anchor = window.document.createElement("a"); + anchor.href = window.URL.createObjectURL(blob); + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } + } + + dispatch({ + type: DOWNLOAD_PUBLIC_FILE, + publicBlob: blob, + receivedAt: Date.now(), + }); + } else { + dispatch( + handleErrors( + response, + filePublicDownloaded(fileId, filename, fileVersionNum, autoSave) + ) + ); + } + }; +} diff --git a/frontend/src/actions/public_visualization.js b/frontend/src/actions/public_visualization.js new file mode 100644 index 000000000..2dc3bf55b --- /dev/null +++ b/frontend/src/actions/public_visualization.js @@ -0,0 +1,123 @@ +import { V2 } from "../openapi"; +import { handleErrors } from "./common"; +import config from "../app.config"; +import { getHeader } from "../utils/common"; + +export const GET_PUBLIC_VIS_CONFIG = "GET_PUBLIC_VIS_CONFIG"; + +export function getPublicVisConfig(resourceId) { + return (dispatch) => { + return V2.PublicVisualizationsService.getResourceVisconfigApiV2PublicVisualizationsResourceIdConfigGet( + resourceId + ) + .then((json) => { + dispatch({ + type: GET_PUBLIC_VIS_CONFIG, + receivedAt: Date.now(), + publicVisConfig: json, + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, getPublicVisConfig(resourceId))); + }); + }; +} + +export const GET_PUBLIC_VIS_DATA = "GET_PUBLIC_VIS_DATA"; + +export function getPublicVisData(visualizationId) { + return (dispatch) => { + return V2.PublicVisualizationsService.getVisualizationApiV2VisualizationsVisualizationIdGet( + visualizationId + ) + .then((json) => { + dispatch({ + type: GET_PUBLIC_VIS_DATA, + receivedAt: Date.now(), + publicVisData: json, + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, getPublicVisData(visualizationId))); + }); + }; +} + +export const DOWNLOAD_PUBLIC_VIS_DATA = "DOWNLOAD_PUBLIC_VIS_DATA"; + +export function downloadPublicVisData( + visualizationId, + filename = "", + autoSave = true +) { + return async (dispatch) => { + if (filename === "") { + // TODO guess extension + filename = `${visualizationId}.tmp`; + } + let endpoint = `${config.hostname}/api/v2/public/visualizations/${visualizationId}/bytes`; + const response = await fetch(endpoint, { + method: "GET", + mode: "cors", + }); + + if (response.status === 200) { + const blob = await response.blob(); + if (autoSave) { + if (window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveBlob(blob, filename); + } else { + const anchor = window.document.createElement("a"); + anchor.href = window.URL.createObjectURL(blob); + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } + } + + dispatch({ + type: DOWNLOAD_PUBLIC_VIS_DATA, + publicBlob: blob, + receivedAt: Date.now(), + }); + } else { + dispatch( + handleErrors( + response, + downloadPublicVisData(visualizationId, filename, autoSave) + ) + ); + } + }; +} + +export const GET_PUBLIC_VIS_DATA_PRESIGNED_URL = "GET_PUBLIC_VIS_DATA_PRESIGNED_URL"; +export const RESET_PUBLIC_VIS_DATA_PRESIGNED_URL = "RESET_PUBLIC_VIS_DATA_PRESIGNED_URL"; + +export function generatePublicVisPresignedUrl( + visualizationId, + expiresInSeconds = 7 * 24 * 3600 +) { + return async (dispatch) => { + return V2.PublicVisualizationsService.downloadVisualizationUrlApiV2PublicVisualizationsVisualizationIdUrlGet( + visualizationId, + expiresInSeconds + ) + .then((json) => { + dispatch({ + type: GET_PUBLIC_VIS_DATA_PRESIGNED_URL, + receivedAt: Date.now(), + publicPresignedUrl: json["presigned_url"], + }); + }) + .catch((reason) => { + dispatch( + handleErrors( + reason, + generatePublicVisPresignedUrl(visualizationId, expiresInSeconds) + ) + ); + }); + }; +} diff --git a/frontend/src/components/Public.tsx b/frontend/src/components/Public.tsx new file mode 100644 index 000000000..97822b7de --- /dev/null +++ b/frontend/src/components/Public.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useState } from "react"; +import { Box, Button, ButtonGroup, Grid, Tab, Tabs } from "@mui/material"; + +import { RootState } from "../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import {fetchPublicDatasets} from "../actions/public_dataset"; + +import { a11yProps, TabPanel } from "./tabs/TabComponent"; +import PublicDatasetCard from "./datasets/PublicDatasetCard"; +import { ArrowBack, ArrowForward } from "@material-ui/icons"; +import PublicLayout from "./PublicLayout"; +import Layout from "./Layout"; + +import { Link as RouterLink } from "react-router-dom"; +import { Listeners } from "./listeners/Listeners"; +import { ErrorModal } from "./errors/ErrorModal"; +import {fetchDatasets} from "../actions/dataset"; + +const tab = { + fontStyle: "normal", + fontWeight: "normal", + fontSize: "16px", + textTransform: "capitalize", +}; + +export const Public = (): JSX.Element => { + // Redux connect equivalent + const dispatch = useDispatch(); + const [skip, setSkip] = useState(); + + const [limit] = useState(21); + // TODO add switch to turn on and off "mine" dataset + const [mine] = useState(false); + const listPublicDatasets = ( + skip: number | undefined, + limit: number | undefined + ) => dispatch(fetchPublicDatasets(skip, limit)); + + const listDatasets = ( + skip: number | undefined, + limit: number | undefined, + mine: boolean | undefined + ) => dispatch(fetchDatasets(skip, limit, mine)); + const datasetState = useSelector((state: RootState) => state.dataset); + const datasets = useSelector((state: RootState) => state.dataset.datasets); + + + const currrentPublicDatasetState = useSelector((state: RootState) => state.publicDataset); + const public_datasets = useSelector((state: RootState) => state.publicDataset.public_datasets); + const [currPageNum, setCurrPageNum] = useState(0); + const [prevDisabled, setPrevDisabled] = useState(true); + const [nextDisabled, setNextDisabled] = useState(false); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [errorOpen, setErrorOpen] = useState(false); + + + useEffect(() => { + listDatasets(0, limit, mine); + }, []); + + useEffect( () => { + listPublicDatasets(0, limit); + }, []); + + // fetch thumbnails from each individual dataset/id calls + useEffect(() => { + // disable flipping if reaches the last page + if (public_datasets.length < limit) setNextDisabled(true); + else setNextDisabled(false); + }, [public_datasets]); + + // switch tabs + const handleTabChange = ( + _event: React.ChangeEvent<{}>, + newTabIndex: number + ) => { + setSelectedTabIndex(newTabIndex); + }; + + // for pagination keep flipping until the return dataset is less than the limit + const previous = () => { + if (currPageNum - 1 >= 0) { + setSkip((currPageNum - 1) * limit); + setCurrPageNum(currPageNum - 1); + } + }; + const next = () => { + if (public_datasets.length === limit) { + setSkip((currPageNum + 1) * limit); + setCurrPageNum(currPageNum + 1); + } + }; + useEffect(() => { + if (skip !== null && skip !== undefined) { + listPublicDatasets(skip, limit); + if (skip === 0) setPrevDisabled(true); + else setPrevDisabled(false); + } + }, [skip]); + + + return ( + + {/*Error Message dialogue*/} + + + + + + + + + + + + + {public_datasets !== undefined ? ( + public_datasets.map((dataset) => { + return ( + + + + ); + }) + ) : ( + <> + )} + + {datasets.length !== 0 ? ( + + + + + + + ): ( + <> + )} + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/PublicLayout.tsx b/frontend/src/components/PublicLayout.tsx new file mode 100644 index 000000000..2bffb28e2 --- /dev/null +++ b/frontend/src/components/PublicLayout.tsx @@ -0,0 +1,263 @@ +import * as React from "react"; +import { useEffect } from "react"; +import { styled, useTheme } from "@mui/material/styles"; +import Box from "@mui/material/Box"; +import Drawer from "@mui/material/Drawer"; +import CssBaseline from "@mui/material/CssBaseline"; +import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; +import Toolbar from "@mui/material/Toolbar"; +import List from "@mui/material/List"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import MenuIcon from "@mui/icons-material/Menu"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import SearchDatasetIcon from "@mui/icons-material/Search"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import { Link, Menu, MenuItem, MenuList } from "@mui/material"; +import { Link as RouterLink, useLocation } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { RootState } from "../types/data"; +import { AddBox, Explore } from "@material-ui/icons"; +import HistoryIcon from "@mui/icons-material/History"; +import GroupIcon from "@mui/icons-material/Group"; +import Gravatar from "react-gravatar"; +import PersonIcon from "@mui/icons-material/Person"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { getCurrEmail } from "../utils/common"; +import VpnKeyIcon from "@mui/icons-material/VpnKey"; +import LogoutIcon from "@mui/icons-material/Logout"; +import { EmbeddedSearch } from "./search/EmbeddedSearch"; + +const drawerWidth = 240; + +const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ + open?: boolean; +}>(({ theme, open }) => ({ + flexGrow: 1, + padding: theme.spacing(3), + transition: theme.transitions.create("margin", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + marginLeft: `-${drawerWidth}px`, + ...(open && { + transition: theme.transitions.create("margin", { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + marginLeft: 0, + }), +})); + +const SearchDiv = styled("div")(({ theme }) => ({ + position: "relative", + marginLeft: theme.spacing(3), + marginBottom: "-5px", // to compoensate the tags div + width: "50%", +})); + +interface AppBarProps extends MuiAppBarProps { + open?: boolean; +} + +const AppBar = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== "open", +})(({ theme, open }) => ({ + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + ...(open && { + width: `calc(100% - ${drawerWidth}px)`, + marginLeft: `${drawerWidth}px`, + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }), +})); + +const DrawerHeader = styled("div")(({ theme }) => ({ + display: "flex", + alignItems: "center", + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, + justifyContent: "flex-end", +})); + +const link = { + textDecoration: "none", + fontSize: "16px", + color: "#495057", + m: 2, +}; + +export default function PersistentDrawerLeft(props) { + const { children } = props; + const theme = useTheme(); + const [open, setOpen] = React.useState(false); + const [embeddedSearchHidden, setEmbeddedSearchHidden] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const isMenuOpen = Boolean(anchorEl); + + const handleDrawerOpen = () => { + setOpen(true); + }; + + const handleDrawerClose = () => { + setOpen(false); + }; + + const handleProfileMenuOpen = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleProfileMenuClose = () => { + setAnchorEl(null); + }; + + const location = useLocation(); + + useEffect(() => { + if (location.pathname.includes("search")) { + setEmbeddedSearchHidden(true); + } else { + setEmbeddedSearchHidden(false); + } + }, [location]); + + const loggedOut = useSelector((state: RootState) => state.error.loggedOut); + + // @ts-ignore + return ( + + + + + + + + + + + + {/*for searching*/} + + + + + Register + + + Login + + + + + {/*Profile menu*/} + + + + + + + User Profile + + + + + + + API Key + + + + + + + Log Out + + + + {/*side drawer*/} + + + + {theme.direction === "ltr" ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + {/*search commented out for now*/} + {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/**/} + + {/**/} + +
+ + {children} +
+
+ ); +} diff --git a/frontend/src/components/datasets/Dataset.tsx b/frontend/src/components/datasets/Dataset.tsx index 962fd1b48..69769e329 100644 --- a/frontend/src/components/datasets/Dataset.tsx +++ b/frontend/src/components/datasets/Dataset.tsx @@ -405,7 +405,7 @@ export const Dataset = (): JSX.Element => { resourceId={datasetId} /> - {enableAddMetadata && datasetRole.role !== undefined && datasetRole.role !== "viewer" ? + {datasetRole.role !== undefined && datasetRole.role !== "viewer" ? + + + ) : ( + <> + + + {enableAddMetadata && datasetRole.role !== undefined && datasetRole.role !== "viewer" ? + : + <> + } + + + )} + + + + + {datasetRole.role !== undefined && datasetRole.role !== "viewer" ? + + + : + <> + } + + + + {datasetRole.role !== undefined && datasetRole.role !== "viewer" ? + + + + : <> + } + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/datasets/EditStatusModal.tsx b/frontend/src/components/datasets/EditStatusModal.tsx index b1c7429a3..8e6feb66d 100644 --- a/frontend/src/components/datasets/EditStatusModal.tsx +++ b/frontend/src/components/datasets/EditStatusModal.tsx @@ -1,7 +1,8 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import LoadingOverlay from "react-loading-overlay-ts"; import { Alert, + Autocomplete, Button, Collapse, Container, @@ -15,13 +16,15 @@ import { InputLabel, MenuItem, Select, + TextField, Typography, } from "@mui/material"; import { useParams } from "react-router-dom"; -import { updateDataset } from "../../actions/dataset"; +import { setDatasetUserRole, updateDataset } from "../../actions/dataset"; import { useDispatch, useSelector } from "react-redux"; import CloseIcon from "@mui/icons-material/Close"; -import { DatasetIn } from "../../openapi/v2"; +import { fetchAllUsers } from "../../actions/user"; +import {DatasetIn, UserOut} from "../../openapi/v2"; import { RootState } from "../../types/data"; type EditStatusModalProps = { @@ -35,93 +38,98 @@ export default function EditStatusModal(props: EditStatusModalProps) { const { open, handleClose, datasetName } = props; const { datasetId } = useParams<{ datasetId?: string }>(); const [showSuccessAlert, setShowSuccessAlert] = useState(false); - const editDataset = (datasetId: string | undefined, formData: DatasetIn) => - dispatch(updateDataset(datasetId, formData)); + const editDataset = (datasetId: string | undefined, formData: DatasetIn) => dispatch(updateDataset(datasetId, formData)); const about = useSelector((state: RootState) => state.dataset.about); - const [datasetStatus, setDatasetStatus] = useState(about["status"]); + const [datasetStatus, setDatasetStatus] = useState(about["status"]) const [loading, setLoading] = useState(false); const onSetStatus = () => { setLoading(true); - editDataset(datasetId, { status: datasetStatus }); + editDataset(datasetId, {"status": datasetStatus}); setLoading(false); handleClose(true); }; return ( - - - - Change Dataset Status '{datasetName}' - - - - Change the status of your dataset -
- - Status - { + setDatasetStatus(event.target.value); + }} + > + Private + Authenticated + Public + {/*Published*/} + + +
+ + +
+ { + setShowSuccessAlert(false); }} > - Private - Authenticated - {/*Public*/} - {/*Published*/} - - - - -
- { - setShowSuccessAlert(false); - }} - > - - - } - sx={{ mb: 2 }} - > - Successfully added role! - -
-
- - - - -
+ + + } + sx={{ mb: 2 }} + > + Successfully added role! + + + + + + +
); diff --git a/frontend/src/components/datasets/PublicActionsMenu.tsx b/frontend/src/components/datasets/PublicActionsMenu.tsx new file mode 100644 index 000000000..eb76a279a --- /dev/null +++ b/frontend/src/components/datasets/PublicActionsMenu.tsx @@ -0,0 +1,33 @@ +import { Button, Stack } from "@mui/material"; +import React from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "../../types/data"; +import { Download } from "@mui/icons-material"; +import config from "../../app.config"; + +type ActionsMenuProps = { + datasetId: string; +}; + +export const PublicActionsMenu = (props: ActionsMenuProps): JSX.Element => { + const { datasetId} = props; + + return ( + + + {/*owner, editor, uploader cannot create new*/} + + ); +}; diff --git a/frontend/src/components/datasets/PublicDataset.tsx b/frontend/src/components/datasets/PublicDataset.tsx new file mode 100644 index 000000000..be7b65dc7 --- /dev/null +++ b/frontend/src/components/datasets/PublicDataset.tsx @@ -0,0 +1,399 @@ +// lazy loading +import React, { useEffect, useState } from "react"; +import {Box, Button, ButtonGroup, Grid, Stack, Tab, Tabs, Typography} from "@mui/material"; +import { useParams, useSearchParams } from "react-router-dom"; +import { RootState } from "../../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import { + fetchDatasetAbout, + fetchFilesInDataset, + fetchFoldersInDataset, +} from "../../actions/dataset"; +import { fetchFolderPath } from "../../actions/folder"; + +import { + fetchPublicDatasetAbout, + fetchFilesInPublicDataset, + fetchFoldersInPublicDataset, +} from "../../actions/public_dataset"; + +import { fetchPublicFolderPath } from "../../actions/folder"; + +import { a11yProps, TabPanel } from "../tabs/TabComponent"; +import FilesTable from "../files/FilesTable"; +import { MetadataIn } from "../../openapi/v2"; +import { DisplayMetadata } from "../metadata/DisplayMetadata"; +import { DisplayListenerMetadata } from "../metadata/DisplayListenerMetadata"; +import { EditMetadata } from "../metadata/EditMetadata"; +import { MainBreadcrumbs } from "../navigation/BreadCrumb"; +import { + deleteDatasetMetadata as deleteDatasetMetadataAction, + fetchDatasetMetadata, fetchMetadataDefinitions, + patchDatasetMetadata as patchDatasetMetadataAction, + postDatasetMetadata, +} from "../../actions/metadata"; +import Layout from "../Layout"; +import PublicLayout from "../PublicLayout"; +import { PublicActionsMenu } from "./PublicActionsMenu"; +import { DatasetDetails } from "./DatasetDetails"; +import {ArrowBack, ArrowForward, FormatListBulleted, InsertDriveFile} from "@material-ui/icons"; +import { Listeners } from "../listeners/Listeners"; +import AssessmentIcon from "@mui/icons-material/Assessment"; +import HistoryIcon from "@mui/icons-material/History"; +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 { Forbidden } from "../errors/Forbidden"; +import { PageNotFound } from "../errors/PageNotFound"; +import { ErrorModal } from "../errors/ErrorModal"; +import { Visualization } from "../visualizations/Visualization"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import {UserMetadataTabPanel} from "../metadata/UserMetadataTabPanel"; +import {EditMetadataTabPanel} from "../metadata/EditMetadataTabPanel"; + +export const PublicDataset = (): JSX.Element => { + // path parameter + const { datasetId } = useParams<{ datasetId?: string }>(); + + // search parameters + const [searchParams] = useSearchParams(); + const folderId = searchParams.get("folder"); + // Redux connect equivalent + const dispatch = useDispatch(); + const updateDatasetMetadata = ( + datasetId: string | undefined, + content: object + ) => dispatch(patchDatasetMetadataAction(datasetId, content)); + const createDatasetMetadata = ( + datasetId: string | undefined, + metadata: MetadataIn + ) => dispatch(postDatasetMetadata(datasetId, metadata)); + const deleteDatasetMetadata = ( + datasetId: string | undefined, + metadata: object + ) => dispatch(deleteDatasetMetadataAction(datasetId, metadata)); + const getFolderPath = (folderId: string | null) => + dispatch(fetchFolderPath(folderId)); + const listFilesPublicInDataset = ( + datasetId: string | undefined, + folderId: string | null + , skip: number | undefined, limit: number | undefined) => dispatch(fetchFilesInPublicDataset(datasetId, folderId, skip, limit)); + const listFoldersInDataset = ( + datasetId: string | undefined, + parentFolder: string | null, + skip: number | undefined, limit: number | undefined + ) => dispatch(fetchFoldersInDataset(datasetId, parentFolder, skip, limit)); + const listFoldersInPublicDataset = ( + datasetId: string | undefined, + parentFolder: string | null, + skip: number | undefined, limit: number | undefined + ) => dispatch(fetchFoldersInPublicDataset(datasetId, parentFolder, skip, limit)); + const listDatasetAbout = (datasetId: string | undefined) => + dispatch(fetchDatasetAbout(datasetId)); + const listPublicDatasetAbout = (datasetId: string | undefined) => + dispatch(fetchPublicDatasetAbout(datasetId)); + const listDatasetMetadata = (datasetId: string | undefined) => + dispatch(fetchDatasetMetadata(datasetId)); + const getMetadatDefinitions = (name:string|null, skip:number, limit:number) => dispatch(fetchMetadataDefinitions(name, skip,limit)); + + + // mapStateToProps + const about = useSelector((state: RootState) => state.publicDataset.public_about); + // const datasetRole = useSelector( + // (state: RootState) => state.dataset.datasetRole + // ); + const folderPath = useSelector((state: RootState) => state.folder.publicFolderPath); + + // state + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [enableAddMetadata, setEnableAddMetadata] = + React.useState(false); + const [metadataRequestForms, setMetadataRequestForms] = useState({}); + + const [allowSubmit, setAllowSubmit] = React.useState(false); + // Error msg dialog + const [errorOpen, setErrorOpen] = useState(false); + const [showForbiddenPage, setShowForbiddenPage] = useState(false); + const [showNotFoundPage, setShowNotFoundPage] = useState(false); + + const [paths, setPaths] = useState([]); + + // TODO add option to determine limit number; default show 20 files each time + const [currPageNum, setCurrPageNum] = useState(0); + const [limit] = useState(10); + const [skip, setSkip] = useState(0); + const [prevDisabled, setPrevDisabled] = useState(true); + const [nextDisabled, setNextDisabled] = useState(false); + // we use the public files here + const filesInDataset = useSelector((state: RootState) => state.publicDataset.public_files); + + const foldersInDataset = useSelector((state: RootState) => state.folder.folders); + const publicFoldersInDataset = useSelector((state: RootState) => state.folder.publicFolders); + const metadataDefinitionList = useSelector((state: RootState) => state.metadata.metadataDefinitionList); + + // component did mount list all files in dataset + useEffect(() => { + listFilesPublicInDataset(datasetId, folderId, skip, limit); + // listFoldersInDataset(datasetId, folderId, skip, limit); + listFoldersInPublicDataset(datasetId, folderId, skip, limit); + // listDatasetAbout(datasetId); + listPublicDatasetAbout(datasetId); + getFolderPath(folderId); + }, [searchParams]); + + useEffect(() => { + getMetadatDefinitions(null, 0, 100); + }, []); + + useEffect(() => { + // disable flipping if reaches the last page + if (filesInDataset.length < limit && foldersInDataset.length < limit) + setNextDisabled(true); + else + setNextDisabled(false); + }, [filesInDataset]); + + useEffect(() => { + if (skip !== null && skip !== undefined) { + listFilesPublicInDataset(datasetId, folderId, skip, limit); + // listFoldersInDataset(datasetId, folderId, skip, limit); + listFoldersInPublicDataset(datasetId, folderId, skip, limit); + if (skip === 0) setPrevDisabled(true); + else setPrevDisabled(false); + } + }, [skip]); + + // for breadcrumb + useEffect(() => { + // for breadcrumb + const tmpPaths = [ + { + name: about["name"], + url: `/public_datasets/${datasetId}`, + }, + ]; + + if (folderPath != null) { + for (const folderBread of folderPath) { + tmpPaths.push({ + name: folderBread["folder_name"], + url: `/public_datasets/${datasetId}?folder=${folderBread["folder_id"]}`, + }); + } + } else { + tmpPaths.slice(0, 1); + } + + setPaths(tmpPaths); + }, [about, folderPath]); + + // for pagination keep flipping until the return dataset is less than the limit + const previous = () => { + if (currPageNum - 1 >= 0) { + setSkip((currPageNum - 1) * limit); + setCurrPageNum(currPageNum - 1); + } + }; + const next = () => { + if (filesInDataset.length === limit || foldersInDataset.length === limit) { + setSkip((currPageNum + 1) * limit); + setCurrPageNum(currPageNum + 1); + } + }; + + const handleTabChange = ( + _event: React.ChangeEvent<{}>, + newTabIndex: number + ) => { + setSelectedTabIndex(newTabIndex); + }; + + const setMetadata = (metadata: any) => { + // TODO wrap this in to a function + setMetadataRequestForms((prevState) => { + // merge the content field; e.g. lat lon + if (metadata.definition in prevState) { + const prevContent = prevState[metadata.definition].content; + metadata.content = { ...prevContent, ...metadata.content }; + } + return { ...prevState, [metadata.definition]: metadata }; + }); + }; + + const handleMetadataUpdateFinish = () => { + Object.keys(metadataRequestForms).map((key) => { + if ( + "id" in metadataRequestForms[key] && + metadataRequestForms[key]["id"] !== undefined && + metadataRequestForms[key]["id"] !== null && + metadataRequestForms[key]["id"] !== "" + ) { + // update existing metadata + updateDatasetMetadata(datasetId, metadataRequestForms[key]); + } else { + // post new metadata if metadata id doesn"t exist + createDatasetMetadata(datasetId, metadataRequestForms[key]); + } + }); + + // reset the form + setMetadataRequestForms({}); + + // pulling lastest from the API endpoint + listDatasetMetadata(datasetId); + + // switch to display mode + setEnableAddMetadata(false); + }; + + if (showForbiddenPage) { + return ; + } else if (showNotFoundPage) { + return ; + } + + return ( + + {/*Error Message dialogue*/} + + + {/*title*/} + + + + + {about["name"]} + + + + {/**/} + + + + {/*actions*/} + + + + {/*actions*/} + + + + + {about["description"]} + + + } + iconPosition="start" + sx={TabStyle} + label="Files" + {...a11yProps(0)} + /> + } + iconPosition="start" + sx={TabStyle} + label="Visualizations" + {...a11yProps(1)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="User Metadata" + {...a11yProps(2)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Extracted Metadata" + {...a11yProps(3)} + disabled={false} + /> + + + {folderId !== null ? ( + + + + ) : ( + <> + )} + + + + + + + + + + + + + + + {/**/} + {/* */} + {/**/} + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/datasets/PublicDatasetCard.tsx b/frontend/src/components/datasets/PublicDatasetCard.tsx new file mode 100644 index 000000000..9ea0fd916 --- /dev/null +++ b/frontend/src/components/datasets/PublicDatasetCard.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useState } from "react"; +import Card from "@mui/material/Card"; +import CardActions from "@mui/material/CardActions"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import { Link } from "react-router-dom"; +import { parseDate } from "../../utils/common"; +import { + CardActionArea, + CardHeader, + CardMedia, + IconButton, + Tooltip, +} from "@mui/material"; +import { Download } from "@mui/icons-material"; +import { generateThumbnailUrl } from "../../utils/visualization"; +import config from "../../app.config"; +// import {Favorite, Share} from "@material-ui/icons"; + +type PublicDatasetCardProps = { + id?: string; + name?: string; + author?: string; + created?: string | Date; + description?: string; + thumbnailId?: string; + publicView?: boolean | false; +}; + +export default function PublicDatasetCard(props: PublicDatasetCardProps) { + const { id, name, author, created, description, thumbnailId, publicView } = props; + const [thumbnailUrl, setThumbnailUrl] = useState(""); + + useEffect(() => { + let url = ""; + if (thumbnailId) { + url = generateThumbnailUrl(thumbnailId); + } + setThumbnailUrl(url); + }, [thumbnailId]); + + const formattedCreated = parseDate(created, "PP"); + const subheader = `${formattedCreated} \u00B7 ${author}`; + + return ( + + {publicView? + ( + + + {thumbnailId ? ( + + ) : + null} + + + {description} + + + ): + ( + + {thumbnailId ? ( + + ) : null} + + + {description} + + + ) + } + + + + + + + {/**/} + {/* */} + {/* */} + {/* */} + {/**/} + {/**/} + {/* */} + {/* */} + {/* */} + {/**/} + + + ); +} diff --git a/frontend/src/components/files/FileMenu.tsx b/frontend/src/components/files/FileMenu.tsx index 9a0e6a8b6..820e26655 100644 --- a/frontend/src/components/files/FileMenu.tsx +++ b/frontend/src/components/files/FileMenu.tsx @@ -20,10 +20,11 @@ import config from "../../app.config"; type FileMenuProps = { file: File; setSelectedVersion: any; + publicView: boolean | false; }; export default function FileMenu(props: FileMenuProps) { - const { file, setSelectedVersion } = props; + const { file, setSelectedVersion , publicView} = props; const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { @@ -115,23 +116,40 @@ export default function FileMenu(props: FileMenuProps) { + {/*owner, editor, uploader and viewer can download file*/} - - { - handleClose(); - window.location.href = `${config.hostname}/api/v2/files/${file.id}`; - }} + + {publicView? + ( + { + handleClose(); + window.location.href = `${config.hostname}/api/v2/public_files/${file.id}`; + }} + > + + + + Download + + ): + - - - - Download - - + { + handleClose(); + window.location.href = `${config.hostname}/api/v2/files/${file.id}`; + }} + > + + + + Download + + + } {/*owner can delete file*/} diff --git a/frontend/src/components/files/FilesTable.tsx b/frontend/src/components/files/FilesTable.tsx index e2d3b7ef5..04bf8f9b8 100644 --- a/frontend/src/components/files/FilesTable.tsx +++ b/frontend/src/components/files/FilesTable.tsx @@ -19,6 +19,7 @@ import FolderMenu from "./FolderMenu"; type FilesTableProps = { datasetId: string | undefined; folderId: string | null; + publicView: boolean | false; }; const iconStyle = { @@ -27,11 +28,16 @@ const iconStyle = { }; export default function FilesTable(props: FilesTableProps) { + const {publicView} = props; // mapStateToProps const filesInDataset = useSelector((state: RootState) => state.dataset.files); + const publicFilesInDataset = useSelector((state: RootState) => state.publicDataset.public_files); const foldersInDataset = useSelector( (state: RootState) => state.folder.folders ); + const publicFoldersIndataset = useSelector( + (state: RootState) => state.folder.publicFolders + ); // use history hook to redirect/navigate between routes const history = useNavigate(); // get existing folder @@ -42,56 +48,118 @@ export default function FilesTable(props: FilesTableProps) { `/files/${selectedFileId}?dataset=${props.datasetId}&folder=${parentFolderId}&verNum=${selectedFileId}` ); }; + const selectPublicFile = (selectedFileId: string | undefined) => { + // Redirect to file route with file Id and dataset id and folderId + history( + `/public/files/${selectedFileId}?dataset=${props.datasetId}&folder=${parentFolderId}&verNum=${selectedFileId}` + ); + }; const selectFolder = (selectedFolderId: string | undefined) => { // Redirect to file route with file Id and dataset id history(`/datasets/${props.datasetId}?folder=${selectedFolderId}`); }; + const selectPublicFolder = (selectedFolderId: string | undefined) => { + // Redirect to file route with file Id and dataset id + history(`/public/datasets/${props.datasetId}?folder=${selectedFolderId}`); + }; + return ( - - - - Name - Version - Created - Size - Type - - - - - {foldersInDataset.map((folder) => ( - - - - - -   - {parseDate(folder.created)} -   -   - - - + {publicView? + ( +
+ + + Name + Version + Created + Size + Type + + + + + {publicFoldersIndataset.map((folder) => ( + + + + + +   + {parseDate(folder.created)} +   +   + + + + + ))} + {publicFilesInDataset.map((file) => ( + + ))} + +
+ ) : + + + + Name + Version + Created + Size + Type + - ))} - {filesInDataset.map((file) => ( - - ))} - -
+ + + {foldersInDataset.map((folder) => ( + + + + + +   + {parseDate(folder.created)} +   +   + + + + + ))} + {filesInDataset.map((file) => ( + + ))} + + + } + +
); } diff --git a/frontend/src/components/files/FilesTableFileEntry.tsx b/frontend/src/components/files/FilesTableFileEntry.tsx index 6992cb9af..02d404667 100644 --- a/frontend/src/components/files/FilesTableFileEntry.tsx +++ b/frontend/src/components/files/FilesTableFileEntry.tsx @@ -16,13 +16,13 @@ type FilesTableFileEntryProps = { selectFile: any; file: FileOut; parentFolderId: any; + publicView: boolean | false; }; export function FilesTableFileEntry(props: FilesTableFileEntryProps) { - const { iconStyle, selectFile, file, parentFolderId } = props; + const { iconStyle, selectFile, file, parentFolderId, publicView } = props; const [thumbnailUrl, setThumbnailUrl] = useState(""); const [selectedVersion, setSelectedVersion] = useState(file.version_num); - useEffect(() => { let url = ""; if (file.thumbnail_id) { @@ -63,7 +63,7 @@ export function FilesTableFileEntry(props: FilesTableFileEntryProps) { {file.content_type ? file.content_type.content_type : "NA"} - + ) : ( diff --git a/frontend/src/components/files/PublicFile.tsx b/frontend/src/components/files/PublicFile.tsx new file mode 100644 index 000000000..169c2a9ba --- /dev/null +++ b/frontend/src/components/files/PublicFile.tsx @@ -0,0 +1,525 @@ +import React, { useEffect, useState } from "react"; +import config from "../../app.config"; +import { + Box, + Button, + FormControl, + Grid, + MenuItem, + Snackbar, + Tab, + Tabs, +} from "@mui/material"; +import {downloadPublicResource} from "../../utils/common"; +import { PreviewConfiguration, RootState } from "../../types/data"; +import { useParams, useSearchParams } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; + +import { a11yProps, TabPanel } from "../tabs/TabComponent"; +import {fetchPublicFileSummary, fetchPublicFileVersions, fetchPublicFileMetadata} from "../../actions/public_file.js"; +import { MainBreadcrumbs } from "../navigation/BreadCrumb"; +import { FileVersionHistory } from "../versions/FileVersionHistory"; +import { DisplayMetadata } from "../metadata/DisplayMetadata"; +import { DisplayListenerMetadata } from "../metadata/DisplayListenerMetadata"; +import { + fetchFileMetadata, +} from "../../actions/metadata"; +import PublicLayout from "../PublicLayout"; +import { fetchPublicDatasetAbout} from "../../actions/public_dataset"; +import { FileDetails } from "./FileDetails"; +import {fetchPublicFolderPath } from "../../actions/folder"; +import { ExtractionHistoryTab } from "../listeners/ExtractionHistoryTab"; +import { PublicFileActionsMenu } from "./PublicFileActionsMenu"; +import { FormatListBulleted, InsertDriveFile } from "@material-ui/icons"; +import { TabStyle } from "../../styles/Styles"; +import BuildIcon from "@mui/icons-material/Build"; +import AssessmentIcon from "@mui/icons-material/Assessment"; +import HistoryIcon from "@mui/icons-material/History"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import { PublicVisualization } from "../visualizations/PublicVisualization"; +import { ErrorModal } from "../errors/ErrorModal"; +import { FileHistory } from "./FileHistory"; +import { VersionChip } from "../versions/VersionChip"; +import Typography from "@mui/material/Typography"; +import { ClowderSelect } from "../styledComponents/ClowderSelect"; + +export const PublicFile = (): JSX.Element => { + // path parameter + const { fileId } = useParams<{ fileId?: string }>(); + + // search parameters + const [searchParams] = useSearchParams(); + const folderId = searchParams.get("folder"); + const datasetId = searchParams.get("dataset"); + + const listDatasetAbout = (datasetId: string | undefined) => + dispatch(fetchPublicDatasetAbout(datasetId)); + const about = useSelector((state: RootState) => state.publicDataset.public_about); + + const dispatch = useDispatch(); + const listPublicFileSummary = (fileId: string | undefined) => + dispatch(fetchPublicFileSummary(fileId)); + const listPublicFileVersions = (fileId: string | undefined, + skip: number | undefined, + limit: number | undefined) => + dispatch(fetchPublicFileVersions(fileId, skip, limit)); + const listFileMetadata = (fileId: string | undefined) => + dispatch(fetchPublicFileMetadata(fileId)); + const getPublicFolderPath = (folderId: string | null) => + dispatch(fetchPublicFolderPath(folderId)); + + const file = useSelector((state: RootState) => state.publicFile); + const fileSummary = useSelector((state: RootState) => state.publicFile.publicFileSummary); + const filePreviews = useSelector((state: RootState) => state.publicFile.publicPreviews); + const fileVersions = useSelector( + (state: RootState) => state.publicFile.publicFileVersions + ); + const latestVersionNum = useSelector( + (state: RootState) => state.publicFile.publicFileSummary.version_num + ); + const [selectedVersionNum, setSelectedVersionNum] = useState( + latestVersionNum ?? 1 + ); + const folderPath = useSelector((state: RootState) => state.folder.publicFolderPath); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [previews, setPreviews] = useState([]); + const [enableAddMetadata, setEnableAddMetadata] = + React.useState(false); + const [metadataRequestForms, setMetadataRequestForms] = useState({}); + const [paths, setPaths] = useState([]); + + // Error msg dialog + const [errorOpen, setErrorOpen] = useState(false); + const [showForbiddenPage, setShowForbiddenPage] = useState(false); + const [showNotFoundPage, setShowNotFoundPage] = useState(false); + + // snack bar + const [snackBarOpen, setSnackBarOpen] = useState(false); + const [snackBarMessage, setSnackBarMessage] = useState(""); + + // component did mount + useEffect(() => { + // load file information + listPublicFileSummary(fileId); + listPublicFileVersions(fileId, 0, 20); + // FIXME replace checks for null with logic to load this info from redux instead of the page parameters + if (datasetId != "null" && datasetId != "undefined") { + listDatasetAbout(datasetId); // get dataset name + } + if (folderId != "null" && folderId != "undefined") { + getPublicFolderPath(folderId); // get folder path + } + }, []); + + // for breadcrumb + useEffect(() => { + const tmpPaths = [ + { + name: about["name"], + url: `/public_datasets/${datasetId}`, + }, + ]; + + if (folderPath != null) { + for (const folderBread of folderPath) { + tmpPaths.push({ + name: folderBread["folder_name"], + url: `/public_datasets/${datasetId}?folder=${folderBread["folder_id"]}`, + }); + } + } else { + tmpPaths.slice(0, 1); + } + + // add file name to breadcrumb + tmpPaths.push({ + name: fileSummary.name, + url: "", + }); + + setPaths(tmpPaths); + }, [about, fileSummary, folderPath]); + + useEffect(() => { + if (latestVersionNum !== undefined && latestVersionNum !== null) { + setSelectedVersionNum(latestVersionNum); + } + }, [latestVersionNum]); + + useEffect(() => { + (async () => { + if ( + filePreviews !== undefined && + filePreviews.length > 0 && + filePreviews[0].previews !== undefined + ) { + const previewsTemp: any = []; + await Promise.all( + filePreviews[0].previews.map(async (filePreview) => { + // download resources + const Configuration: PreviewConfiguration = { + previewType: "", + url: "", + fileid: "", + previewer: "", + fileType: "", + resource: "", + }; + Configuration.previewType = filePreview["p_id"] + .replace(" ", "-") + .toLowerCase(); + Configuration.url = `/public/${config.hostname}${filePreview["pv_route"]}?superAdmin=true`; + Configuration.fileid = filePreview["pv_id"]; + Configuration.previewer = `/public/${filePreview["p_path"]}/`; + Configuration.fileType = filePreview["pv_contenttype"]; + + const resourceURL = `/public/${config.hostname}${filePreview["pv_route"]}?superAdmin=true`; + Configuration.resource = await downloadPublicResource(resourceURL); + previewsTemp.push(Configuration); + }) + ); + setPreviews(previewsTemp); + } + })(); + }, [filePreviews]); + + const handleTabChange = ( + _event: React.ChangeEvent<{}>, + newTabIndex: number + ) => { + setSelectedTabIndex(newTabIndex); + }; + + // if (showForbiddenPage) { + // return ; + // } else if (showNotFoundPage) { + // return ; + // } + + return ( + + {/*Error Message dialogue*/} + + {/*snackbar*/} + { + setSnackBarOpen(false); + setSnackBarMessage(""); + }} + message={snackBarMessage} + /> + + + + + + + + + + + + + + + } + iconPosition="start" + sx={TabStyle} + label="Visualizations" + {...a11yProps(0)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Version History" + {...a11yProps(1)} + /> + } + iconPosition="start" + sx={TabStyle} + label="User Metadata" + {...a11yProps(2)} + disabled={false} + /> + } + iconPosition="start" + sx={TabStyle} + label="Extracted Metadata" + {...a11yProps(3)} + disabled={false} + /> + {/*}*/} + {/* iconPosition="start"*/} + {/* sx={TabStyle}*/} + {/* label="Extract"*/} + {/* {...a11yProps(4)}*/} + {/* disabled={false}*/} + {/*/>*/} + + + + + {/*Version History*/} + + {fileVersions !== undefined ? ( + + ) : ( + <> + )} + + + + + + + + {/**/} + {/* */} + {/**/} + + + {latestVersionNum == selectedVersionNum ? ( + // latest version + <> + {Object.keys(fileSummary).length > 0 && ( + + )} + + ) : ( + // history version + <> + {Object.keys(fileSummary).length > 0 && ( + + )} + + )} + <> + Version + + { + setSelectedVersionNum(event.target.value); + setSnackBarMessage("Viewing version " + event.target.value); + setSnackBarOpen(true); + }} + > + {fileVersions.map((fileVersion) => { + return ( + + {fileVersion.version_num} + + ); + })} + + + + + + + ) + + // return ( + // + // {/*Error Message dialogue*/} + // + // {/*snackbar*/} + // { + // setSnackBarOpen(false); + // setSnackBarMessage(""); + // }} + // message={snackBarMessage} + // /> + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // } + // iconPosition="start" + // sx={TabStyle} + // label="Visualizations" + // {...a11yProps(0)} + // disabled={false} + // /> + // } + // iconPosition="start" + // sx={TabStyle} + // label="Version History" + // {...a11yProps(1)} + // /> + // } + // iconPosition="start" + // sx={TabStyle} + // label="User Metadata" + // {...a11yProps(2)} + // disabled={false} + // /> + // } + // iconPosition="start" + // sx={TabStyle} + // label="Extracted Metadata" + // {...a11yProps(3)} + // disabled={false} + // /> + // {/*}*/} + // {/* iconPosition="start"*/} + // {/* sx={TabStyle}*/} + // {/* label="Extract"*/} + // {/* {...a11yProps(4)}*/} + // {/* disabled={false}*/} + // {/*/>*/} + // } + // iconPosition="start" + // sx={TabStyle} + // label="Extraction History" + // {...a11yProps(5)} + // disabled={false} + // /> + // + // + // + // + // {/*Version History*/} + // + // {fileVersions !== undefined ? ( + // + // ) : ( + // <> + // )} + // + // + // + // + // + // + // + // {/**/} + // {/* */} + // {/**/} + // + // + // + // + // + // {latestVersionNum == selectedVersionNum ? ( + // // latest version + // <> + // {/*{Object.keys(fileSummary).length > 0 && (*/} + // {/* */} + // {/*)}*/} + // + // ) : ( + // // history version + // <> + // {/*{Object.keys(fileSummary).length > 0 && (*/} + // {/* */} + // {/*)}*/} + // + // )} + // <> + // Version + // + // { + // setSelectedVersionNum(event.target.value); + // setSnackBarMessage("Viewing version " + event.target.value); + // setSnackBarOpen(true); + // }} + // > + // {fileVersions.map((fileVersion) => { + // return ( + // + // {fileVersion.version_num} + // + // ); + // })} + // + // + // + // + // + // + // ); +}; diff --git a/frontend/src/components/files/PublicFileActionsMenu.tsx b/frontend/src/components/files/PublicFileActionsMenu.tsx new file mode 100644 index 000000000..07be1f4fc --- /dev/null +++ b/frontend/src/components/files/PublicFileActionsMenu.tsx @@ -0,0 +1,68 @@ +import { + Button, + Dialog, + DialogTitle, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Stack, +} from "@mui/material"; +import React, { useState } from "react"; +import { + fetchPublicFileSummary, +} from "../../actions/public_file"; +import { useDispatch, useSelector } from "react-redux"; +import { Download, MoreHoriz, Upload } from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import config from "../../app.config"; + +type PublicFileActionsMenuProps = { + fileId?: string; + datasetId?: string; + setSelectedVersion: any; +}; + +export const PublicFileActionsMenu = (props: PublicFileActionsMenuProps): JSX.Element => { + const { fileId, datasetId, setSelectedVersion } = props; + + const [anchorEl, setAnchorEl] = React.useState(null); + + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + // redux + const dispatch = useDispatch(); + + const listFileSummary = (fileId: string | undefined) => + dispatch(fetchPublicFileSummary(fileId)); + const history = useNavigate(); + + return ( + + + + ); +}; diff --git a/frontend/src/components/metadata/DisplayListenerMetadata.tsx b/frontend/src/components/metadata/DisplayListenerMetadata.tsx index e899157f1..0b5f8a73f 100644 --- a/frontend/src/components/metadata/DisplayListenerMetadata.tsx +++ b/frontend/src/components/metadata/DisplayListenerMetadata.tsx @@ -6,25 +6,29 @@ import { fetchDatasetMetadata, fetchFileMetadata, fetchMetadataDefinitions, + fetchPublicDatasetMetadata, + fetchPublicFileMetadata, } from "../../actions/metadata"; import { ListenerMetadataEntry } from "../metadata/ListenerMetadataEntry"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; + type MetadataType = { - updateMetadata: any; - deleteMetadata: any; - resourceType: string | undefined; - resourceId: string | undefined; - version: number | undefined; -}; + updateMetadata: any, + deleteMetadata: any, + resourceType: string | undefined, + resourceId: string | undefined, + version: number | undefined, + publicView: boolean | false, +} /* This is the interface displayed already created metadata and allow eidts Uses only the list of metadata */ export const DisplayListenerMetadata = (props: MetadataType) => { - const { updateMetadata, deleteMetadata, resourceType, resourceId, version } = + const { updateMetadata, deleteMetadata, resourceType, resourceId, version, publicView } = props; const dispatch = useDispatch(); @@ -43,12 +47,26 @@ export const DisplayListenerMetadata = (props: MetadataType) => { fileId: string | undefined, version: number | undefined ) => dispatch(fetchFileMetadata(fileId, version)); + const listPublicFileMetadata = ( + fileId: string | undefined, + version: number | undefined + ) => dispatch(fetchPublicFileMetadata(fileId, version)); + const listPublicDatasetMetadata = ( + datasetId: string | undefined + ) => dispatch(fetchPublicDatasetMetadata(datasetId)); + const datasetMetadataList = useSelector( (state: RootState) => state.metadata.datasetMetadataList ); const fileMetadataList = useSelector( (state: RootState) => state.metadata.fileMetadataList ); + const publicDatasetMetadataList = useSelector( + (state: RootState) => state.metadata.publicDatasetMetadataList); + const publicFileMetadataList = useSelector( + (state: RootState) => state.metadata.publicFileMetadataList); + + useEffect(() => { getMetadatDefinitions(null, 0, 100); @@ -57,20 +75,31 @@ export const DisplayListenerMetadata = (props: MetadataType) => { // complete metadata list with both definition and values useEffect(() => { if (resourceType === "dataset") { - listDatasetMetadata(resourceId); + if (publicView) { + listPublicDatasetMetadata(resourceId); + } else { + listDatasetMetadata(resourceId); + } } else if (resourceType === "file") { - listFileMetadata(resourceId, version); + if (publicView){ + listPublicFileMetadata(resourceId, version); + } else { + listFileMetadata(resourceId, version); + + } } }, [resourceType, resourceId, version]); - return ( <> - {(() => { - let metadataList = []; - if (resourceType === "dataset") metadataList = datasetMetadataList; - else if (resourceType === "file") metadataList = fileMetadataList; - let listenerMetadataList = []; - let listenerMetadataContent = []; + { + (() => { + let metadataList = []; + if (resourceType === "dataset" && !publicView) metadataList = datasetMetadataList; + else if (resourceType === "file" && !publicView) metadataList = fileMetadataList; + else if (resourceType === "file" && publicView) metadataList = publicFileMetadataList; + else if (resourceType === "dataset" && publicView) metadataList = publicDatasetMetadataList; + let listenerMetadataList = []; + let listenerMetadataContent = []; return ( diff --git a/frontend/src/components/metadata/DisplayMetadata.tsx b/frontend/src/components/metadata/DisplayMetadata.tsx index 32ad33c70..12999c729 100644 --- a/frontend/src/components/metadata/DisplayMetadata.tsx +++ b/frontend/src/components/metadata/DisplayMetadata.tsx @@ -7,15 +7,19 @@ import { fetchDatasetMetadata, fetchFileMetadata, fetchMetadataDefinitions, + fetchPublicFileMetadata, + fetchPublicMetadataDefinitions, } from "../../actions/metadata"; import { Agent } from "./Agent"; import { MetadataDeleteButton } from "./widgets/MetadataDeleteButton"; +import {fetchPublicDatasetMetadata} from "../../actions/public_dataset"; type MetadataType = { updateMetadata: any; deleteMetadata: any; resourceType: string | undefined; resourceId: string | undefined; + publicView: boolean | false; }; /* @@ -23,7 +27,7 @@ This is the interface displayed already created metadata and allow eidts Uses only the list of metadata */ export const DisplayMetadata = (props: MetadataType) => { - const { updateMetadata, deleteMetadata, resourceType, resourceId } = props; + const { updateMetadata, deleteMetadata, resourceType, resourceId,publicView } = props; const dispatch = useDispatch(); @@ -32,13 +36,25 @@ export const DisplayMetadata = (props: MetadataType) => { skip: number, limit: number ) => dispatch(fetchMetadataDefinitions(name, skip, limit)); + const getPublicMetadatDefinitions = ( + name: string | null, + skip: number, + limit: number + ) => dispatch(fetchPublicMetadataDefinitions(name, skip, limit)); const metadataDefinitionList = useSelector( (state: RootState) => state.metadata.metadataDefinitionList ); + const publicMetadataDefinitionList = useSelector( + (state: RootState) => state.metadata.publicMetadataDefinitionList + ); const listDatasetMetadata = (datasetId: string | undefined) => dispatch(fetchDatasetMetadata(datasetId)); const listFileMetadata = (fileId: string | undefined) => dispatch(fetchFileMetadata(fileId)); + const listPublicDatasetMetadata = (datasetId: string | undefined) => + dispatch(fetchPublicDatasetMetadata(datasetId)); + const listPublicFileMetadata = (fileId: string | undefined) => + dispatch(fetchPublicFileMetadata(fileId)); const datasetMetadataList = useSelector( (state: RootState) => state.metadata.datasetMetadataList ); @@ -48,17 +64,30 @@ export const DisplayMetadata = (props: MetadataType) => { const datasetRole = useSelector( (state: RootState) => state.dataset.datasetRole ); - console.log(updateMetadata, "updateMetadataDisplay"); + const publicDatasetMetadataList = useSelector( + (state: RootState) => state.metadata.publicDatasetMetadataList); + const publicFileMetadataList = useSelector( + (state: RootState) => state.metadata.publicFileMetadataList); useEffect(() => { getMetadatDefinitions(null, 0, 100); + getPublicMetadatDefinitions(null, 0, 100); }, []); // complete metadata list with both definition and values useEffect(() => { - if (resourceType === "dataset") { - listDatasetMetadata(resourceId); - } else if (resourceType === "file") { - listFileMetadata(resourceId); + if (resourceType === "dataset"){ + if (publicView){ + listPublicDatasetMetadata(resourceId); + } else { + listDatasetMetadata(resourceId); + } + } + else if (resourceType === "file"){ + if (publicView){ + listPublicFileMetadata(resourceId); + } else { + listFileMetadata(resourceId); + } } }, [resourceType, resourceId]); @@ -66,10 +95,15 @@ export const DisplayMetadata = (props: MetadataType) => { <> {(() => { let metadataList = []; - if (resourceType === "dataset") metadataList = datasetMetadataList; - else if (resourceType === "file") metadataList = fileMetadataList; + let currentMetadataDefList = []; + if (resourceType === "dataset" && !publicView) metadataList = datasetMetadataList; + else if (resourceType === "file" && !publicView) metadataList = fileMetadataList; + else if (resourceType === "file" && publicView) metadataList = publicFileMetadataList; + else if (resourceType === "dataset" && publicView) metadataList = publicDatasetMetadataList; + if (publicView) currentMetadataDefList = publicMetadataDefinitionList; + else currentMetadataDefList = metadataDefinitionList; - return metadataDefinitionList.map((metadataDef) => { + return currentMetadataDefList.map((metadataDef) => { return metadataList.map((metadata, idx) => { if (metadataDef.name === metadata.definition) { return ( diff --git a/frontend/src/components/versions/FileVersionHistory.tsx b/frontend/src/components/versions/FileVersionHistory.tsx index 7bc4d0534..e661ce771 100644 --- a/frontend/src/components/versions/FileVersionHistory.tsx +++ b/frontend/src/components/versions/FileVersionHistory.tsx @@ -14,10 +14,11 @@ import config from "../../app.config"; type FileVersionHistoryProps = { fileVersions: FileVersion[]; + publicView: boolean | false; }; export function FileVersionHistory(props: FileVersionHistoryProps) { - const { fileVersions } = props; + const { fileVersions, publicView } = props; return ( @@ -27,6 +28,7 @@ export function FileVersionHistory(props: FileVersionHistoryProps) { fileVersions.map((fileVersion) => { const { version_num, creator, created } = fileVersion; return ( + @@ -40,12 +42,24 @@ export function FileVersionHistory(props: FileVersionHistoryProps) { secondary={`Uploaded on ${parseDate(created)}`} sx={{ maxWidth: "38rem" }} /> - + ): + + + + } + {/*TODO implement those actions*/} {/**/} {/**/} diff --git a/frontend/src/components/visualizations/Audio/Audio.tsx b/frontend/src/components/visualizations/Audio/Audio.tsx index 867690ac2..2f3e88a1f 100644 --- a/frontend/src/components/visualizations/Audio/Audio.tsx +++ b/frontend/src/components/visualizations/Audio/Audio.tsx @@ -1,25 +1,34 @@ import React, { useEffect, useState } from "react"; import { - generateFileDownloadUrl, + generateFileDownloadUrl, generatePublicFileDownloadUrl, generatePublicVisDataDownloadUrl, generateVisDataDownloadUrl, } from "../../../utils/visualization"; type AudioProps = { fileId?: string; visualizationId?: string; + publicView?: boolean | false; }; export default function Audio(props: AudioProps) { - const { fileId, visualizationId } = props; + const { fileId, visualizationId, publicView } = props; const [url, setUrl] = useState(""); useEffect(() => { let downloadUrl; if (visualizationId) { - downloadUrl = generateVisDataDownloadUrl(visualizationId); + if (publicView){ + downloadUrl = generatePublicVisDataDownloadUrl(visualizationId); + } else { + downloadUrl = generateVisDataDownloadUrl(visualizationId); + } } else { - downloadUrl = generateFileDownloadUrl(fileId, 0); + if (publicView){ + downloadUrl = generatePublicFileDownloadUrl(fileId, 0); + } else { + downloadUrl = generateFileDownloadUrl(fileId, 0); + } } setUrl(downloadUrl); }, [visualizationId, fileId]); diff --git a/frontend/src/components/visualizations/CSV/CSV.tsx b/frontend/src/components/visualizations/CSV/CSV.tsx index fadff400a..47aae372b 100644 --- a/frontend/src/components/visualizations/CSV/CSV.tsx +++ b/frontend/src/components/visualizations/CSV/CSV.tsx @@ -9,10 +9,13 @@ import { import { Box, Container, Grid, MenuItem, Select } from "@mui/material"; import { ClowderInputLabel } from "../../styledComponents/ClowderInputLabel"; import { theme } from "../../../theme"; +import {downloadPublicVisData} from "../../../actions/public_visualization"; +import {filePublicDownloaded} from "../../../actions/public_file"; type TextProps = { fileId?: string; visualizationId?: string; + publicView?: boolean | false; }; const allowedType = [ @@ -24,7 +27,7 @@ const allowedType = [ ]; export default function CSV(props: TextProps) { - const { fileId, visualizationId } = props; + const { fileId, visualizationId,publicView } = props; const [mark, setMark] = useState("bar"); // TODO default to the first two columns const [availableColumns, setAvailableColumns] = useState([]); @@ -42,9 +45,17 @@ export default function CSV(props: TextProps) { try { let blob; if (visualizationId) { - blob = await downloadVisData(visualizationId); + if (publicView){ + blob = await downloadPublicVisData(visualizationId); + } else { + blob = await downloadVisData(visualizationId); + } } else { - blob = await fileDownloaded(fileId, 0); + if (publicView){ + blob = await filePublicDownloaded(fileId); + } else { + blob = await fileDownloaded(fileId, 0); + } } const file = new File([blob], "text.tmp"); const text = await readTextFromFile(file); diff --git a/frontend/src/components/visualizations/Html/Html.tsx b/frontend/src/components/visualizations/Html/Html.tsx index 722c87438..1f77d4a47 100644 --- a/frontend/src/components/visualizations/Html/Html.tsx +++ b/frontend/src/components/visualizations/Html/Html.tsx @@ -1,14 +1,17 @@ import React, {useEffect, useRef, useState} from "react"; -import {downloadVisData, fileDownloaded,} from "../../../utils/visualization"; +import {downloadVisData, fileDownloaded} from "../../../utils/visualization"; import {readTextFromFile} from "../../../utils/common"; +import {downloadPublicVisData} from "../../../actions/public_visualization"; +import {filePublicDownloaded} from "../../../actions/public_file"; type htmlProps = { fileId?: string; visualizationId?: string; + publicView?: boolean | false; }; export default function Html(props: htmlProps) { - const {fileId, visualizationId} = props; + const {fileId, visualizationId, publicView} = props; const divRef = useRef(null); const isFirstRender = useRef(true); @@ -19,9 +22,18 @@ export default function Html(props: htmlProps) { try { let blob; if (visualizationId) { - blob = await downloadVisData(visualizationId); + if (publicView){ + blob = await downloadPublicVisData(visualizationId); + } else{ + blob = await downloadVisData(visualizationId); + } + } else { - blob = await fileDownloaded(fileId, 0); + if (publicView){ + blob = await filePublicDownloaded(fileId); + } else { + blob = await fileDownloaded(fileId, 0); + } } const file = new File([blob], "text.tmp"); const text = await readTextFromFile(file); diff --git a/frontend/src/components/visualizations/Image/Image.tsx b/frontend/src/components/visualizations/Image/Image.tsx index 15a08298c..88f6410ec 100644 --- a/frontend/src/components/visualizations/Image/Image.tsx +++ b/frontend/src/components/visualizations/Image/Image.tsx @@ -2,24 +2,34 @@ import React, { useEffect, useState } from "react"; import { generateFileDownloadUrl, generateVisDataDownloadUrl, + generatePublicVisDataDownloadUrl, + generatePublicFileDownloadUrl, } from "../../../utils/visualization"; type ImageProps = { fileId?: string; visualizationId?: string; + publicView?: boolean | false; }; export default function Image(props: ImageProps) { - const { fileId, visualizationId } = props; + const { fileId, visualizationId, publicView } = props; const [url, setUrl] = useState(""); - useEffect(() => { let downloadUrl; if (visualizationId) { - downloadUrl = generateVisDataDownloadUrl(visualizationId); + if (publicView){ + downloadUrl = generatePublicVisDataDownloadUrl(visualizationId); + } else { + downloadUrl = generateVisDataDownloadUrl(visualizationId); + } } else { - downloadUrl = generateFileDownloadUrl(fileId, 0); + if (publicView){ + downloadUrl = generatePublicFileDownloadUrl(fileId, 0); + } else { + downloadUrl = generateFileDownloadUrl(fileId, 0); + } } setUrl(downloadUrl); }, [visualizationId, fileId]); diff --git a/frontend/src/components/visualizations/PublicVisualization.tsx b/frontend/src/components/visualizations/PublicVisualization.tsx new file mode 100644 index 000000000..e42e7827c --- /dev/null +++ b/frontend/src/components/visualizations/PublicVisualization.tsx @@ -0,0 +1,183 @@ +import React, { Suspense, useEffect, useState } from "react"; +import { LazyLoadErrorBoundary } from "../errors/LazyLoadErrorBoundary"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "../../types/data"; +import { fetchFileSummary } from "../../actions/file"; +import { getVisConfig as getVisConfigAction } from "../../actions/visualization"; +import {getPublicVisConfig as getPublicVisConfigAction} from "../../actions/public_visualization"; +import { visComponentDefinitions } from "../../visualization.config"; +import { Grid } from "@mui/material"; +import { PublicVisualizationRawBytesCard } from "./PublicVisualizationRawBytesCard"; +import { PublicVisualizationSpecCard } from "./PublicVisualizationSpecCard"; +import config from "../../app.config"; +import {fetchPublicFileSummary} from "../../actions/public_file"; +import {PublicVisualizationCard} from "./PublicVisualizationCard"; + +type previewProps = { + fileId?: string; + datasetId?: string; +}; + +export const PublicVisualization = (props: previewProps) => { + const { fileId, datasetId } = props; + //const flag = false; + const [isEmptyVisData, setIsEmptyVisData] = useState(false); + const [isVisDataGreaterThanMaxSize, setIsVisDataGreaterThanMaxSize] = + useState(false); + const [isRawDataSupported, setIsRawDataSupported] = useState(false); + + const fileSummary = useSelector((state: RootState) => state.publicFile.publicFileSummary); + const visConfig = useSelector( + (state: RootState) => state.publicVisualization.publicVisConfig + ); + + const dispatch = useDispatch(); + const listPublicFileSummary = (fileId: string | undefined) => + dispatch(fetchPublicFileSummary(fileId)); + + const getPublicVisConfig = (resourceId: string | undefined) => + dispatch(getPublicVisConfigAction(resourceId)); + + useEffect(() => { + if (fileId !== undefined) { + listPublicFileSummary(fileId); + getPublicVisConfig(fileId); + } + + if (datasetId !== undefined) { + getPublicVisConfig(datasetId); + } + }, [fileId, datasetId]); + // Check for conditions and set state only once + useEffect(() => { + const supportedMimeType = visComponentDefinitions.reduce( + (acc, visComponentDefinition) => { + // @ts-ignore + if (!acc.includes(visComponentDefinition.mainType)) { + // @ts-ignore + acc.push(visComponentDefinition.mainType); + } + return acc; + }, + [] + ); + // if raw type supported + if ( + fileSummary&& + ((fileSummary.content_type && fileSummary.content_type.content_type !== undefined && + // @ts-ignore + supportedMimeType.includes(fileSummary.content_type.content_type)) || + (fileSummary.content_type && fileSummary.content_type.main_type !== undefined && + // @ts-ignore + supportedMimeType.includes(fileSummary.content_type.main_type))) + ) { + setIsRawDataSupported(true); + } else { + setIsRawDataSupported(false); + } + + if (fileSummary && + fileSummary.bytes && fileSummary.bytes >= config["rawDataVisualizationThreshold"]) { + setIsVisDataGreaterThanMaxSize(true); + } else { + setIsVisDataGreaterThanMaxSize(false); + } + + setIsEmptyVisData(visConfig.length === 0); + }, [fileSummary, visConfig]); + + return ( + + {isEmptyVisData && !isRawDataSupported? ( +
+ No visualization data or parameters available. Incomplete + visualization configuration. +
+ ) : ( + <> + )} + {isEmptyVisData && isRawDataSupported && isVisDataGreaterThanMaxSize ? ( +
File is greater than threshold
+ ) : ( + <> + )} + {/* 1. load all the visualization components and its definition available to the frontend */} + {visComponentDefinitions.map((visComponentDefinition) => { + return ( + Fail to load...}> + Loading...}> + {(() => { + // 2. looking for visualization configuration registered for this resource + if (visConfig.length > 0) { + return visConfig.map((visConfigEntry) => { + // instantiate the matching visualization component if documented + // in configuration + const componentName = + visConfigEntry.visualization_component_id; + if (componentName === visComponentDefinition.name) { + // use visualization data if available + if ( + visConfigEntry.visualization_data && + visConfigEntry.visualization_data?.length > 0 + ) { + return visConfigEntry.visualization_data.map( + (visualizationDataItem) => { + return ( + + ); + } + ); + } else { + // use visualization parameters if available + if (Object.keys(visConfigEntry.parameters).length > 0) { + return ( + + ); + } + } + } + }); + } + // if no visualization config exist, guess which widget to use by looking at the mime type of + // the raw bytes + else { + //to make sure file size is less than threshold + if ( + fileSummary && + fileSummary.bytes && + fileSummary.bytes < + config["rawDataVisualizationThreshold"] && + fileSummary.content_type !== undefined && + ((fileSummary.content_type.content_type !== undefined && + visComponentDefinition.mimeTypes.includes( + fileSummary.content_type.content_type + )) || + (fileSummary.content_type.content_type === undefined && + fileSummary.content_type.main_type !== undefined && + fileSummary.content_type.main_type === + visComponentDefinition.mainType)) + ) { + return ( + + ); + } else { + return null; + } + } + })()} + + + ); + })} +
+ ); +}; diff --git a/frontend/src/components/visualizations/PublicVisualizationCard.tsx b/frontend/src/components/visualizations/PublicVisualizationCard.tsx new file mode 100644 index 000000000..4d3129ad1 --- /dev/null +++ b/frontend/src/components/visualizations/PublicVisualizationCard.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { Button, Card, CardActions, CardContent, Grid } from "@mui/material"; +import Collapse from "@mui/material/Collapse"; +import { VisualizationDataDetail } from "./VisualizationDataDetail"; +import { VisualizationDataOut } from "../../openapi/v2"; +import { VisComponentDefinitions } from "../../visualization.config"; +import { PresignedUrlShareModal } from "../sharing/PresignedUrlShareModal"; +import { useDispatch, useSelector } from "react-redux"; +import { + generateVisPresignedUrl as generateVisPresignedUrlAction, + RESET_VIS_DATA_PRESIGNED_URL, +} from "../../actions/visualization"; +import { RootState } from "../../types/data"; +import {RESET_PUBLIC_VIS_DATA_PRESIGNED_URL} from "../../actions/public_visualization"; + +type publicPreviewProps = { + publicVisComponentDefinition: VisComponentDefinitions; + publicVisualizationDataItem: VisualizationDataOut; +}; + +export const PublicVisualizationCard = (props: publicPreviewProps) => { + const { publicVisComponentDefinition, publicVisualizationDataItem } = props; + const [expanded, setExpanded] = React.useState(false); + const [visShareModalOpen, setVisShareModalOpen] = useState(false); + // share visualization + const dispatch = useDispatch(); + const generateVisPresignedUrl = ( + visualizationId: string | undefined, + expiresInSeconds: number | undefined + ) => + dispatch(generateVisPresignedUrlAction(visualizationId, expiresInSeconds)); + const presignedUrl = useSelector( + (state: RootState) => state.publicVisualization.publicPresignedUrl + ); + + const handleExpandClick = () => { + setExpanded(!expanded); + }; + + const handleShareLinkClick = () => { + generateVisPresignedUrl(publicVisualizationDataItem.id, 7 * 24 * 3600); + setVisShareModalOpen(true); + }; + const setVisShareModalClose = () => { + setVisShareModalOpen(false); + dispatch({ type: RESET_PUBLIC_VIS_DATA_PRESIGNED_URL }); + }; + + return ( + + + + + + {React.cloneElement(publicVisComponentDefinition.component, { + visualizationId: publicVisualizationDataItem.id, + })} + + + + {!expanded ? ( + + ) : ( + + )} + + + + + + + + + + ); +}; diff --git a/frontend/src/components/visualizations/PublicVisualizationRawBytesCard.tsx b/frontend/src/components/visualizations/PublicVisualizationRawBytesCard.tsx new file mode 100644 index 000000000..7cda98ff2 --- /dev/null +++ b/frontend/src/components/visualizations/PublicVisualizationRawBytesCard.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Card, CardContent, Grid } from "@mui/material"; +import { VisComponentDefinitions } from "../../visualization.config"; + +type publicPreviewProps = { + fileId?: string; + visComponentDefinition: VisComponentDefinitions; +}; + +export const PublicVisualizationRawBytesCard = (props: publicPreviewProps) => { + const { visComponentDefinition, fileId } = props; + return ( + + + + {React.cloneElement(visComponentDefinition.component, { + fileId: fileId, publicView: true, + })} + + + + ); +}; diff --git a/frontend/src/components/visualizations/PublicVisualizationSpecCard.tsx b/frontend/src/components/visualizations/PublicVisualizationSpecCard.tsx new file mode 100644 index 000000000..d034ba821 --- /dev/null +++ b/frontend/src/components/visualizations/PublicVisualizationSpecCard.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Button, Card, CardActions, CardContent, Grid } from "@mui/material"; +import Collapse from "@mui/material/Collapse"; +import { VisualizationConfigOut } from "../../openapi/v2"; +import { VisComponentDefinitions } from "../../visualization.config"; +import { VisualizationParamDetail } from "./VisualizationParamDetail"; + +type publicPreviewProps = { + visComponentDefinition: VisComponentDefinitions; + visConfigEntry: VisualizationConfigOut; +}; + +export const PublicVisualizationSpecCard = (props: publicPreviewProps) => { + const { visComponentDefinition, visConfigEntry } = props; + const [expanded, setExpanded] = React.useState(false); + const handleExpandClick = () => { + setExpanded(!expanded); + }; + return ( + + + + + {React.cloneElement(visComponentDefinition.component, { + visConfigEntry: visConfigEntry, + })} + + + + {!expanded ? ( + + ) : ( + + )} + + + + + + + + + ); +}; diff --git a/frontend/src/components/visualizations/Text/Text.tsx b/frontend/src/components/visualizations/Text/Text.tsx index 9f4457b34..e998741f5 100644 --- a/frontend/src/components/visualizations/Text/Text.tsx +++ b/frontend/src/components/visualizations/Text/Text.tsx @@ -2,25 +2,38 @@ import React, { useEffect, useState } from "react"; import ShowMoreText from "react-show-more-text"; import { readTextFromFile } from "../../../utils/common"; -import { downloadVisData, fileDownloaded } from "../../../utils/visualization"; +import { downloadVisData, publicFileDownloaded, fileDownloaded } from "../../../utils/visualization"; +import {downloadPublicVisData} from "../../../actions/public_visualization"; type TextProps = { fileId?: string; visualizationId?: string; + publicView?: boolean; }; export default function Text(props: TextProps) { - const { fileId, visualizationId } = props; + const { fileId, visualizationId, publicView } = props; const [text, setText] = useState(""); - useEffect(() => { const processBlob = async () => { try { let blob; if (visualizationId) { - blob = await downloadVisData(visualizationId); + if (publicView){ + blob = await downloadPublicVisData(visualizationId); + } else { + blob = await downloadVisData(visualizationId); + } } else { - blob = await fileDownloaded(fileId, 0); + if (publicView){ + console.log('public file download blob'); + blob = await publicFileDownloaded(fileId); + console.log(blob); + } else { + console.log('downloading blob here'); + blob = await fileDownloaded(fileId); + console.log(blob); + } } const file = new File([blob], "text.tmp"); const text = await readTextFromFile(file); diff --git a/frontend/src/components/visualizations/VegaSpec/VegaSpec.tsx b/frontend/src/components/visualizations/VegaSpec/VegaSpec.tsx index 20881b5e5..c6c28306f 100644 --- a/frontend/src/components/visualizations/VegaSpec/VegaSpec.tsx +++ b/frontend/src/components/visualizations/VegaSpec/VegaSpec.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useState } from "react"; import { VegaLite } from "react-vega"; import { downloadVisData, fileDownloaded } from "../../../utils/visualization"; -import { +import {downloadPublicVisData} from "../../../actions/public_visualization"; +import {filePublicDownloaded} from "../../../actions/public_file"; + + import { guessDataType, parseTextToJson, readTextFromFile, @@ -13,6 +16,7 @@ import { theme } from "../../../theme"; type TextProps = { fileId?: string; visualizationId?: string; + publicView?: boolean | false; }; const allowedType = [ @@ -24,7 +28,7 @@ const allowedType = [ ]; export default function VegaSpec(props: TextProps) { - let { fileId, visualizationId } = props; + let { fileId, visualizationId, publicView } = props; const [data, setData] = useState(); useEffect(() => { @@ -32,9 +36,17 @@ export default function VegaSpec(props: TextProps) { try { let blob; if (visualizationId) { - blob = await downloadVisData(visualizationId); + if (publicView){ + blob = await downloadPublicVisData(visualizationId); + } else { + blob = await downloadVisData(visualizationId); + } } else { - blob = await fileDownloaded(fileId, 0); + if (publicView){ + blob = await filePublicDownloaded(fileId); + } else { + blob = await fileDownloaded(fileId, 0); + } } const file = new File([blob], "text.tmp"); const reader = new FileReader(); diff --git a/frontend/src/components/visualizations/Visualization.tsx b/frontend/src/components/visualizations/Visualization.tsx index 8a96fcb74..dd9dc0dc1 100644 --- a/frontend/src/components/visualizations/Visualization.tsx +++ b/frontend/src/components/visualizations/Visualization.tsx @@ -46,7 +46,6 @@ export const Visualization = (props: previewProps) => { getVisConfig(datasetId); } }, [fileId, datasetId]); - // Check for conditions and set state only once useEffect(() => { const supportedMimeType = visComponentDefinitions.reduce( diff --git a/frontend/src/openapi/v2/index.ts b/frontend/src/openapi/v2/index.ts index e08177cf2..a75993a5a 100644 --- a/frontend/src/openapi/v2/index.ts +++ b/frontend/src/openapi/v2/index.ts @@ -12,7 +12,9 @@ export type { Body_add_thumbnail_api_v2_thumbnails_post } from './models/Body_ad export type { Body_add_Visualization_api_v2_visualizations_post } from './models/Body_add_Visualization_api_v2_visualizations_post'; export type { Body_create_dataset_from_zip_api_v2_datasets_createFromZip_post } from './models/Body_create_dataset_from_zip_api_v2_datasets_createFromZip_post'; export type { Body_get_dataset_metadata_api_v2_datasets__dataset_id__metadata_get } from './models/Body_get_dataset_metadata_api_v2_datasets__dataset_id__metadata_get'; +export type { Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get } from './models/Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get'; export type { Body_get_file_metadata_api_v2_files__file_id__metadata_get } from './models/Body_get_file_metadata_api_v2_files__file_id__metadata_get'; +export type { Body_get_file_metadata_api_v2_public_files__file_id__metadata_get } from './models/Body_get_file_metadata_api_v2_public_files__file_id__metadata_get'; export type { Body_save_file_api_v2_datasets__dataset_id__files_post } from './models/Body_save_file_api_v2_datasets__dataset_id__files_post'; export type { Body_save_files_api_v2_datasets__dataset_id__filesMultiple_post } from './models/Body_save_files_api_v2_datasets__dataset_id__filesMultiple_post'; export type { Body_update_file_api_v2_files__file_id__put } from './models/Body_update_file_api_v2_files__file_id__put'; @@ -84,6 +86,12 @@ export { JobsService } from './services/JobsService'; export { ListenersService } from './services/ListenersService'; export { LoginService } from './services/LoginService'; export { MetadataService } from './services/MetadataService'; +export { PublicDatasetsService } from './services/PublicDatasetsService'; +export { PublicElasticsearchService } from './services/PublicElasticsearchService'; +export { PublicFilesService } from './services/PublicFilesService'; +export { PublicFoldersService } from './services/PublicFoldersService'; +export { PublicMetadataService } from './services/PublicMetadataService'; +export { PublicVisualizationsService } from './services/PublicVisualizationsService'; export { ServiceService } from './services/ServiceService'; export { StatusService } from './services/StatusService'; export { ThumbnailsService } from './services/ThumbnailsService'; diff --git a/frontend/src/openapi/v2/models/Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get.ts b/frontend/src/openapi/v2/models/Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get.ts new file mode 100644 index 000000000..3fc078736 --- /dev/null +++ b/frontend/src/openapi/v2/models/Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get = { + listener_name?: string; + listener_version?: number; +} diff --git a/frontend/src/openapi/v2/models/Body_get_file_metadata_api_v2_public_files__file_id__metadata_get.ts b/frontend/src/openapi/v2/models/Body_get_file_metadata_api_v2_public_files__file_id__metadata_get.ts new file mode 100644 index 000000000..851062e35 --- /dev/null +++ b/frontend/src/openapi/v2/models/Body_get_file_metadata_api_v2_public_files__file_id__metadata_get.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type Body_get_file_metadata_api_v2_public_files__file_id__metadata_get = { + definition?: string; + listener_name?: string; + listener_version?: number; +} diff --git a/frontend/src/openapi/v2/models/DatasetBase.ts b/frontend/src/openapi/v2/models/DatasetBase.ts index c999b35a2..b160cbb5c 100644 --- a/frontend/src/openapi/v2/models/DatasetBase.ts +++ b/frontend/src/openapi/v2/models/DatasetBase.ts @@ -5,4 +5,5 @@ export type DatasetBase = { name?: string; description?: string; + status?: string; } diff --git a/frontend/src/openapi/v2/models/DatasetIn.ts b/frontend/src/openapi/v2/models/DatasetIn.ts index 58af32a31..4a9600127 100644 --- a/frontend/src/openapi/v2/models/DatasetIn.ts +++ b/frontend/src/openapi/v2/models/DatasetIn.ts @@ -5,4 +5,5 @@ export type DatasetIn = { name?: string; description?: string; + status?: string; } diff --git a/frontend/src/openapi/v2/models/DatasetOut.ts b/frontend/src/openapi/v2/models/DatasetOut.ts index b6057ed51..30c691705 100644 --- a/frontend/src/openapi/v2/models/DatasetOut.ts +++ b/frontend/src/openapi/v2/models/DatasetOut.ts @@ -20,11 +20,11 @@ import type { UserOut } from './UserOut'; export type DatasetOut = { name?: string; description?: string; + status?: string; id?: string; creator: UserOut; created?: string; modified?: string; - status?: string; user_views?: number; downloads?: number; thumbnail_id?: string; diff --git a/frontend/src/openapi/v2/models/FileOut.ts b/frontend/src/openapi/v2/models/FileOut.ts index 51459f4b1..2c998e4eb 100644 --- a/frontend/src/openapi/v2/models/FileOut.ts +++ b/frontend/src/openapi/v2/models/FileOut.ts @@ -21,6 +21,7 @@ import type { UserOut } from './UserOut'; */ export type FileOut = { name?: string; + status?: string; id?: string; creator: UserOut; created?: string; diff --git a/frontend/src/openapi/v2/services/PublicDatasetsService.ts b/frontend/src/openapi/v2/services/PublicDatasetsService.ts new file mode 100644 index 000000000..ac480c247 --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicDatasetsService.ts @@ -0,0 +1,154 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get } from '../models/Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get'; +import type { DatasetOut } from '../models/DatasetOut'; +import type { FileOut } from '../models/FileOut'; +import type { FolderOut } from '../models/FolderOut'; +import type { MetadataOut } from '../models/MetadataOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicDatasetsService { + + /** + * Get Datasets + * @param skip + * @param limit + * @returns DatasetOut Successful Response + * @throws ApiError + */ + public static getDatasetsApiV2PublicDatasetsGet( + skip?: number, + limit: number = 10, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets`, + query: { + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Dataset + * @param datasetId + * @returns DatasetOut Successful Response + * @throws ApiError + */ + public static getDatasetApiV2PublicDatasetsDatasetIdGet( + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets/${datasetId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Dataset Files + * @param datasetId + * @param folderId + * @param skip + * @param limit + * @returns FileOut Successful Response + * @throws ApiError + */ + public static getDatasetFilesApiV2PublicDatasetsDatasetIdFilesGet( + datasetId: string, + folderId?: string, + skip?: number, + limit: number = 10, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets/${datasetId}/files`, + query: { + 'folder_id': folderId, + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Dataset Folders + * @param datasetId + * @param parentFolder + * @param skip + * @param limit + * @returns FolderOut Successful Response + * @throws ApiError + */ + public static getDatasetFoldersApiV2PublicDatasetsDatasetIdFoldersGet( + datasetId: string, + parentFolder?: string, + skip?: number, + limit: number = 10, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets/${datasetId}/folders`, + query: { + 'parent_folder': parentFolder, + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Dataset Metadata + * @param datasetId + * @param formData + * @returns MetadataOut Successful Response + * @throws ApiError + */ + public static getDatasetMetadataApiV2PublicDatasetsDatasetIdMetadataGet( + datasetId: string, + formData?: Body_get_dataset_metadata_api_v2_public_datasets__dataset_id__metadata_get, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets/${datasetId}/metadata`, + formData: formData, + mediaType: 'application/x-www-form-urlencoded', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Download Dataset + * @param datasetId + * @returns DatasetOut Successful Response + * @throws ApiError + */ + public static downloadDatasetApiV2PublicDatasetsDatasetIdDownloadGet( + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_datasets/${datasetId}/download`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/openapi/v2/services/PublicElasticsearchService.ts b/frontend/src/openapi/v2/services/PublicElasticsearchService.ts new file mode 100644 index 000000000..7bf2b97f0 --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicElasticsearchService.ts @@ -0,0 +1,45 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicElasticsearchService { + + /** + * Search + * @param indexName + * @param query + * @returns string Successful Response + * @throws ApiError + */ + public static searchApiV2PublicElasticsearchSearchPut( + indexName: string, + query: string, + ): CancelablePromise { + return __request({ + method: 'PUT', + path: `/api/v2/public_elasticsearch/search`, + query: { + 'index_name': indexName, + 'query': query, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Msearch + * @returns any Successful Response + * @throws ApiError + */ + public static msearchApiV2PublicElasticsearchAllMsearchPost(): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/public_elasticsearch/all/_msearch`, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/openapi/v2/services/PublicFilesService.ts b/frontend/src/openapi/v2/services/PublicFilesService.ts new file mode 100644 index 000000000..58492c15c --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicFilesService.ts @@ -0,0 +1,155 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Body_get_file_metadata_api_v2_public_files__file_id__metadata_get } from '../models/Body_get_file_metadata_api_v2_public_files__file_id__metadata_get'; +import type { FileOut } from '../models/FileOut'; +import type { FileVersion } from '../models/FileVersion'; +import type { MetadataOut } from '../models/MetadataOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicFilesService { + + /** + * Get File Summary + * @param fileId + * @returns FileOut Successful Response + * @throws ApiError + */ + public static getFileSummaryApiV2PublicFilesFileIdSummaryGet( + fileId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}/summary`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get File Version Details + * @param fileId + * @param versionNum + * @returns FileOut Successful Response + * @throws ApiError + */ + public static getFileVersionDetailsApiV2PublicFilesFileIdVersionDetailsGet( + fileId: string, + versionNum?: number, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}/version_details`, + query: { + 'version_num': versionNum, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get File Versions + * @param fileId + * @param skip + * @param limit + * @returns FileVersion Successful Response + * @throws ApiError + */ + public static getFileVersionsApiV2PublicFilesFileIdVersionsGet( + fileId: string, + skip?: number, + limit: number = 20, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}/versions`, + query: { + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Download File + * @param fileId + * @param version + * @param increment + * @returns any Successful Response + * @throws ApiError + */ + public static downloadFileApiV2PublicFilesFileIdGet( + fileId: string, + version?: number, + increment: boolean = true, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}`, + query: { + 'version': version, + 'increment': increment, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Download File Thumbnail + * @param fileId + * @returns any Successful Response + * @throws ApiError + */ + public static downloadFileThumbnailApiV2PublicFilesFileIdThumbnailGet( + fileId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}/thumbnail`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get File Metadata + * Get file metadata. + * @param fileId + * @param version + * @param allVersions + * @param formData + * @returns MetadataOut Successful Response + * @throws ApiError + */ + public static getFileMetadataApiV2PublicFilesFileIdMetadataGet( + fileId: string, + version?: number, + allVersions: boolean = false, + formData?: Body_get_file_metadata_api_v2_public_files__file_id__metadata_get, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_files/${fileId}/metadata`, + query: { + 'version': version, + 'all_versions': allVersions, + }, + formData: formData, + mediaType: 'application/x-www-form-urlencoded', + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/openapi/v2/services/PublicFoldersService.ts b/frontend/src/openapi/v2/services/PublicFoldersService.ts new file mode 100644 index 000000000..4f97d32cf --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicFoldersService.ts @@ -0,0 +1,27 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicFoldersService { + + /** + * Download Folder + * @param folderId + * @returns any Successful Response + * @throws ApiError + */ + public static downloadFolderApiV2PublicFoldersFolderIdPathGet( + folderId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_folders/${folderId}/path`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/openapi/v2/services/PublicMetadataService.ts b/frontend/src/openapi/v2/services/PublicMetadataService.ts new file mode 100644 index 000000000..4d0c7dd94 --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicMetadataService.ts @@ -0,0 +1,87 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { MetadataDefinitionOut } from '../models/MetadataDefinitionOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicMetadataService { + + /** + * Get Metadata Definition List + * @param name + * @param skip + * @param limit + * @returns MetadataDefinitionOut Successful Response + * @throws ApiError + */ + public static getMetadataDefinitionListApiV2PublicMetadataDefinitionGet( + name?: string, + skip?: number, + limit: number = 2, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_metadata/definition`, + query: { + 'name': name, + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Metadata Definition + * @param metadataDefinitionId + * @returns MetadataDefinitionOut Successful Response + * @throws ApiError + */ + public static getMetadataDefinitionApiV2PublicMetadataDefinitionMetadataDefinitionIdGet( + metadataDefinitionId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_metadata/definition/${metadataDefinitionId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Search Metadata Definition + * Search all metadata definition in the db based on text. + * + * Arguments: + * text -- any text matching name or description + * skip -- number of initial records to skip (i.e. for pagination) + * limit -- restrict number of records to be returned (i.e. for pagination) + * @param searchTerm + * @param skip + * @param limit + * @returns MetadataDefinitionOut Successful Response + * @throws ApiError + */ + public static searchMetadataDefinitionApiV2PublicMetadataDefinitionSearchSearchTermGet( + searchTerm: string, + skip?: number, + limit: number = 10, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_metadata/definition/search/${searchTerm}`, + query: { + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/openapi/v2/services/PublicVisualizationsService.ts b/frontend/src/openapi/v2/services/PublicVisualizationsService.ts new file mode 100644 index 000000000..04d6b2399 --- /dev/null +++ b/frontend/src/openapi/v2/services/PublicVisualizationsService.ts @@ -0,0 +1,124 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { VisualizationConfigOut } from '../models/VisualizationConfigOut'; +import type { VisualizationDataOut } from '../models/VisualizationDataOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class PublicVisualizationsService { + + /** + * Get Visualization + * @param visualizationId + * @returns VisualizationDataOut Successful Response + * @throws ApiError + */ + public static getVisualizationApiV2PublicVisualizationsVisualizationIdGet( + visualizationId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/${visualizationId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Download Visualization + * @param visualizationId + * @returns any Successful Response + * @throws ApiError + */ + public static downloadVisualizationApiV2PublicVisualizationsVisualizationIdBytesGet( + visualizationId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/${visualizationId}/bytes`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Download Visualization Url + * @param visualizationId + * @param expiresInSeconds + * @returns any Successful Response + * @throws ApiError + */ + public static downloadVisualizationUrlApiV2PublicVisualizationsVisualizationIdUrlGet( + visualizationId: string, + expiresInSeconds: number = 3600, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/${visualizationId}/url/`, + query: { + 'expires_in_seconds': expiresInSeconds, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Resource Visconfig + * @param resourceId + * @returns VisualizationConfigOut Successful Response + * @throws ApiError + */ + public static getResourceVisconfigApiV2PublicVisualizationsResourceIdConfigGet( + resourceId: string, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/${resourceId}/config`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Visconfig + * @param configId + * @returns VisualizationConfigOut Successful Response + * @throws ApiError + */ + public static getVisconfigApiV2PublicVisualizationsConfigConfigIdGet( + configId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/config/${configId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Visdata From Visconfig + * @param configId + * @returns VisualizationDataOut Successful Response + * @throws ApiError + */ + public static getVisdataFromVisconfigApiV2PublicVisualizationsConfigConfigIdVisdataGet( + configId: string, + ): CancelablePromise> { + return __request({ + method: 'GET', + path: `/api/v2/public_visualizations/config/${configId}/visdata`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/reducers/dataset.ts b/frontend/src/reducers/dataset.ts index f45c4bc53..7b78f785f 100644 --- a/frontend/src/reducers/dataset.ts +++ b/frontend/src/reducers/dataset.ts @@ -80,7 +80,6 @@ const dataset = (state = defaultState, action: DataAction) => { return Object.assign({}, state, { about: action.about }); case RECEIVE_DATASET_ROLE: return Object.assign({}, state, { datasetRole: action.role }); - return Object.assign({}, state, { datasetRole: action.role }); case RECEIVE_DATASET_ROLES: return Object.assign({}, state, { roles: action.roles }); case UPDATE_DATASET: diff --git a/frontend/src/reducers/folder.ts b/frontend/src/reducers/folder.ts index 81f818a3c..c00c8ac56 100644 --- a/frontend/src/reducers/folder.ts +++ b/frontend/src/reducers/folder.ts @@ -1,11 +1,14 @@ -import {FOLDER_DELETED, FOLDER_ADDED, GET_FOLDER_PATH} from "../actions/folder"; +import {FOLDER_DELETED, FOLDER_ADDED, GET_FOLDER_PATH, GET_PUBLIC_FOLDER_PATH} from "../actions/folder"; import {DataAction} from "../types/action"; import {FolderState} from "../types/data"; import {RECEIVE_FOLDERS_IN_DATASET} from "../actions/dataset"; +import {RECEIVE_FOLDERS_IN_PUBLIC_DATASET} from "../actions/public_dataset"; const defaultState: FolderState = { folders: [], - folderPath: [] + folderPath: [], + publicFolders: [], + publicFolderPath: [], }; const folder = (state = defaultState, action: DataAction) => { @@ -22,8 +25,14 @@ const folder = (state = defaultState, action: DataAction) => { return Object.assign({}, state, { folderPath: action.folderPath }); + case GET_PUBLIC_FOLDER_PATH: + return Object.assign({}, state, { + publicFolderPath: action.publicFolderPath + }); case RECEIVE_FOLDERS_IN_DATASET: return Object.assign({}, state, {folders: action.folders}); + case RECEIVE_FOLDERS_IN_PUBLIC_DATASET: + return Object.assign({}, state, {publicFolders: action.publicFolders}); default: return state; } diff --git a/frontend/src/reducers/index.ts b/frontend/src/reducers/index.ts index c71b07f1e..30f81c161 100644 --- a/frontend/src/reducers/index.ts +++ b/frontend/src/reducers/index.ts @@ -1,6 +1,8 @@ import { combineReducers } from "redux"; import file from "./file"; import dataset from "./dataset"; +import publicDataset from "./public_dataset"; +import publicFile from "./public_file"; import folder from "./folder"; import user from "./user"; import error from "./error"; @@ -8,10 +10,13 @@ import metadata from "./metadata"; import listeners from "./listeners"; import group from "./group"; import visualization from "./visualization"; +import publicVisualization from "./public_visualization"; const rootReducer = combineReducers({ file: file, dataset: dataset, + publicDataset: publicDataset, + publicFile: publicFile, folder: folder, user: user, error: error, @@ -19,6 +24,7 @@ const rootReducer = combineReducers({ listener: listeners, group: group, visualization: visualization, + publicVisualization: publicVisualization, }); export default rootReducer; diff --git a/frontend/src/reducers/metadata.ts b/frontend/src/reducers/metadata.ts index 8a753e87b..0b346ce64 100644 --- a/frontend/src/reducers/metadata.ts +++ b/frontend/src/reducers/metadata.ts @@ -5,9 +5,12 @@ import { POST_DATASET_METADATA, POST_FILE_METADATA, RECEIVE_DATASET_METADATA, + RECEIVE_PUBLIC_DATASET_METADATA, RECEIVE_FILE_METADATA, + RECEIVE_PUBLIC_FILE_METADATA, RECEIVE_METADATA_DEFINITION, RECEIVE_METADATA_DEFINITIONS, + RECEIVE_PUBLIC_METADATA_DEFINITIONS, SAVE_METADATA_DEFINITIONS, SEARCH_METADATA_DEFINITIONS, UPDATE_DATASET_METADATA, @@ -19,8 +22,11 @@ import { MetadataDefinitionOut } from "../openapi/v2/"; const defaultState: MetadataState = { datasetMetadataList: [], + publicDatasetMetadataList: [], fileMetadataList: [], + publicFileMetadataList: [], metadataDefinitionList: [], + publicMetadataDefinitionList: [], metadataDefinition: {}, }; @@ -30,6 +36,10 @@ const metadata = (state = defaultState, action: DataAction) => { return Object.assign({}, state, { metadataDefinitionList: action.metadataDefinitionList, }); + case RECEIVE_PUBLIC_METADATA_DEFINITIONS: + return Object.assign({}, state, { + publicMetadataDefinitionList: action.publicMetadataDefinitionList, + }); case RECEIVE_METADATA_DEFINITION: return Object.assign({}, state, { metadataDefinition: action.metadataDefinition, @@ -56,10 +66,18 @@ const metadata = (state = defaultState, action: DataAction) => { return Object.assign({}, state, { datasetMetadataList: action.metadataList, }); + case RECEIVE_PUBLIC_DATASET_METADATA: + return Object.assign({}, state, { + publicDatasetMetadataList: action.publicDatasetMetadataList, + }); case RECEIVE_FILE_METADATA: return Object.assign({}, state, { fileMetadataList: action.metadataList, }); + case RECEIVE_PUBLIC_FILE_METADATA: + return Object.assign({}, state, { + publicFileMetadataList: action.publicFileMetadataList, + }); case UPDATE_DATASET_METADATA: return Object.assign({}, state, { datasetMetadataList: state.datasetMetadataList.map((dm) => { diff --git a/frontend/src/reducers/public_dataset.ts b/frontend/src/reducers/public_dataset.ts new file mode 100644 index 000000000..ef974d8fd --- /dev/null +++ b/frontend/src/reducers/public_dataset.ts @@ -0,0 +1,41 @@ +import { + RECEIVE_PUBLIC_DATASET_ABOUT, + RECEIVE_PUBLIC_DATASETS, + RECEIVE_FILES_IN_PUBLIC_DATASET, + RECEIVE_FOLDERS_IN_PUBLIC_DATASET +} from "../actions/public_dataset"; +import { DataAction } from "../types/action"; +import { PublicDatasetState } from "../types/data"; +import { + AuthorizationBase, + DatasetOut as Dataset, + DatasetRoles, + FileOut as File, + UserOut, +} from "../openapi/v2"; + +const defaultState: PublicDatasetState = { + public_files: [], + public_about: { creator: {} }, + public_datasetRole: {}, + public_datasets: [], + public_newDataset: {}, + public_newFile: {}, + public_newFiles: [], + public_roles: {}, +}; + +const publicDataset = (state = defaultState, action: DataAction) => { + switch (action.type) { + case RECEIVE_FILES_IN_PUBLIC_DATASET: + return Object.assign({}, state, { public_files: action.public_files }); + case RECEIVE_PUBLIC_DATASET_ABOUT: + return Object.assign({}, state, { public_about: action.public_about }); + case RECEIVE_PUBLIC_DATASETS: + return Object.assign({}, state, { public_datasets: action.public_datasets }); + default: + return state; + } +}; + +export default publicDataset; diff --git a/frontend/src/reducers/public_file.ts b/frontend/src/reducers/public_file.ts new file mode 100644 index 000000000..fcc8b8760 --- /dev/null +++ b/frontend/src/reducers/public_file.ts @@ -0,0 +1,50 @@ +import { + DOWNLOAD_PUBLIC_FILE, + RECEIVE_PUBLIC_FILE_EXTRACTED_METADATA, + RECEIVE_PUBLIC_FILE_METADATA_JSONLD, + RECEIVE_PUBLIC_FILE_SUMMARY, + RECEIVE_PUBLIC_PREVIEWS, + RECEIVE_PUBLIC_VERSIONS, + CHANGE_PUBLIC_SELECTED_VERSION, +} from "../actions/public_file"; +import { DataAction } from "../types/action"; +import {FileOut as FileSummary } from "../openapi/v2"; +import { PublicFileState } from "../types/data"; + +const defaultState: PublicFileState = { + publicFileSummary: {}, + publicExtractedMetadata: [], + publicMetadataJsonld: [], + publicPreviews: [], + publicFileVersions: [], + publicBlob: new Blob([]), + publicSelected_version_num:1, +}; + +const publicFile = (state = defaultState, action: DataAction) => { + switch (action.type) { + case RECEIVE_PUBLIC_FILE_SUMMARY: + return Object.assign({}, state, { publicFileSummary: action.publicFileSummary }); + case RECEIVE_PUBLIC_FILE_EXTRACTED_METADATA: + return Object.assign({}, state, { + publicExtractedMetadata: action.publicExtractedMetadata, + }); + case RECEIVE_PUBLIC_FILE_METADATA_JSONLD: + return Object.assign({}, state, { + publicMetadataJsonld: action.publicMetadataJsonld, + }); + case RECEIVE_PUBLIC_PREVIEWS: + return Object.assign({}, state, { publicPreviews: action.publicPreviews }); + case CHANGE_PUBLIC_SELECTED_VERSION: + return Object.assign({}, state,{publicSelected_version_num:action.publicSelected_version_num}); + case RECEIVE_PUBLIC_VERSIONS: + return Object.assign({}, state, { publicFileVersions: action.publicFileVersions }); + case DOWNLOAD_PUBLIC_FILE: + // TODO do nothing for now; but in the future can utilize to display certain effects + return Object.assign({}, state, { publicBlob: action.publicBlob }); + default: + return state; + } +}; + +export default publicFile; diff --git a/frontend/src/reducers/public_visualization.ts b/frontend/src/reducers/public_visualization.ts new file mode 100644 index 000000000..1673a82c0 --- /dev/null +++ b/frontend/src/reducers/public_visualization.ts @@ -0,0 +1,36 @@ +import { DataAction } from "../types/action"; +import { VisualizationConfigOut, VisualizationDataOut } from "../openapi/v2"; +import { PublicVisualizationState } from "../types/data"; +import { + DOWNLOAD_PUBLIC_VIS_DATA, + GET_PUBLIC_VIS_CONFIG, + GET_PUBLIC_VIS_DATA, + GET_PUBLIC_VIS_DATA_PRESIGNED_URL, + RESET_PUBLIC_VIS_DATA_PRESIGNED_URL, +} from "../actions/public_visualization"; + +const defaultState: PublicVisualizationState = { + publicVisData: {}, + publicVisConfig: [], + publicPresignedUrl: "", + publicBlob: new Blob([]), +}; + +const publicVisualization = (state = defaultState, action: DataAction) => { + switch (action.type) { + case GET_PUBLIC_VIS_DATA: + return Object.assign({}, state, { publicVisData: action.publicVisData }); + case GET_PUBLIC_VIS_CONFIG: + return Object.assign({}, state, { publicVisConfig: action.publicVisConfig }); + case DOWNLOAD_PUBLIC_VIS_DATA: + return Object.assign({}, state, { publicBlob: action.publicBlob }); + case GET_PUBLIC_VIS_DATA_PRESIGNED_URL: + return Object.assign({}, state, { publicPresignedUrl: action.publicPresignedUrl }); + case RESET_PUBLIC_VIS_DATA_PRESIGNED_URL: + return Object.assign({}, state, { publicPresignedUrl: "" }); + default: + return state; + } +}; + +export default publicVisualization; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index c461637b7..3bab422a1 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -8,7 +8,9 @@ import { useParams, } from "react-router-dom"; import { Dataset as DatasetComponent } from "./components/datasets/Dataset"; +import {PublicDataset as PublicDatasetComponent} from "./components/datasets/PublicDataset"; import { File as FileComponent } from "./components/files/File"; +import {PublicFile as PublicFileComponent} from "./components/files/PublicFile"; import { CreateDataset } from "./components/datasets/CreateDataset"; import { Groups as GroupListComponent } from "./components/groups/Groups"; import { Group as GroupComponent } from "./components/groups/Group"; @@ -28,6 +30,7 @@ import { resetLogout, } from "./actions/common"; import { Explore } from "./components/Explore"; +import {Public} from "./components/Public"; import { ExtractionHistory } from "./components/listeners/ExtractionHistory"; import { fetchDatasetRole, fetchFileRole } from "./actions/authorization"; import { PageNotFound } from "./components/errors/PageNotFound"; @@ -103,7 +106,7 @@ const PrivateRoute = (props): JSX.Element => { if (fileId && reason === "") listFileRole(fileId); }, [fileId, reason]); - return <>{isAuthorized() ? children : }; + return <>{isAuthorized() ? children : }; }; export const AppRoutes = (): JSX.Element => { @@ -111,13 +114,29 @@ export const AppRoutes = (): JSX.Element => { - - + } /> + {isAuthorized()? + ( + + + + } + /> + ) : + + } + /> + } { } /> + + } + /> { } /> + + } + /> } /> } /> } /> diff --git a/frontend/src/types/action.ts b/frontend/src/types/action.ts index 912aeccc8..36ef6b4a2 100644 --- a/frontend/src/types/action.ts +++ b/frontend/src/types/action.ts @@ -22,6 +22,15 @@ import { VisualizationConfigOut, VisualizationDataOut, } from "../openapi/v2"; +import { + LIST_USERS, + PREFIX_SEARCH_USERS, + RECEIVE_USER_PROFILE, +} from "../actions/user"; +import { CREATE_GROUP, DELETE_GROUP } from "../actions/group"; +import { RECEIVE_FILE_PRESIGNED_URL } from "../actions/file"; +import { GET_VIS_DATA_PRESIGNED_URL } from "../actions/visualization"; +import { GET_PUBLIC_VIS_DATA_PRESIGNED_URL } from "../actions/public_visualization"; interface RECEIVE_FILES_IN_DATASET { type: "RECEIVE_FILES_IN_DATASET"; @@ -58,6 +67,21 @@ interface RECEIVE_DATASETS { datasets: Dataset[]; } +interface RECEIVE_PUBLIC_DATASETS { + type: "RECEIVE_PUBLIC_DATASETS"; + public_datasets: Dataset[]; +} + +interface RECEIVE_PUBLIC_DATASET_ABOUT { + type: "RECEIVE_PUBLIC_DATASET_ABOUT"; + public_about: Dataset; +} + +interface RECEIVE_FILES_IN_PUBLIC_DATASET { + type: "RECEIVE_FILES_IN_PUBLIC_DATASET"; + public_files: FileSummary[]; +} + interface DELETE_DATASET { type: "DELETE_DATASET"; dataset: Dataset; @@ -68,26 +92,51 @@ interface RECEIVE_FILE_EXTRACTED_METADATA { extractedMetadata: ExtractedMetadata[]; } +interface RECEIVE_PUBLIC_FILE_EXTRACTED_METADATA { + type: "RECEIVE_PUBLIC_FILE_EXTRACTED_METADATA"; + publicExtractedMetadata: ExtractedMetadata[]; +} + interface RECEIVE_FILE_METADATA_JSONLD { type: "RECEIVE_FILE_METADATA_JSONLD"; metadataJsonld: MetadataJsonld[]; } +interface RECEIVE_PUBLIC_FILE_METADATA_JSONLD { + type: "RECEIVE_PUBLIC_FILE_METADATA_JSONLD"; + publicMetadataJsonld: MetadataJsonld[]; +} + interface RECEIVE_PREVIEWS { type: "RECEIVE_PREVIEWS"; previews: FilePreview[]; } +interface RECEIVE_PUBLIC_PREVIEWS { + type: "RECEIVE_PUBLIC_PREVIEWS"; + publicPreviews: FilePreview[]; +} + interface RECEIVE_VERSIONS { type: "RECEIVE_VERSIONS"; fileVersions: FileVersion[]; } +interface RECEIVE_PUBLIC_VERSIONS { + type: "RECEIVE_PUBLIC_VERSIONS"; + publicFileVersions: FileVersion[]; +} + interface CHANGE_SELECTED_VERSION { type: "CHANGE_SELECTED_VERSION"; selected_version: number; } +interface CHANGE_PUBLIC_SELECTED_VERSION { + type: "CHANGE_PUBLIC_SELECTED_VERSION"; + publicSelected_version_num: number; +} + interface SET_USER { type: "SET_USER"; Authorization: string; @@ -227,21 +276,41 @@ interface RECEIVE_FILE_SUMMARY { fileSummary: FileSummary; } +interface RECEIVE_PUBLIC_FILE_SUMMARY { + type: "RECEIVE_PUBLIC_FILE_SUMMARY"; + publicFileSummary: FileSummary; +} + interface RECEIVE_DATASET_METADATA { type: "RECEIVE_DATASET_METADATA"; metadataList: Metadata[]; } +interface RECEIVE_PUBLIC_DATASET_METADATA { + type: "RECEIVE_PUBLIC_DATASET_METADATA"; + publicDatasetMetadataList: Metadata[]; +} + interface RECEIVE_FILE_METADATA { type: "RECEIVE_FILE_METADATA"; metadataList: Metadata[]; } +interface RECEIVE_PUBLIC_FILE_METADATA { + type: "RECEIVE_PUBLIC_FILE_METADATA"; + publicFileMetadataList: Metadata[]; +} + interface RECEIVE_FOLDERS_IN_DATASET { type: "RECEIVE_FOLDERS_IN_DATASET"; folders: Folder[]; } +interface RECEIVE_FOLDERS_IN_PUBLIC_DATASET { + type: "RECEIVE_FOLDERS_IN_PUBLIC_DATASET"; + publicFolders: Folder[]; +} + interface UPDATE_DATASET_METADATA { type: "UPDATE_DATASET_METADATA"; metadata: Metadata; @@ -267,6 +336,11 @@ interface RECEIVE_METADATA_DEFINITIONS { metadataDefinitionList: MetadataDefinition[]; } +interface RECEIVE_PUBLIC_METADATA_DEFINITIONS { + type: "RECEIVE_PUBLIC_METADATA_DEFINITIONS"; + publicMetadataDefinitionList: MetadataDefinition[]; +} + interface RECEIVE_METADATA_DEFINITION { type: "RECEIVE_METADATA_DEFINITION"; metadataDefinition: MetadataDefinition; @@ -322,6 +396,11 @@ interface GET_FOLDER_PATH { folderPath: string[]; } +interface GET_PUBLIC_FOLDER_PATH { + type: "GET_PUBLIC_FOLDER_PATH"; + publicFolderPath: string[]; +} + interface RECEIVE_LISTENERS { type: "RECEIVE_LISTENERS"; listeners: []; @@ -457,15 +536,44 @@ interface RESET_VIS_DATA_PRESIGNED_URL { preSignedUrl: string; } +interface GET_PUBLIC_VIS_DATA { + type: "GET_PUBLIC_VIS_DATA"; + publicVisData: VisualizationDataOut; +} + +interface GET_PUBLIC_VIS_CONFIG { + type: "GET_PUBLIC_VIS_CONFIG"; + publicVisConfig: VisualizationConfigOut; +} + +interface DOWNLOAD_PUBLIC_VIS_DATA { + type: "DOWNLOAD_PUBLIC_VIS_DATA"; + publicBlob: Blob; +} + +interface GET_PUBLIC_VIS_DATA_PRESIGNED_URL { + type: "GET_PUBLIC_VIS_DATA_PRESIGNED_URL"; + publicPresignedUrl: string; +} + +interface RESET_PUBLIC_VIS_DATA_PRESIGNED_URL { + type: "RESET_PUBLIC_VIS_DATA_PRESIGNED_URL"; + publicPreSignedUrl: string; +} + export type DataAction = | GET_ADMIN_MODE_STATUS | TOGGLE_ADMIN_MODE | RECEIVE_FILES_IN_DATASET | RECEIVE_FOLDERS_IN_DATASET + | RECEIVE_FOLDERS_IN_PUBLIC_DATASET | DELETE_FILE | RECEIVE_DATASET_ABOUT | RECEIVE_DATASET_ROLE | RECEIVE_DATASETS + | RECEIVE_PUBLIC_DATASETS + | RECEIVE_PUBLIC_DATASET_ABOUT + | RECEIVE_FILES_IN_PUBLIC_DATASET | DELETE_DATASET | RECEIVE_FILE_SUMMARY | RECEIVE_FILE_ROLE @@ -474,6 +582,12 @@ export type DataAction = | RECEIVE_PREVIEWS | RECEIVE_VERSIONS | CHANGE_SELECTED_VERSION + | RECEIVE_PUBLIC_FILE_SUMMARY + | RECEIVE_PUBLIC_FILE_EXTRACTED_METADATA + | RECEIVE_PUBLIC_FILE_METADATA_JSONLD + | RECEIVE_PUBLIC_PREVIEWS + | RECEIVE_PUBLIC_VERSIONS + | CHANGE_PUBLIC_SELECTED_VERSION | SET_USER | LOGIN_ERROR | LOGOUT @@ -500,12 +614,15 @@ export type DataAction = | POST_DATASET_METADATA | POST_FILE_METADATA | RECEIVE_METADATA_DEFINITIONS + | RECEIVE_PUBLIC_METADATA_DEFINITIONS | RECEIVE_METADATA_DEFINITION | SEARCH_METADATA_DEFINITIONS | DELETE_METADATA_DEFINITION | SAVE_METADATA_DEFINITIONS | RECEIVE_DATASET_METADATA + | RECEIVE_PUBLIC_DATASET_METADATA | RECEIVE_FILE_METADATA + | RECEIVE_PUBLIC_FILE_METADATA | DELETE_DATASET_METADATA | DELETE_FILE_METADATA | DOWNLOAD_FILE @@ -513,6 +630,7 @@ export type DataAction = | RESET_FILE_PRESIGNED_URL | FOLDER_DELETED | GET_FOLDER_PATH + | GET_PUBLIC_FOLDER_PATH | RECEIVE_LISTENERS | SEARCH_LISTENERS | RECEIVE_LISTENER_CATEGORIES @@ -541,4 +659,9 @@ export type DataAction = | GET_VIS_CONFIG | DOWNLOAD_VIS_DATA | GET_VIS_DATA_PRESIGNED_URL - | RESET_VIS_DATA_PRESIGNED_URL; + | RESET_VIS_DATA_PRESIGNED_URL + | GET_PUBLIC_VIS_DATA + | GET_PUBLIC_VIS_CONFIG + | DOWNLOAD_PUBLIC_VIS_DATA + | GET_PUBLIC_VIS_DATA_PRESIGNED_URL + | RESET_PUBLIC_VIS_DATA_PRESIGNED_URL diff --git a/frontend/src/types/data.ts b/frontend/src/types/data.ts index 39bd8ef1d..ce51e2cf0 100644 --- a/frontend/src/types/data.ts +++ b/frontend/src/types/data.ts @@ -141,6 +141,16 @@ export interface DatasetState { roles: DatasetRoles; } +export interface PublicDatasetState { + public_files: FileSummary[]; + public_datasets: Dataset[]; + public_newDataset: Dataset; + public_newFile: FileSummary; + public_about: Dataset; + public_datasetRole: AuthorizationBase; + public_roles: DatasetRoles; +} + export interface ListenerState { listeners: Listener[]; categories: string[]; @@ -160,9 +170,12 @@ export interface GroupState { export interface MetadataState { metadataDefinitionList: MetadataDefinitionOut[]; + publicMetadataDefinitionList: MetadataDefinitionOut[]; metadataDefinition: MetadataDefinitionOut; datasetMetadataList: Metadata[]; + publicDatasetMetadataList: Metadata[]; fileMetadataList: Metadata[]; + publicFileMetadataList: Metadata[]; } export interface FileState { @@ -178,6 +191,17 @@ export interface FileState { selected_version_num: number; } +export interface PublicFileState { + publicUrl: string; + publicBlob: Blob; + publicFileSummary: FileSummary; + publicExtractedMetadata: ExtractedMetadata[]; + publicMetadataJsonld: MetadataJsonld[]; + publicPreviews: FilePreview[]; + publicFileVersions: FileVersion[]; + publicSelected_version_num: number; +} + export interface UserState { Authorization: string | null; loginError: boolean; @@ -201,6 +225,8 @@ export interface ErrorState { export interface FolderState { folders: FolderOut[]; folderPath: string[]; + publicFolders: FolderOut[]; + publicFolderPath: string[]; } export interface JobSummary { @@ -217,6 +243,13 @@ export interface VisualizationState { blob: Blob; } +export interface PublicVisualizationState { + publicVisData: VisualizationDataOut; + publicVisConfig: VisualizationConfigOut[]; + publicPresignedUrl: string; + publicBlob: Blob; +} + export interface EventListenerJobStatus { created: string; started: string; @@ -231,10 +264,13 @@ export interface RootState { metadata: MetadataState; error: ErrorState; file: FileState; + publicFile: PublicFileState; dataset: DatasetState; + publicDataset: PublicDatasetState; listener: ListenerState; group: GroupState; user: UserState; folder: FolderState; visualization: VisualizationState; + publicVisualization: PublicVisualizationState; } diff --git a/frontend/src/utils/common.js b/frontend/src/utils/common.js index 3f106c554..2fa04c2e6 100644 --- a/frontend/src/utils/common.js +++ b/frontend/src/utils/common.js @@ -31,6 +31,25 @@ export function getHeader() { } } +export async function downloadPublicResource(url) { + const authHeader = getHeader(); + const response = await fetch(url, { + method: "GET", + mode: "cors", + }); + + if (response.status === 200) { + const blob = await response.blob(); + return window.URL.createObjectURL(blob); + } else if (response.status === 401) { + // TODO handle error + // logout(); + return null; + } else { + // TODO handle error + return null; + } +} export async function downloadResource(url) { const authHeader = getHeader(); const response = await fetch(url, { @@ -52,6 +71,7 @@ export async function downloadResource(url) { } } + export function dataURItoFile(dataURI) { const metadata = dataURI.split(",")[0]; const mime = metadata.match(/:(.*?);/)[1]; @@ -98,6 +118,9 @@ export const getCurrEmail = () => { authorization !== "" && authorization.split(" ").length > 0 ) { + if (authorization === "Bearer none") { + return "public@clowder.org"; + } const userInfo = jwt_decode(authorization.split(" ")[1]); return userInfo["email"]; } diff --git a/frontend/src/utils/visualization.js b/frontend/src/utils/visualization.js index 1ca31995e..823910b20 100644 --- a/frontend/src/utils/visualization.js +++ b/frontend/src/utils/visualization.js @@ -9,6 +9,10 @@ export function generateVisDataDownloadUrl(visualizationId) { return `${config.hostname}/api/v2/visualizations/${visualizationId}/bytes`; } +export function generatePublicVisDataDownloadUrl(visualizationId) { + return `${config.hostname}/api/v2/public_visualizations/${visualizationId}/bytes`; +} + export function generateFileDownloadUrl(fileId, fileVersionNum = 0) { let url = `${config.hostname}/api/v2/files/${fileId}?increment=false`; if (fileVersionNum > 0) url = `${url}&version=${fileVersionNum}`; @@ -16,6 +20,13 @@ export function generateFileDownloadUrl(fileId, fileVersionNum = 0) { return url; } +export function generatePublicFileDownloadUrl(fileId, fileVersionNum = 0) { + let url = `${config.hostname}/api/v2/public_files/${fileId}?increment=false`; + if (fileVersionNum > 0) url = `${url}&version=${fileVersionNum}`; + + return url; +} + export async function downloadVisData(visualizationId) { const endpoint = `${config.hostname}/api/v2/visualizations/${visualizationId}/bytes`; const response = await fetch(endpoint, { @@ -46,3 +57,17 @@ export async function fileDownloaded(fileId, fileVersionNum = 0) { return ""; } } + +export async function publicFileDownloaded(fileId) { + let endpoint = `${config.hostname}/api/v2/public_files/${fileId}?increment=False`; + const response = await fetch(endpoint, { + method: "GET", + mode: "cors", + }); + + if (response.status === 200) { + return await response.blob(); + } else { + return ""; + } +} diff --git a/scripts/develop/populate_fake_data/populate_fake_data.py b/scripts/develop/populate_fake_data/populate_fake_data.py index 710628fda..1483330e5 100755 --- a/scripts/develop/populate_fake_data/populate_fake_data.py +++ b/scripts/develop/populate_fake_data/populate_fake_data.py @@ -4,6 +4,7 @@ `python populate_fake_data.py http://localhost:8000/api/v2` """ + import os import random import sys @@ -216,14 +217,28 @@ def upload_metadata_file(fake, api, file_id): for _ in range(0, 100): n = random.randint(0, 4) + s = random.randint(0, 3) user = users[n] response = requests.post(f"{api}/login", json=user) token = response.json().get("token") headers = {"Authorization": "Bearer " + token} - dataset_data = { - "name": fake.sentence(nb_words=10).rstrip("."), - "description": fake.paragraph(), - } + if s == 0: + dataset_data = { + "name": fake.sentence(nb_words=10).rstrip("."), + "description": fake.paragraph(), + "status": "PUBLIC", + } + elif s == 1: + dataset_data = { + "name": fake.sentence(nb_words=10).rstrip("."), + "description": fake.paragraph(), + "status": "AUTHENTICATED", + } + else: + dataset_data = { + "name": fake.sentence(nb_words=10).rstrip("."), + "description": fake.paragraph(), + } response = requests.post(f"{api}/datasets", json=dataset_data, headers=headers) if response.status_code != 200: raise ValueError(response.json())