diff --git a/backend/app/deps/authorization_deps.py b/backend/app/deps/authorization_deps.py index d0c0976e7..8005c6850 100644 --- a/backend/app/deps/authorization_deps.py +++ b/backend/app/deps/authorization_deps.py @@ -1,3 +1,7 @@ +from beanie import PydanticObjectId +from beanie.operators import Or +from fastapi import Depends, HTTPException + from app.keycloak_auth import get_current_username, get_read_only_user from app.models.authorization import AuthorizationDB, RoleType from app.models.datasets import DatasetDBViewList, DatasetStatus @@ -6,47 +10,45 @@ from app.models.groups import GroupDB from app.models.listeners import EventListenerDB from app.models.metadata import MetadataDB +from app.models.projects import ProjectDB from app.routers.authentication import get_admin, get_admin_mode -from beanie import PydanticObjectId -from beanie.operators import Or -from fastapi import Depends, HTTPException async def check_public_access( - resource_id: str, - resource_type: str, - role: RoleType, - current_user=Depends(get_current_username), + 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 DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(resource_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(resource_id) + ) ) is not None: if ( - dataset.status == DatasetStatus.PUBLIC.name - or dataset.status == DatasetStatus.AUTHENTICATED.name + 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 + 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), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin=Depends(get_admin), + dataset_id: str, + current_user=Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin=Depends(get_admin), ) -> RoleType: """Returns the role a specific user has on a dataset. If the user is a creator (owner), they are not listed in the user_ids list.""" @@ -69,11 +71,11 @@ async def get_role( async def get_role_by_file( - file_id: str, - current_user=Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin=Depends(get_admin), + file_id: str, + current_user=Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin=Depends(get_admin), ) -> RoleType: if admin and admin_mode: return RoleType.OWNER @@ -88,13 +90,13 @@ async def get_role_by_file( ) if authorization is None: if ( - dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(file.dataset_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(file.dataset_id) + ) ) is not None: if ( - dataset.status == DatasetStatus.AUTHENTICATED.name - or dataset.status == DatasetStatus.PUBLIC.name + dataset.status == DatasetStatus.AUTHENTICATED.name + or dataset.status == DatasetStatus.PUBLIC.name ): return RoleType.VIEWER else: @@ -107,11 +109,11 @@ async def get_role_by_file( async def get_role_by_metadata( - metadata_id: str, - current_user=Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin=Depends(get_admin), + metadata_id: str, + current_user=Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin=Depends(get_admin), ) -> RoleType: if admin and admin_mode: return RoleType.OWNER @@ -131,9 +133,9 @@ async def get_role_by_metadata( return authorization.role elif resource_type == "datasets": if ( - dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(resource_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(resource_id) + ) ) is not None: authorization = await AuthorizationDB.find_one( AuthorizationDB.dataset_id == dataset.id, @@ -146,11 +148,11 @@ async def get_role_by_metadata( async def get_role_by_group( - group_id: str, - current_user=Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin=Depends(get_admin), + group_id: str, + current_user=Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin=Depends(get_admin), ) -> RoleType: if admin and admin_mode: return RoleType.OWNER @@ -173,13 +175,13 @@ async def get_role_by_group( async def is_public_dataset( - dataset_id: str, + dataset_id: str, ) -> bool: """Checks if a dataset is public.""" if ( - dataset_out := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(dataset_id) - ) + dataset_out := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(dataset_id) + ) ) is not None: if dataset_out.status == DatasetStatus.PUBLIC: return True @@ -188,13 +190,13 @@ async def is_public_dataset( async def is_authenticated_dataset( - dataset_id: str, + dataset_id: str, ) -> bool: """Checks if a dataset is authenticated.""" if ( - dataset_out := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(dataset_id) - ) + dataset_out := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(dataset_id) + ) ) is not None: if dataset_out.status == DatasetStatus.AUTHENTICATED: return True @@ -210,13 +212,13 @@ def __init__(self, role: str): self.role = role async def __call__( - self, - dataset_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), - readonly: bool = Depends(get_read_only_user), + self, + dataset_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), + readonly: bool = Depends(get_read_only_user), ): # TODO: Make sure we enforce only one role per user per dataset, or find_one could yield wrong answer here. @@ -242,14 +244,14 @@ async def __call__( ) else: if ( - current_dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(dataset_id) - ) + current_dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(dataset_id) + ) ) is not None: if ( - current_dataset.status == DatasetStatus.AUTHENTICATED.name - or current_dataset.status == DatasetStatus.PUBLIC.name - and self.role == "viewer" + current_dataset.status == DatasetStatus.AUTHENTICATED.name + or current_dataset.status == DatasetStatus.PUBLIC.name + and self.role == "viewer" ): return True else: @@ -272,12 +274,12 @@ def __init__(self, role: str): self.role = role async def __call__( - self, - file_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), + self, + file_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), ): # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned if admin and admin_mode: @@ -301,8 +303,8 @@ async def __call__( ) else: if ( - file.status == FileStatus.PUBLIC.name - or file.status == FileStatus.AUTHENTICATED.name + file.status == FileStatus.PUBLIC.name + or file.status == FileStatus.AUTHENTICATED.name ) and self.role == RoleType.VIEWER: return True else: @@ -319,12 +321,12 @@ def __init__(self, role: str): self.role = role async def __call__( - self, - metadata_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), + self, + metadata_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), ): # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned if admin and admin_mode: @@ -336,7 +338,7 @@ async def __call__( resource_id = md_out.resource.resource_id if resource_type == "files": if ( - file := await FileDB.get(PydanticObjectId(resource_id)) + file := await FileDB.get(PydanticObjectId(resource_id)) ) is not None: authorization = await AuthorizationDB.find_one( AuthorizationDB.dataset_id == file.dataset_id, @@ -358,9 +360,9 @@ async def __call__( ) elif resource_type == "datasets": if ( - dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(resource_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(resource_id) + ) ) is not None: authorization = await AuthorizationDB.find_one( AuthorizationDB.dataset_id == dataset.id, @@ -389,12 +391,12 @@ def __init__(self, role: str): self.role = role async def __call__( - self, - group_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), + self, + group_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), ): # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned if admin and admin_mode: @@ -418,6 +420,43 @@ async def __call__( raise HTTPException(status_code=404, detail=f"Group {group_id} not found") +class ProjectAuthorization: + + def __init__(self, role: str): + self.role = role + + async def __call__( + self, + project_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), + ): + # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned + if admin and admin_mode: + return True + + # Else check role assigned to the user + if (project := await ProjectDB.get(project_id)) is not None: + if project.creator == current_user: + # Creator can do everything + return True + for gid in project.group_ids: + if (group := await GroupDB.get(gid)) is not None: + for u in group.users: + if u.user.email == current_user: + if group.project_id == project.id and u.editor and self.role == RoleType.EDITOR: + return True + elif self.role == RoleType.VIEWER: + return True + raise HTTPException( + status_code=403, + detail=f"User `{current_user} does not have `{self.role}` permission on project {project_id}", + ) + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + class ListenerAuthorization: """We use class dependency so that we can provide the `permission` parameter to the dependency. For more info see https://fastapi.tiangolo.com/advanced/advanced-dependencies/. @@ -427,12 +466,12 @@ class ListenerAuthorization: # self.optional_arg = optional_arg async def __call__( - self, - listener_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), + self, + listener_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), ): # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned if admin and admin_mode: @@ -440,10 +479,10 @@ async def __call__( # Else check if listener is active or current user is the creator of the extractor if ( - listener := await EventListenerDB.get(PydanticObjectId(listener_id)) + listener := await EventListenerDB.get(PydanticObjectId(listener_id)) ) is not None: if listener.active is True or ( - listener.creator and listener.creator.email == current_user + listener.creator and listener.creator.email == current_user ): return True else: @@ -463,12 +502,12 @@ class FeedAuthorization: # self.optional_arg = optional_arg async def __call__( - self, - feed_id: str, - current_user: str = Depends(get_current_username), - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), + self, + feed_id: str, + current_user: str = Depends(get_current_username), + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), ): # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned if admin and admin_mode: @@ -494,13 +533,13 @@ def __init__(self, status: str): self.status = status async def __call__( - self, - dataset_id: str, + self, + dataset_id: str, ): if ( - dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(dataset_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(dataset_id) + ) ) is not None: if dataset.status == self.status: return True @@ -518,15 +557,15 @@ def __init__(self, status: str): self.status = status async def __call__( - self, - file_id: str, + self, + file_id: str, ): if (file_out := await FileDB.get(PydanticObjectId(file_id))) is not None: dataset_id = file_out.dataset_id if ( - dataset := await DatasetDBViewList.find_one( - DatasetDBViewList.id == PydanticObjectId(dataset_id) - ) + dataset := await DatasetDBViewList.find_one( + DatasetDBViewList.id == PydanticObjectId(dataset_id) + ) ) is not None: if dataset.status == self.status: return True @@ -539,12 +578,12 @@ async def __call__( def access( - user_role: RoleType, - role_required: RoleType, - enable_admin: bool = False, - admin_mode: bool = Depends(get_admin_mode), - admin: bool = Depends(get_admin), - read_only_user: bool = Depends(get_read_only_user), + user_role: RoleType, + role_required: RoleType, + enable_admin: bool = False, + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), + read_only_user: bool = Depends(get_read_only_user), ) -> bool: # check for read only user first if read_only_user and role_required == RoleType.VIEWER: @@ -553,24 +592,24 @@ def access( if user_role == RoleType.OWNER or (admin and admin_mode): return True elif ( - user_role == RoleType.EDITOR - and role_required - in [ - RoleType.EDITOR, - RoleType.UPLOADER, - RoleType.VIEWER, - ] - and not read_only_user + user_role == RoleType.EDITOR + and role_required + in [ + RoleType.EDITOR, + RoleType.UPLOADER, + RoleType.VIEWER, + ] + and not read_only_user ): return True elif ( - user_role == RoleType.UPLOADER - and role_required - in [ - RoleType.UPLOADER, - RoleType.VIEWER, - ] - and not read_only_user + user_role == RoleType.UPLOADER + and role_required + in [ + RoleType.UPLOADER, + RoleType.VIEWER, + ] + and not read_only_user ): return True elif user_role == RoleType.VIEWER and role_required == RoleType.VIEWER: diff --git a/backend/app/main.py b/backend/app/main.py index 84f54d38b..37ca62fb6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,12 @@ import logging import uvicorn +from beanie import init_beanie +from fastapi import APIRouter, Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from motor.motor_asyncio import AsyncIOMotorClient +from pydantic import BaseConfig + from app.config import settings from app.keycloak_auth import get_current_username from app.models.authorization import AuthorizationDB @@ -26,6 +32,7 @@ MetadataDefinitionDB, MetadataFreezeDB, ) +from app.models.projects import ProjectDB from app.models.thumbnails import ThumbnailDB, ThumbnailDBViewList, ThumbnailFreezeDB from app.models.tokens import TokenDB from app.models.users import ListenerAPIKeyDB, UserAPIKeyDB, UserDB @@ -48,6 +55,7 @@ files, folders, groups, + projects, jobs, keycloak, licenses, @@ -67,16 +75,10 @@ users, visualization, ) - # setup loggers # logging.config.fileConfig('logging.conf', disable_existing_loggers=False) from app.search.config import indexSettings from app.search.connect import connect_elasticsearch, create_index -from beanie import init_beanie -from fastapi import APIRouter, Depends, FastAPI -from fastapi.middleware.cors import CORSMiddleware -from motor.motor_asyncio import AsyncIOMotorClient -from pydantic import BaseConfig logger = logging.getLogger(__name__) @@ -84,8 +86,8 @@ title=settings.APP_NAME, openapi_url=f"{settings.API_V2_STR}/openapi.json", description="A cloud native data management framework to support any research domain. Clowder was " - "developed to help researchers and scientists in data intensive domains manage raw data, complex " - "metadata, and automatic data pipelines. ", + "developed to help researchers and scientists in data intensive domains manage raw data, complex " + "metadata, and automatic data pipelines. ", version="2.0.0-beta.2", contact={"name": "Clowder", "url": "https://clowderframework.org/"}, license_info={ @@ -228,6 +230,11 @@ tags=["groups"], dependencies=[Depends(get_current_username)], ) +api_router.include_router( + projects.router, + prefix="/projects", + tags=["projects"], +) api_router.include_router( visualization.router, prefix="/visualizations", @@ -303,6 +310,7 @@ async def startup_beanie(): UserAPIKeyDB, ListenerAPIKeyDB, GroupDB, + ProjectDB, TokenDB, ErrorDB, VisualizationConfigDB, diff --git a/backend/app/models/groups.py b/backend/app/models/groups.py index ed71f38a3..1730a73ed 100644 --- a/backend/app/models/groups.py +++ b/backend/app/models/groups.py @@ -1,9 +1,11 @@ +from enum import Enum from typing import List, Optional +from beanie import Document, PydanticObjectId +from pydantic import BaseModel + from app.models.authorization import Provenance from app.models.users import UserOut -from beanie import Document -from pydantic import BaseModel class Member(BaseModel): @@ -11,10 +13,21 @@ class Member(BaseModel): editor: bool = False +class GroupType(str, Enum): + """Certain group types will be hidden from common lists. For example, 'project' type groups are associated with + specific projects and used to track their membership; those groups are managed using the project interface, not + the groups interface.""" + + STANDARD = "standard" + PROJECT = "project" + + class GroupBase(BaseModel): name: str description: Optional[str] users: List[Member] = [] + type: GroupType = GroupType.STANDARD + project_id: Optional[PydanticObjectId] = None class GroupIn(GroupBase): diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py new file mode 100644 index 000000000..8457b3967 --- /dev/null +++ b/backend/app/models/projects.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import List, Optional + +from beanie import Document, PydanticObjectId +from pydantic import BaseModel, Field + +from app.models.groups import GroupOut +from app.models.users import UserOut + + +class ProjectMember(BaseModel): + group: GroupOut + editor: bool = False + + +class ProjectBase(BaseModel): + """Projects handle their membership and permissions with a group that is created with the project. + Members who are added to the project are added to this group. Other groups can also be added to the + project, but this one is a special one tied to the project - it cannot be deleted unless the project + is deleted (which deletes the associated group). + + """ + name: str + description: Optional[str] = None + # Individual users are added to one of the project's hidden groups (viewers or editors) + viewers_group_id: Optional[PydanticObjectId] = None + editors_group_id: Optional[PydanticObjectId] = None + groups: List[ProjectMember] = [] + dataset_ids: List[PydanticObjectId] = [] + + +class ProjectDB(Document, ProjectBase): + creator: UserOut + created: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "projects" + + +class ProjectIn(ProjectBase): + pass + + +class ProjectOut(ProjectDB): + class Config: + fields = {"id": "id"} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 000000000..5403bd7ab --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,256 @@ +import os +from typing import Optional + +from beanie import PydanticObjectId +from beanie.operators import Or +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPBearer + +from app.deps.authorization_deps import Authorization, ProjectAuthorization +from app.keycloak_auth import get_current_user, get_user +from app.models.datasets import DatasetDB +from app.models.groups import GroupDB, Member, GroupType +from app.models.pages import Paged, _construct_page_metadata, _get_page_query +from app.models.projects import ProjectDB, ProjectIn, ProjectOut +from app.models.users import UserDB, UserOut + +router = APIRouter() +security = HTTPBearer() + +clowder_bucket = os.getenv("MINIO_BUCKET_NAME", "clowder") + + +@router.post("", response_model=ProjectOut) +async def save_project( + project_in: ProjectIn, + user=Depends(get_current_user), +): + project = ProjectDB(**project_in.dict(), creator=user) + await project.insert() + + # Automatically create viewer and editor groups to go with this project + viewer_group = GroupDB(**{ + "name": project.name + " (Viewers)", + "description": f"Automatically created for viewers of {project.name} project.", + "users": [], + "project_id": project.id, + "type": GroupType.PROJECT + }, creator=user.email) + await viewer_group.insert() + + editor_group = GroupDB(**{ + "name": project.name + " (Editors)", + "description": f"Automatically created for editors of {project.name} project.", + "users": [ + {"user": user, "editor": True} + ], + "project_id": str(project.id), + "type": GroupType.PROJECT + }, creator=user.email) + await editor_group.insert() + + project.viewers_group_id = viewer_group.id + project.editors_group_id = editor_group.id + await project.save() + + return project.dict() + + +@router.post("/{project_id}/add_dataset/{dataset_id}", response_model=ProjectOut) +async def add_dataset( + project_id: str, + dataset_id: str, + allow_proj: bool = Depends(ProjectAuthorization("editor")), + allow_ds: bool = Depends(Authorization("viewer")), +): + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + if (dataset := await DatasetDB.get(PydanticObjectId(dataset_id))) is not None: + if dataset_id not in project.dataset_ids: + project.dataset_ids.append(PydanticObjectId(dataset_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_dataset/{dataset_id}", response_model=ProjectOut) +async def remove_dataset( + project_id: str, + dataset_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + dataset := await DatasetDB.find_one( + Or( + DatasetDB.id == PydanticObjectId(dataset_id), + ) + ) + ) is not None: + if dataset_id in project.dataset_ids: + project.dataset_ids.remove(PydanticObjectId(dataset_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.get("", response_model=Paged) +async def get_projects( + user_id=Depends(get_user), + skip: int = 0, + limit: int = 10, + mine: bool = False, + enable_admin: bool = False, +): + # TODO check if the current user is a member OR creator + projects_and_count = await ProjectDB.aggregate( + [_get_page_query(skip, limit, sort_field="email", ascending=True)], + ).to_list() + + page_metadata = _construct_page_metadata(projects_and_count, skip, limit) + # TODO have to change _id this way otherwise it won't work + # TODO need to research if there is other pydantic trick to make it work + + page = Paged( + metadata=page_metadata, + data=[ + ProjectOut(id=item.pop("_id"), **item) + for item in projects_and_count[0]["data"] + ], + ) + + return page.dict() + + +@router.get("/{project_id}", response_model=ProjectOut) +async def get_project( + project_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.delete("/{project_id}", response_model=ProjectOut) +async def delete_project( + project_id: str, +): + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + await project.delete() + return project.dict() # TODO: Do we need to return what we just deleted? + else: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/add_member/{username}", response_model=ProjectOut) +async def add_member( + project_id: str, + username: str, + role: Optional[str] = "viewer", + allow: bool = Depends(ProjectAuthorization("editor")), +): + """Add a new user to the project individually - this is routed to one of the project's hidden groups.""" + if (user := await UserDB.find_one(UserDB.email == username)) is not None: + # Add to viewers group if role is none, otherwise add to appropriate group + new_member = Member(user=UserOut(**user.dict()), editor=(role == "editor")) + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + viewers_group = await GroupDB.get(PydanticObjectId(project.viewers_group_id)) + editors_group = await GroupDB.get(PydanticObjectId(project.editors_group_id)) + + if role == "viewer": + found_in_viewers = False + for u in viewers_group.users: + if u.user.email == username: + found_in_viewers = True + break + if not found_in_viewers: + viewers_group.users.append(new_member) + await viewers_group.save() + + found_in_editors = False + clean_users = [] + for u in editors_group.users: + if u.user.email == username: + found_in_editors = True + else: + clean_users.append(u) + if found_in_editors: + editors_group.users = clean_users + await editors_group.save() + + elif role == "editor": + found_in_editors = False + for u in editors_group.users: + if u.user.email == username: + found_in_editors = True + break + if not found_in_editors: + editors_group.users.append(new_member) + await editors_group.save() + + found_in_viewers = False + clean_users = [] + for u in viewers_group.users: + if u.user.email == username: + found_in_viewers = True + else: + clean_users.append(u) + if found_in_viewers: + viewers_group.users = clean_users + await viewers_group.save() + + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + raise HTTPException(status_code=404, detail=f"User {username} not found") + + +@router.post("/{project_id}/remove_member/{username}", response_model=ProjectOut) +async def remove_member( + project_id: str, + username: str, + allow: bool = Depends(ProjectAuthorization("editor")), +): + """Remove a user from a group.""" + + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + viewers_group = await GroupDB.get(PydanticObjectId(project.viewers_group_id)) + editors_group = await GroupDB.get(PydanticObjectId(project.editors_group_id)) + + found_in_editors = False + clean_users = [] + for u in editors_group.users: + if u.user.email == username: + found_in_editors = True + else: + clean_users.append(u) + if found_in_editors: + editors_group.users = clean_users + await editors_group.save() + + found_in_viewers = False + clean_users = [] + for u in viewers_group.users: + if u.user.email == username: + found_in_viewers = True + else: + clean_users.append(u) + if found_in_viewers: + viewers_group.users = clean_users + await viewers_group.save() + + return project.dict() + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") diff --git a/backend/app/tests/test_projects.py b/backend/app/tests/test_projects.py new file mode 100644 index 000000000..bf76ec7d7 --- /dev/null +++ b/backend/app/tests/test_projects.py @@ -0,0 +1,66 @@ +from fastapi.testclient import TestClient + +from app.config import settings +from app.tests.utils import ( + create_dataset, + create_project, + create_user, + user_alt, +) + +member_alt = {"user": user_alt, "editor": False} + + +def test_create_project(client: TestClient, headers: dict): + create_project(client, headers) + + +def test_get_project(client: TestClient, headers: dict): + project_id = create_project(client, headers).get("id") + response = client.get( + f"{settings.API_V2_STR}/projects/{project_id}", headers=headers + ) + assert response.status_code == 200 + assert response.json().get("id") is not None + + +def test_delete_project(client: TestClient, headers: dict): + project_id = create_project(client, headers).get("id") + response = client.delete( + f"{settings.API_V2_STR}/projects/{project_id}", headers=headers + ) + assert response.status_code == 200 + + +def test_add_member(client: TestClient, headers: dict): + new_project = create_project(client, headers) + project_id = new_project.get("id") + + create_user(client, headers) + new_project["users"].append(member_alt) + + response = client.post( + f"{settings.API_V2_STR}/projects/{project_id}/add_member/{member_alt['user']['email']}", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json().get("id") is not None + for user in response.json().get("users"): + assert user.get("user").get("email") == member_alt["user"]["email"] + + +def test_add_dataset(client: TestClient, headers: dict): + new_project = create_project(client, headers) + project_id = new_project.get("id") + + dataset_id = create_dataset(client, headers).get("id") + + response = client.post( + f"{settings.API_V2_STR}/projects/{project_id}/add_dataset/{dataset_id}", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json().get("id") is not None + assert dataset_id in response.json().get("dataset_ids") diff --git a/backend/app/tests/utils.py b/backend/app/tests/utils.py index 395d2a676..ab32ecc2c 100644 --- a/backend/app/tests/utils.py +++ b/backend/app/tests/utils.py @@ -1,12 +1,13 @@ import os import struct -from app.config import settings -from app.keycloak_auth import delete_user from elasticsearch import Elasticsearch from fastapi.testclient import TestClient from pymongo import MongoClient +from app.config import settings +from app.keycloak_auth import delete_user + """These are standard JSON entries to be used for creating test resources.""" user_example = { "email": "test@test.org", @@ -32,6 +33,12 @@ "description": "a dataset is a container of files and metadata", } +project_example = { + "name": "test_project", + "description": "This project is a test", + "creator": user_example, +} + license_example = { "name": "test license", "description": "test description", @@ -99,7 +106,7 @@ def create_user(client: TestClient, headers: dict, email: str = user_alt["email" u["email"] = email response = client.post(f"{settings.API_V2_STR}/users", json=u) assert ( - response.status_code == 200 or response.status_code == 409 + response.status_code == 200 or response.status_code == 409 ) # 409 = user already exists return response.json() @@ -176,12 +183,24 @@ def create_dataset_with_custom_license(client: TestClient, headers: dict): return response.json() +def create_project(client: TestClient, headers: dict): + """Creates a test dataset and returns the JSON.""" + response = client.post( + f"{settings.API_V2_STR}/projects", + headers=headers, + json=project_example, + ) + assert response.status_code == 200 + assert response.json().get("id") is not None + return response.json() + + def upload_file( - client: TestClient, - headers: dict, - dataset_id: str, - filename=filename_example_1, - content=file_content_example_1, + client: TestClient, + headers: dict, + dataset_id: str, + filename=filename_example_1, + content=file_content_example_1, ): """Uploads a dummy file (optionally with custom name/content) to a dataset and returns the JSON.""" with open(filename, "w") as tempf: @@ -199,11 +218,11 @@ def upload_file( def upload_files( - client: TestClient, - headers: dict, - dataset_id: str, - filenames=[filename_example_1, filename_example_2], - file_contents=[file_content_example_1, file_content_example_2], + client: TestClient, + headers: dict, + dataset_id: str, + filenames=[filename_example_1, filename_example_2], + file_contents=[file_content_example_1, file_content_example_2], ): """Uploads a dummy file (optionally with custom name/content) to a dataset and returns the JSON.""" upload_files = [] @@ -229,11 +248,11 @@ def upload_files( def create_folder( - client: TestClient, - headers: dict, - dataset_id: str, - name="test folder", - parent_folder=None, + client: TestClient, + headers: dict, + dataset_id: str, + name="test folder", + parent_folder=None, ): """Creates a folder (optionally under an existing folder) in a dataset and returns the JSON.""" folder_data = {"name": name} diff --git a/frontend/src/actions/project.js b/frontend/src/actions/project.js new file mode 100644 index 000000000..ca0c507cf --- /dev/null +++ b/frontend/src/actions/project.js @@ -0,0 +1,79 @@ +import {V2} from "../openapi"; +import {handleErrors} from "./common"; + +export const RECEIVE_PROJECTS = "RECEIVE_PROJECTS"; + +export function fetchProjects(skip = 0, limit = 12) { + return (dispatch) => { + return V2.ProjectsService.getProjectsApiV2ProjectsGet(skip, limit) + .then((json) => { + dispatch({ + type: RECEIVE_PROJECTS, + projects: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch( + handleErrors( + reason, + fetchProjects(skip, limit) + ) + ); + }); + }; +} + +export const RECEIVE_PROJECT = "RECEIVE_PROJECT"; + +export function fetchProject(id) { + return (dispatch) => { + return V2.ProjectsService.getProjectApiV2ProjectsProjectIdGet(id) + .then((json) => { + dispatch({ + type: RECEIVE_PROJECT, + project: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, fetchProject(id))); + }); + }; +} + +export const CREATE_PROJECT = "CREATE_PROJECT"; + +export function projectCreated(formData) { + return (dispatch) => { + return V2.ProjectsService.saveProjectApiV2ProjectsPost( + formData + ) + .then((project) => { + dispatch({ + type: CREATE_PROJECT, + project: project, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch( + handleErrors( + reason, + projectCreated(formData) + ) + ); + }); + }; +} + +export const RESET_CREATE_PROJECT = "RESET_CREATE_PROJECT"; + +export function resetProjectCreated() { + return (dispatch) => { + dispatch({ + type: RESET_CREATE_PROJECT, + receivedAt: Date.now(), + }); + }; +} diff --git a/frontend/src/app.config.ts b/frontend/src/app.config.ts index 7c325852c..3d690e55a 100644 --- a/frontend/src/app.config.ts +++ b/frontend/src/app.config.ts @@ -1,5 +1,5 @@ -import { V2 } from "./openapi"; -import { EventListenerJobStatus } from "./types/data"; +import {V2} from "./openapi"; +import {EventListenerJobStatus} from "./types/data"; interface Config { appVersion: string; @@ -26,6 +26,7 @@ interface Config { defaultFolderFilePerPage: number; defaultDatasetPerPage: number; defaultGroupPerPage: number; + defaultProjectPerPage: number; defaultUserPerPage: number; defaultApikeyPerPage: number; defaultExtractors: number; @@ -89,10 +90,11 @@ config["eventListenerJobStatus"]["resubmitted"] = "RESUBMITTED"; config["streamingBytes"] = 1024 * 10; // 10 MB? config["rawDataVisualizationThreshold"] = 1024 * 1024 * 10; // 10 MB - +config["defaultProjectPerPage"] = 12; config["defaultDatasetPerPage"] = 12; config["defaultFolderFilePerPage"] = 5; config["defaultGroupPerPage"] = 5; + config["defaultUserPerPage"] = 5; config["defaultApikeyPerPage"] = 5; config["defaultExtractors"] = 5; diff --git a/frontend/src/components/Explore.tsx b/frontend/src/components/Explore.tsx index fea232ac9..e67144c90 100644 --- a/frontend/src/components/Explore.tsx +++ b/frontend/src/components/Explore.tsx @@ -1,16 +1,16 @@ -import React, { ChangeEvent, useEffect, useState } from "react"; -import { Box, Button, Grid, Pagination, Tab, Tabs } from "@mui/material"; +import React, {ChangeEvent, useEffect, useState} from "react"; +import {Box, Button, Grid, Pagination, Tab, Tabs} from "@mui/material"; -import { RootState } from "../types/data"; -import { useDispatch, useSelector } from "react-redux"; -import { fetchDatasets } from "../actions/dataset"; +import {RootState} from "../types/data"; +import {useDispatch, useSelector} from "react-redux"; +import {fetchDatasets} from "../actions/dataset"; -import { a11yProps, TabPanel } from "./tabs/TabComponent"; +import {a11yProps, TabPanel} from "./tabs/TabComponent"; import DatasetCard from "./datasets/DatasetCard"; import Layout from "./Layout"; -import { Link as RouterLink } from "react-router-dom"; -import { ErrorModal } from "./errors/ErrorModal"; -import { DatasetOut } from "../openapi/v2"; +import {Link as RouterLink} from "react-router-dom"; +import {ErrorModal} from "./errors/ErrorModal"; +import {DatasetOut} from "../openapi/v2"; import config from "../app.config"; const tab = { @@ -83,10 +83,10 @@ export const Explore = (): JSX.Element => { return ( {/*Error Message dialogue*/} - + - + { thumbnailId={dataset.thumbnail_id} frozen={dataset.frozen} frozenVersionNum={dataset.frozen_version_num} + download={true} /> ); @@ -129,7 +130,7 @@ export const Explore = (): JSX.Element => { component={RouterLink} to="/create-dataset" variant="contained" - sx={{ m: 2 }} + sx={{m: 2}} > Create Dataset @@ -140,7 +141,7 @@ export const Explore = (): JSX.Element => { )} {datasets.length !== 0 ? ( - + { created={dataset.created} description={dataset.description} thumbnailId={dataset.thumbnail_id} + download={true} /> ); @@ -184,7 +186,7 @@ export const Explore = (): JSX.Element => { component={RouterLink} to="/create-dataset" variant="contained" - sx={{ m: 2 }} + sx={{m: 2}} > Create Dataset @@ -195,7 +197,7 @@ export const Explore = (): JSX.Element => { )} {datasets.length !== 0 ? ( - + { <>> )} - - - + + + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a96716a61..7d9e6a1fb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,9 @@ import * as React from "react"; -import { useEffect } from "react"; -import { styled, useTheme } from "@mui/material/styles"; +import {useEffect} from "react"; +import {styled, useTheme} from "@mui/material/styles"; import Box from "@mui/material/Box"; import Drawer from "@mui/material/Drawer"; -import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; +import MuiAppBar, {AppBarProps as MuiAppBarProps} from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import List from "@mui/material/List"; import IconButton from "@mui/material/IconButton"; @@ -15,39 +15,39 @@ 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 { Badge, Link, Menu, MenuItem, MenuList } from "@mui/material"; -import { Link as RouterLink, useLocation } from "react-router-dom"; -import { useDispatch, useSelector } from "react-redux"; -import { RootState } from "../types/data"; -import { AddBox, Explore } from "@material-ui/icons"; +import {Badge, Link, Menu, MenuItem, MenuList} from "@mui/material"; +import {Link as RouterLink, useLocation} from "react-router-dom"; +import {useDispatch, 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 MenuBookIcon from "@mui/icons-material/MenuBook"; 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 {getCurrEmail} from "../utils/common"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; import LogoutIcon from "@mui/icons-material/Logout"; -import { EmbeddedSearch } from "./search/EmbeddedSearch"; +import {EmbeddedSearch} from "./search/EmbeddedSearch"; import { fetchUserProfile, getAdminModeStatus as getAdminModeStatusAction, toggleAdminMode as toggleAdminModeAction, } from "../actions/user"; -import { AdminPanelSettings, SavedSearch } from "@mui/icons-material"; +import {AdminPanelSettings, Collections, SavedSearch} from "@mui/icons-material"; import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; -import { Footer } from "./navigation/Footer"; +import {Footer} from "./navigation/Footer"; import BuildIcon from "@mui/icons-material/Build"; import config from "../app.config"; const drawerWidth = 240; -const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ +const Main = styled("main", {shouldForwardProp: (prop) => prop !== "open"})<{ open?: boolean; -}>(({ theme, open }) => ({ +}>(({theme, open}) => ({ flexGrow: 1, padding: theme.spacing(3), transition: theme.transitions.create("margin", { @@ -64,7 +64,7 @@ const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ }), })); -const SearchDiv = styled("div")(({ theme }) => ({ +const SearchDiv = styled("div")(({theme}) => ({ position: "relative", marginLeft: theme.spacing(3), marginBottom: "-5px", // to compoensate the tags div @@ -77,7 +77,7 @@ interface AppBarProps extends MuiAppBarProps { const AppBar = styled(MuiAppBar, { shouldForwardProp: (prop) => prop !== "open", -})(({ theme, open }) => ({ +})(({theme, open}) => ({ transition: theme.transitions.create(["margin", "width"], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, @@ -92,7 +92,7 @@ const AppBar = styled(MuiAppBar, { }), })); -const DrawerHeader = styled("div")(({ theme }) => ({ +const DrawerHeader = styled("div")(({theme}) => ({ display: "flex", alignItems: "center", padding: theme.spacing(0, 1), @@ -110,7 +110,7 @@ const link = { export default function PersistentDrawerLeft(props) { const dispatch = useDispatch(); - const { children } = props; + const {children} = props; const theme = useTheme(); const [open, setOpen] = React.useState(false); const [embeddedSearchHidden, setEmbeddedSearchHidden] = React.useState(false); @@ -173,26 +173,26 @@ export default function PersistentDrawerLeft(props) { aria-label="open drawer" onClick={handleDrawerOpen} edge="start" - sx={{ mr: 2, ...(open && { display: "none" }) }} + sx={{mr: 2, ...(open && {display: "none"})}} > - + {/*for searching*/} {/* */} - + - - + + {loggedOut ? ( <> @@ -214,11 +214,11 @@ export default function PersistentDrawerLeft(props) { {getCurrEmail() !== undefined ? ( ) : ( <>> @@ -239,18 +239,18 @@ export default function PersistentDrawerLeft(props) { ) : ( ) : ( <>> ) } > - + )} @@ -262,16 +262,16 @@ export default function PersistentDrawerLeft(props) { - + User Profile @@ -281,14 +281,14 @@ export default function PersistentDrawerLeft(props) { {adminMode ? ( <> - + Drop Admin Mode > ) : ( <> - + Enable Admin Mode > @@ -301,13 +301,13 @@ export default function PersistentDrawerLeft(props) { - + API Key - + Log Out @@ -330,9 +330,9 @@ export default function PersistentDrawerLeft(props) { {theme.direction === "ltr" ? ( - + ) : ( - + )} @@ -340,9 +340,19 @@ export default function PersistentDrawerLeft(props) { - + - + + + + + + + + + + + @@ -350,9 +360,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -361,9 +371,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -374,9 +384,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -388,9 +398,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -399,9 +409,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -409,9 +419,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -419,9 +429,9 @@ export default function PersistentDrawerLeft(props) { - + - + @@ -435,9 +445,9 @@ export default function PersistentDrawerLeft(props) { rel="noopener noreferrer" > - + - + @@ -445,15 +455,15 @@ export default function PersistentDrawerLeft(props) { - + - + - + {children} - + ); diff --git a/frontend/src/components/datasets/DatasetCard.tsx b/frontend/src/components/datasets/DatasetCard.tsx index 622f50aa9..e8dccc19d 100644 --- a/frontend/src/components/datasets/DatasetCard.tsx +++ b/frontend/src/components/datasets/DatasetCard.tsx @@ -1,19 +1,13 @@ -import React, { useEffect, useState } from "react"; +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 {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"; type DatasetCardProps = { @@ -26,6 +20,7 @@ type DatasetCardProps = { publicView?: boolean | false; frozen?: string; frozenVersionNum?: number; + download: boolean; }; export default function DatasetCard(props: DatasetCardProps) { @@ -39,6 +34,7 @@ export default function DatasetCard(props: DatasetCardProps) { publicView, frozen, frozenVersionNum, + download, } = props; const [thumbnailUrl, setThumbnailUrl] = useState(""); @@ -57,16 +53,16 @@ export default function DatasetCard(props: DatasetCardProps) { return ( {publicView ? ( - + {thumbnailId ? ( ) : null} - + - + {thumbnailId ? ( ) : null} - + )} - - + + {download ? - + - + : null} {/**/} {/* */} {/* */} diff --git a/frontend/src/components/datasets/DatasetTableEntry.tsx b/frontend/src/components/datasets/DatasetTableEntry.tsx new file mode 100644 index 000000000..8ebc0b925 --- /dev/null +++ b/frontend/src/components/datasets/DatasetTableEntry.tsx @@ -0,0 +1,80 @@ +import React, {useEffect, useState} from "react"; + +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import {Dataset} from "@mui/icons-material"; +import {Button} from "@mui/material"; +import {parseDate} from "../../utils/common"; +import {generateThumbnailUrl} from "../../utils/visualization"; +import {MoreHoriz} from "@material-ui/icons"; +import {DatasetOut} from "../../openapi/v2"; + +type DatasetTableEntryProps = { + iconStyle: {}; + selectDataset: any; + dataset: DatasetOut; + selected: boolean; +}; + + +export default function DatsetTableEntry(props: DatasetTableEntryProps) { + const {iconStyle, selectDataset, dataset, selected} = props; + const [thumbnailUrl, setThumbnailUrl] = useState(""); + + useEffect(() => { + let url = ""; + if (dataset.thumbnail_id) { + url = generateThumbnailUrl(dataset.thumbnail_id); + } + setThumbnailUrl(url); + }, [dataset]); + + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + return ( + + + {dataset.thumbnail_id ? ( + + ) : ( + + )} + selectDataset(dataset.id, dataset.name)}> + {dataset.name} + + + {parseDate(dataset.created)} + - + - + + + + + + + ); +} diff --git a/frontend/src/components/projects/CreateProject.tsx b/frontend/src/components/projects/CreateProject.tsx new file mode 100644 index 000000000..1321edebb --- /dev/null +++ b/frontend/src/components/projects/CreateProject.tsx @@ -0,0 +1,82 @@ +import React, {useEffect, useState} from "react"; + +import {Box, Typography,} from "@mui/material"; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "../../types/data"; +import {useNavigate} from "react-router-dom"; +import Layout from "../Layout"; +import {ErrorModal} from "../errors/ErrorModal"; +import projectSchema from "../../schema/projectSchema.json"; +import {FormProps} from "@rjsf/core"; +import validator from "@rjsf/validator-ajv8"; +import Form from "@rjsf/mui"; +import {ClowderRjsfTextWidget} from "../styledComponents/ClowderRjsfTextWidget"; +import {ClowderRjsfTextAreaWidget} from "../styledComponents/ClowderRjsfTextAreaWidget"; +import {ClowderRjsfSelectWidget} from "../styledComponents/ClowderRjsfSelectWidget"; +import {projectCreated, resetProjectCreated} from "../../actions/project"; + + +const widgets = { + TextWidget: ClowderRjsfTextWidget, + TextAreaWidget: ClowderRjsfTextAreaWidget, + SelectWidget: ClowderRjsfSelectWidget, +}; + +export const CreateProject = (): JSX.Element => { + const dispatch = useDispatch(); + + const newProject = useSelector( + (state: RootState) => state.project.newProject + ); + + const [errorOpen, setErrorOpen] = useState(false); + + const history = useNavigate(); + + const checkIfFieldsAreRequired = () => { + const required = false; + return required; + }; + + // step 1 - project details + const saveProject = (formData: any) => { + // If no metadata fields are marked as required, allow user to skip directly to submit + if (!checkIfFieldsAreRequired()) { + dispatch(projectCreated(formData)); + } + }; + + useEffect(() => { + if (newProject && newProject.id) { + dispatch(resetProjectCreated()); + history(`/projects/${newProject.id}`); + } + }, [newProject]); + + return ( + + + {/*Error Message dialogue*/} + + + + + A project is a collection of datasets and a community of contributors. + + + ["schema"]} + uiSchema={projectSchema["uiSchema"] as FormProps["uiSchema"]} + validator={validator} + onSubmit={({formData}) => { + saveProject(formData); + }} + /> + + + + + + ); +}; diff --git a/frontend/src/components/projects/CreateProjectModal.tsx b/frontend/src/components/projects/CreateProjectModal.tsx new file mode 100644 index 000000000..39b8097a5 --- /dev/null +++ b/frontend/src/components/projects/CreateProjectModal.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import Form from "@rjsf/mui"; +import projectSchema from "../../schema/projectSchema.json"; +import {FormProps} from "@rjsf/core"; +import {ClowderRjsfTextWidget} from "../styledComponents/ClowderRjsfTextWidget"; +import {ClowderRjsfSelectWidget} from "../styledComponents/ClowderRjsfSelectWidget"; +import {ClowderRjsfTextAreaWidget} from "../styledComponents/ClowderRjsfTextAreaWidget"; +import validator from "@rjsf/validator-ajv8"; + +type CreateProjectModalProps = { + onSave: any; +}; + +const widgets = { + TextWidget: ClowderRjsfTextWidget, + TextAreaWidget: ClowderRjsfTextAreaWidget, + SelectWidget: ClowderRjsfSelectWidget, +}; + +export const CreateProjectModal: React.FC = ( + props: CreateProjectModalProps +) => { + const {onSave} = props; + + return ( + + ); +}; diff --git a/frontend/src/components/projects/Project.tsx b/frontend/src/components/projects/Project.tsx new file mode 100644 index 000000000..0e5ad6c16 --- /dev/null +++ b/frontend/src/components/projects/Project.tsx @@ -0,0 +1,138 @@ +import React, {ChangeEvent, useEffect, useState} from "react"; +import {Box, Grid, Snackbar, Tab, Tabs, Typography,} from "@mui/material"; +import {useParams} from "react-router-dom"; +import {RootState} from "../../types/data"; +import {useDispatch, useSelector} from "react-redux"; +import {fetchProject} from "../../actions/project"; + +import {a11yProps, TabPanel} from "../tabs/TabComponent"; +import Layout from "../Layout"; +// import { ActionsMenuGroup } from "../datasets/ActionsMenuGroup"; +import {ProjectDetails} from "./ProjectDetails"; +import {FormatListBulleted, InsertDriveFile} from "@material-ui/icons"; +import {TabStyle} from "../../styles/Styles"; +import {ErrorModal} from "../errors/ErrorModal"; +import config from "../../app.config"; + +export const Project = (): JSX.Element => { + // Path parameter + const {projectId} = useParams<{ projectId?: string }>(); + + // Redux connect equivalent + const dispatch = useDispatch(); + + const getProject = (projectId: string | undefined) => { + console.log("getProject"); + console.log(projectId); + dispatch(fetchProject(projectId)); + } + const project = useSelector((state: RootState) => state.project.about); + + // State + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [errorOpen, setErrorOpen] = useState(false); + const [snackBarOpen, setSnackBarOpen] = useState(false); + const [snackBarMessage, setSnackBarMessage] = useState(""); + const [paths, setPaths] = useState([]); + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultFolderFilePerPage); + + useEffect(() => { + getProject(projectId); + }, [projectId]); + + // Breadcrumb + useEffect(() => { + if (project) { + console.log("gotProject"); + console.log(project); + const tmpPaths = [ + { + name: project.name, + url: `/projects/${projectId}`, + }, + ]; + setPaths(tmpPaths); + } + }, [project, projectId]); + + const handleTabChange = ( + _event: React.ChangeEvent<{}>, + newTabIndex: number + ) => { + setSelectedTabIndex(newTabIndex); + }; + + const handlePageChange = (_: ChangeEvent, value: number) => { + setCurrPageNum(value); + }; + + return ( + + {/* Error Message dialog */} + + { + setSnackBarOpen(false); + setSnackBarMessage(""); + }} + message={snackBarMessage} + /> + + {/* Title */} + + + + + + {project?.name ?? "Loading..."} + + + {project?.description ?? ""} + + + + + + {/* Actions */} + + {/**/} + + + + + + } + iconPosition="start" + sx={TabStyle} + label="Datasets" + {...a11yProps(0)} + /> + } + iconPosition="start" + sx={TabStyle} + label="Members" + {...a11yProps(1)} + disabled={false} + /> + + + + + {project ? : null} + + + + + ); +}; diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx new file mode 100644 index 000000000..7a8e47140 --- /dev/null +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -0,0 +1,88 @@ +import React 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 {Box, CardActionArea, CardHeader, Tooltip,} from "@mui/material"; +import {Dataset,} from "@mui/icons-material"; + +type ProjectCardProps = { + id?: string; + name?: string; + author?: string; + created?: string | Date; + description?: string; + numDatasets?: number; + numUsers?: number; +}; + +export default function ProjectCard(props: ProjectCardProps) { + const { + id, + name, + author, + created, + description, + numDatasets, + numUsers, + } = props; + + const formattedCreated = parseDate(created, "PP"); + const subheader = `${formattedCreated} \u00B7 ${author}`; + + return ( + + + + + + {description} + + + + + + + + + + {numDatasets ?? 0} + + + + + + + + {numUsers ?? 0} + + + + + + + ); +} diff --git a/frontend/src/components/projects/ProjectDetails.tsx b/frontend/src/components/projects/ProjectDetails.tsx new file mode 100644 index 000000000..b290bdbc2 --- /dev/null +++ b/frontend/src/components/projects/ProjectDetails.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import {Box, Typography} from "@mui/material"; +import {parseDate} from "../../utils/common"; +import {StackedList} from "../util/StackedList"; + + +export function ProjectDetails(props) { + const {id, created, creator} = props.details; + + const details = new Map< + string, + { value: string | undefined; info?: string } + >(); + if (creator && creator.first_name && creator.last_name) { + details.set("Owner", { + value: `${creator.first_name} ${creator.last_name}`, + }); + } + details.set("Created", { + value: parseDate(created), + info: "Date and time of project creation", + }); + + return ( + + + Details + + + + ); +} diff --git a/frontend/src/components/projects/ProjectTable.tsx b/frontend/src/components/projects/ProjectTable.tsx new file mode 100644 index 000000000..0bc4f40f0 --- /dev/null +++ b/frontend/src/components/projects/ProjectTable.tsx @@ -0,0 +1,54 @@ +import React, {useEffect} from "react"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Paper from "@mui/material/Paper"; +import {useNavigate} from "react-router-dom"; +import {theme} from "../../theme"; +import {useDispatch} from "react-redux"; +import {DatsetTableEntry} from "../datasets/DatasetTableEntry"; + +const iconStyle = { + verticalAlign: "middle", + color: theme.palette.primary.main, +}; + +export default function ProjectTable(props) { + const {project} = props; + + // useNavigate hook for navigation + const navigate = useNavigate(); + + const dispatch = useDispatch(); + + useEffect(() => { + if (project) { + if (project.dataset_ids) { + listDatasets(project.dataset_ids); + } + if (project.file_ids) { + listFiles(project.file_ids); + } + } + }, [project]); + + return ( + + + + + Name + Created + Size + Type + + + + + + + ); +} diff --git a/frontend/src/components/projects/Projects.tsx b/frontend/src/components/projects/Projects.tsx new file mode 100644 index 000000000..ba19dcdda --- /dev/null +++ b/frontend/src/components/projects/Projects.tsx @@ -0,0 +1,133 @@ +import React, {ChangeEvent, useEffect, useState} from "react"; +import {Box, Button, CardContent, Grid, Pagination} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; + +import {useDispatch, useSelector} from "react-redux"; +import {fetchProjects} from "../../actions/project"; + +import ProjectCard from "./ProjectCard"; +import Layout from "../Layout"; +import {Link as RouterLink, useNavigate} from "react-router-dom"; +import {ErrorModal} from "../errors/ErrorModal"; +import config from "../../app.config"; +import {RootState} from "../../types/data"; +import Card from "@mui/material/Card"; + +export const Projects = (): JSX.Element => { + const history = useNavigate(); + const dispatch = useDispatch(); + + const listProjects = (skip: number | undefined, limit: number | undefined) => + dispatch(fetchProjects(skip, limit)); + const projects = useSelector( + (state: RootState) => state.project.projects.data + ); + const pageMetadata = useSelector( + (state: RootState) => state.project.projects.metadata + ); + + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultProjectPerPage); + const [errorOpen, setErrorOpen] = useState(false); + + // Admin mode will fetch all projects + useEffect(() => { + listProjects((currPageNum - 1) * limit, limit); + }, [currPageNum, limit]); + + // pagination + const handlePageChange = (_: ChangeEvent, value: number) => { + const newSkip = (value - 1) * limit; + setCurrPageNum(value); + listProjects(newSkip, limit); + }; + + return ( + + {/*Error Message dialogue*/} + + + + + {projects !== undefined ? ( + projects.map((project) => { + return ( + + + + ); + }) + ) : ( + <>> + )} + {projects?.length > 0 ? + + { + history(`/create-project`) + }} + > + + + + + : null} + + + {projects?.length === 0 ? <> + + Nobody has created any projects on this instance. Click + below to create a project! + + + Create Project + + > : null} + + + + + {projects?.length !== 0 ? ( + + + + ) : ( + <>> + )} + + + + ); +}; diff --git a/frontend/src/components/projects/SelectDatasetsModal.tsx b/frontend/src/components/projects/SelectDatasetsModal.tsx new file mode 100644 index 000000000..5ce7d83bc --- /dev/null +++ b/frontend/src/components/projects/SelectDatasetsModal.tsx @@ -0,0 +1,143 @@ +import React, {ChangeEvent, useEffect, useState} from "react"; +import {Box, Button, Chip, Grid, Pagination, Table} from "@mui/material"; + +import {useDispatch, useSelector} from "react-redux"; +import config from "../../app.config"; +import {RootState} from "../../types/data"; +import {fetchDatasets} from "../../actions/dataset"; +import DatasetTableEntry from "../datasets/DatasetTableEntry"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import TableBody from "@mui/material/TableBody"; +import {theme} from "../../theme"; + +type SelectDatasetsModalProps = { + onSave: any; +}; + +type SelectedDataset = { + id: string, + label: string +}; + +export const SelectDatasetsModal = (props: SelectDatasetsModalProps): JSX.Element => { + const {onSave} = props; + + // Redux connect equivalent + const dispatch = useDispatch(); + const listDatasets = (skip: number | undefined, limit: number | undefined) => + dispatch(fetchDatasets(skip, limit)); + const datasets = useSelector( + (state: RootState) => state.dataset.datasets.data + ); + const pageMetadata = useSelector( + (state: RootState) => state.dataset.datasets.metadata + ); + + const [selectedDatasets, setSelectedDatasets] = useState([]); + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultDatasetPerPage); + + // Admin mode will fetch all projects + useEffect(() => { + listDatasets((currPageNum - 1) * limit, limit); + }, [currPageNum, limit]); + + const selectDataset = (selectedDatasetId: string | undefined, selectedDatasetName: string | undefined) => { + // add dataset to selection list + if (selectedDatasetId && selectedDatasetName) { + const record = {id: selectedDatasetId, label: selectedDatasetName}; + if (selectedDatasets.filter(ds => ds.id === selectedDatasetId).length === 0) { + setSelectedDatasets([ + ...selectedDatasets, + record + ]); + } else { + setSelectedDatasets(selectedDatasets.filter(ds => ds.id !== selectedDatasetId)); + } + } + }; + + + // pagination + const handlePageChange = (_: ChangeEvent, value: number) => { + const newSkip = (value - 1) * limit; + setCurrPageNum(value); + listDatasets(newSkip, limit); + }; + + return ( + + + + + { + selectedDatasets?.map((selected) => { + return ( + selectDataset(selected.id, selected.label)}/> + ); + }) + } + + + + {datasets !== undefined ? (<> + + + + Name + Created + Size + Type + + + + + { + datasets.map((dataset) => { + return ( + + ds.id === dataset.id + ).length > 0} + /> + ); + }) + } + + + > + ) : ( + <>> + )} + + {datasets !== undefined && pageMetadata.total_count !== undefined && datasets.length !== 0 ? ( + + + + ) : ( + <>> + )} + + onSave(selectedDatasets)}> + Next + + + + + ); +}; diff --git a/frontend/src/components/projects/SelectUsersModal.tsx b/frontend/src/components/projects/SelectUsersModal.tsx new file mode 100644 index 000000000..d28371fc9 --- /dev/null +++ b/frontend/src/components/projects/SelectUsersModal.tsx @@ -0,0 +1,131 @@ +import React, {ChangeEvent, useEffect, useState} from "react"; +import {Autocomplete, Box, Button, Chip, Grid, TextField} from "@mui/material"; + +import {useDispatch, useSelector} from "react-redux"; +import config from "../../app.config"; +import {RootState} from "../../types/data"; +import {fetchAllUsers} from "../../actions/user"; +import {UserOut} from "../../openapi/v2"; + +type SelectUsersModalProps = { + onSave: any; +}; + +type SelectedUser = { + email: string +}; + +export const SelectUsersModal = (props: SelectUsersModalProps): JSX.Element => { + const {onSave} = props; + + // Redux connect equivalent + const dispatch = useDispatch(); + const listUsers = (skip: number | undefined, limit: number | undefined) => + dispatch(fetchAllUsers(skip, limit)); + + const [email, setEmail] = useState(""); + const myProfile = useSelector((state: RootState) => state.user.profile); + const users = useSelector((state: RootState) => state.group.users); + const [options, setOptions] = useState([]); + const pageMetadata = useSelector( + (state: RootState) => state.dataset.datasets.metadata + ); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultDatasetPerPage); + + // Admin mode will fetch all projects + useEffect(() => { + listUsers((currPageNum - 1) * limit, limit); + }, [currPageNum, limit]); + + useEffect(() => { + setOptions( + users.data?.reduce((list: string[], user: UserOut) => { + // don't include the current user + if (user.email !== myProfile.email) { + return [...list, user.email]; + } + return list; + }, []) + ); + }, [users, myProfile.email]); + + const selectUser = (selectedEmail: string | undefined) => { + // add use to selection list + if (selectedEmail) { + const record = {email: selectedEmail}; + if (selectedUsers.filter(u => u.email === selectedEmail).length === 0) { + setSelectedUsers([ + ...selectedUsers, + record + ]); + } + } + }; + + const removeUser = (selectedEmail: string | undefined) => { + // add use to selection list + if (selectedEmail) { + setSelectedUsers(selectedUsers.filter(u => u.email !== selectedEmail)); + } + }; + + + // pagination + const handlePageChange = (_: ChangeEvent, value: number) => { + const newSkip = (value - 1) * limit; + setCurrPageNum(value); + listUsers(newSkip, limit); + }; + + return ( + + + + + { + selectedUsers?.map((selected) => { + return ( + removeUser(selected.email)}/> + ); + }) + } + + + { + if (value !== '') { + selectUser(value); + } + }} + options={options} + renderInput={(params) => ( + + )} + /> + + onSave(selectedUsers)}> + Save + + + + + ); +}; diff --git a/frontend/src/openapi/v2/index.ts b/frontend/src/openapi/v2/index.ts index 256f73491..fc074f65e 100644 --- a/frontend/src/openapi/v2/index.ts +++ b/frontend/src/openapi/v2/index.ts @@ -65,6 +65,9 @@ export type { MetadataRequiredForItems } from './models/MetadataRequiredForItems export type { MongoDBRef } from './models/MongoDBRef'; export type { Paged } from './models/Paged'; export type { PageMetadata } from './models/PageMetadata'; +export type { ProjectIn } from './models/ProjectIn'; +export type { ProjectMember } from './models/ProjectMember'; +export type { ProjectOut } from './models/ProjectOut'; export type { Repository } from './models/Repository'; export { RoleType } from './models/RoleType'; export type { SearchCriteria } from './models/SearchCriteria'; @@ -96,6 +99,7 @@ export { LicensesService } from './services/LicensesService'; export { ListenersService } from './services/ListenersService'; export { LoginService } from './services/LoginService'; export { MetadataService } from './services/MetadataService'; +export { ProjectsService } from './services/ProjectsService'; export { PublicDatasetsService } from './services/PublicDatasetsService'; export { PublicElasticsearchService } from './services/PublicElasticsearchService'; export { PublicFilesService } from './services/PublicFilesService'; diff --git a/frontend/src/openapi/v2/models/ProjectIn.ts b/frontend/src/openapi/v2/models/ProjectIn.ts new file mode 100644 index 000000000..1f521bb0c --- /dev/null +++ b/frontend/src/openapi/v2/models/ProjectIn.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ProjectMember } from './ProjectMember'; +import type { UserOut } from './UserOut'; + +export type ProjectIn = { + creator: UserOut; + created?: string; + name: string; + description?: string; + users?: Array; + dataset_ids?: Array; +} diff --git a/frontend/src/openapi/v2/models/ProjectMember.ts b/frontend/src/openapi/v2/models/ProjectMember.ts new file mode 100644 index 000000000..5dd3ee1b0 --- /dev/null +++ b/frontend/src/openapi/v2/models/ProjectMember.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { UserOut } from './UserOut'; + +export type ProjectMember = { + user: UserOut; + editor?: boolean; +} diff --git a/frontend/src/openapi/v2/models/ProjectOut.ts b/frontend/src/openapi/v2/models/ProjectOut.ts new file mode 100644 index 000000000..33d10dc27 --- /dev/null +++ b/frontend/src/openapi/v2/models/ProjectOut.ts @@ -0,0 +1,29 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ProjectMember } from './ProjectMember'; +import type { UserOut } from './UserOut'; + +/** + * Document Mapping class. + * + * Fields: + * + * - `id` - MongoDB document ObjectID "_id" field. + * Mapped to the PydanticObjectId class + * + * Inherited from: + * + * - Pydantic BaseModel + * - [UpdateMethods](https://roman-right.github.io/beanie/api/interfaces/#aggregatemethods) + */ +export type ProjectOut = { + creator: UserOut; + created?: string; + name: string; + description?: string; + users?: Array; + dataset_ids?: Array; + id?: string; +} diff --git a/frontend/src/openapi/v2/services/ProjectsService.ts b/frontend/src/openapi/v2/services/ProjectsService.ts new file mode 100644 index 000000000..47607f3c0 --- /dev/null +++ b/frontend/src/openapi/v2/services/ProjectsService.ts @@ -0,0 +1,265 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Paged } from '../models/Paged'; +import type { ProjectIn } from '../models/ProjectIn'; +import type { ProjectOut } from '../models/ProjectOut'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { request as __request } from '../core/request'; + +export class ProjectsService { + + /** + * Get Projects + * @param skip + * @param limit + * @param mine + * @param enableAdmin + * @returns Paged Successful Response + * @throws ApiError + */ + public static getProjectsApiV2ProjectsGet( + skip?: number, + limit: number = 10, + mine: boolean = false, + enableAdmin: boolean = false, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/projects`, + query: { + 'skip': skip, + 'limit': limit, + 'mine': mine, + 'enable_admin': enableAdmin, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Save Project + * @param requestBody + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static saveProjectApiV2ProjectsPost( + requestBody: ProjectIn, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects`, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Dataset + * @param projectId + * @param datasetId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addDatasetApiV2ProjectsProjectIdAddDatasetDatasetIdPost( + projectId: string, + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_dataset/${datasetId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Dataset + * @param projectId + * @param datasetId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeDatasetApiV2ProjectsProjectIdRemoveDatasetDatasetIdPost( + projectId: string, + datasetId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_dataset/${datasetId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Folder + * @param projectId + * @param folderId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addFolderApiV2ProjectsProjectIdAddFolderFolderIdPost( + projectId: string, + folderId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_folder/${folderId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Folder + * @param projectId + * @param folderId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeFolderApiV2ProjectsProjectIdRemoveFolderFolderIdPost( + projectId: string, + folderId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_folder/${folderId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add File + * @param projectId + * @param fileId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addFileApiV2ProjectsProjectIdAddFileFileIdPost( + projectId: string, + fileId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_file/${fileId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove File + * @param projectId + * @param fileId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeFileApiV2ProjectsProjectIdRemoveFileFileIdPost( + projectId: string, + fileId: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_file/${fileId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Project + * @param projectId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static getProjectApiV2ProjectsProjectIdGet( + projectId: string, + ): CancelablePromise { + return __request({ + method: 'GET', + path: `/api/v2/projects/${projectId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Delete Project + * @param projectId + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static deleteProjectApiV2ProjectsProjectIdDelete( + projectId: string, + ): CancelablePromise { + return __request({ + method: 'DELETE', + path: `/api/v2/projects/${projectId}`, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Add Member + * Add a new user to a group. + * @param projectId + * @param username + * @param role + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static addMemberApiV2ProjectsProjectIdAddMemberUsernamePost( + projectId: string, + username: string, + role?: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/add_member/${username}`, + query: { + 'role': role, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Remove Member + * Remove a user from a group. + * @param projectId + * @param username + * @returns ProjectOut Successful Response + * @throws ApiError + */ + public static removeMemberApiV2ProjectsProjectIdRemoveMemberUsernamePost( + projectId: string, + username: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/projects/${projectId}/remove_member/${username}`, + errors: { + 422: `Validation Error`, + }, + }); + } + +} \ No newline at end of file diff --git a/frontend/src/reducers/index.ts b/frontend/src/reducers/index.ts index c1543db60..32050b7d6 100644 --- a/frontend/src/reducers/index.ts +++ b/frontend/src/reducers/index.ts @@ -1,4 +1,4 @@ -import { combineReducers } from "redux"; +import {combineReducers} from "redux"; import file from "./file"; import dataset from "./dataset"; import publicDataset from "./public_dataset"; @@ -9,6 +9,7 @@ import error from "./error"; import metadata from "./metadata"; import listeners from "./listeners"; import group from "./group"; +import project from "./project"; import visualization from "./visualization"; import publicVisualization from "./public_visualization"; import feeds from "./feeds"; @@ -16,6 +17,7 @@ import feeds from "./feeds"; const rootReducer = combineReducers({ file: file, dataset: dataset, + project: project, publicDataset: publicDataset, publicFile: publicFile, folder: folder, diff --git a/frontend/src/reducers/project.ts b/frontend/src/reducers/project.ts new file mode 100644 index 000000000..05877ed50 --- /dev/null +++ b/frontend/src/reducers/project.ts @@ -0,0 +1,35 @@ +import {CREATE_PROJECT, RECEIVE_PROJECT, RECEIVE_PROJECTS, RESET_CREATE_PROJECT} from "../actions/project"; +import {DataAction} from "../types/action"; +import {ProjectState} from "../types/data"; +import {DatasetOut, DatasetRoles, Paged, PageMetadata, ProjectOut, UserOut} from "../openapi/v2"; + +// @ts-ignore +const defaultState: ProjectState = { + projects: {metadata: {}, data: []}, + newProject: {}, + datasets: [], + members: {}, + selectDatasets: {metadata: {}, data: []}, + selectUsers: {metadata: {}, data: []}, +}; + +const project = (state = defaultState, action: DataAction) => { + switch (action.type) { + case CREATE_PROJECT: + return Object.assign({}, state, {newProject: action.project}); + case RESET_CREATE_PROJECT: + return Object.assign({}, state, {newProject: {}}); + case RECEIVE_PROJECT: + return Object.assign({}, state, { + about: action.project, + }); + case RECEIVE_PROJECTS: + return Object.assign({}, state, { + projects: action.projects, + }); + default: + return state; + } +}; + +export default project; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index cd9defe5b..a57c82692 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,54 +1,45 @@ -import React, { useEffect } from "react"; -import { - BrowserRouter, - Navigate, - Route, - Routes, - useNavigate, - 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"; +import React, {useEffect} from "react"; +import {BrowserRouter, Navigate, Route, Routes, useNavigate, 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"; +import {Projects} from "./components/projects/Projects"; +import {Project} from "./components/projects/Project"; -import { RedirectRegister as RedirectRegisterComponent } from "./components/auth/RedirectRegister"; -import { Auth as AuthComponent } from "./components/auth/Auth"; -import { RedirectLogin as RedirectLoginComponent } from "./components/auth/RedirectLogin"; -import { RedirectLogout as RedirectLogoutComponent } from "./components/auth/RedirectLogout"; -import { Search } from "./components/search/Search"; -import { PublicSearch } from "./components/search/PublicSearch"; -import { isAuthorized } from "./utils/common"; -import { useDispatch, useSelector } from "react-redux"; -import { RootState } from "./types/data"; -import { - refreshToken, - resetFailedReason, - resetFailedReasonInline, - 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"; -import { Forbidden } from "./components/errors/Forbidden"; -import { ApiKeys } from "./components/apikeys/ApiKey"; -import { Profile } from "./components/users/Profile"; -import { ManageUsers } from "./components/users/ManageUsers"; +import {RedirectRegister as RedirectRegisterComponent} from "./components/auth/RedirectRegister"; +import {Auth as AuthComponent} from "./components/auth/Auth"; +import {RedirectLogin as RedirectLoginComponent} from "./components/auth/RedirectLogin"; +import {RedirectLogout as RedirectLogoutComponent} from "./components/auth/RedirectLogout"; +import {Search} from "./components/search/Search"; +import {PublicSearch} from "./components/search/PublicSearch"; +import {isAuthorized} from "./utils/common"; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "./types/data"; +import {refreshToken, resetFailedReason, resetFailedReasonInline, 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"; +import {Forbidden} from "./components/errors/Forbidden"; +import {ApiKeys} from "./components/apikeys/ApiKey"; +import {Profile} from "./components/users/Profile"; +import {ManageUsers} from "./components/users/ManageUsers"; import config from "./app.config"; -import { MetadataDefinitions } from "./components/metadata/MetadataDefinitions"; -import { MetadataDefinitionEntry } from "./components/metadata/MetadataDefinitionEntry"; -import { Feeds } from "./components/listeners/Feeds"; -import { AllListeners } from "./components/listeners/AllListeners"; -import { FeedEntry } from "./components/listeners/FeedEntry"; +import {MetadataDefinitions} from "./components/metadata/MetadataDefinitions"; +import {MetadataDefinitionEntry} from "./components/metadata/MetadataDefinitionEntry"; +import {Feeds} from "./components/listeners/Feeds"; +import {AllListeners} from "./components/listeners/AllListeners"; +import {FeedEntry} from "./components/listeners/FeedEntry"; +import {CreateProject} from "./components/projects/CreateProject"; // https://dev.to/iamandrewluca/private-route-in-react-router-v6-lg5 const PrivateRoute = (props): JSX.Element => { - const { children } = props; + const {children} = props; const history = useNavigate(); @@ -62,8 +53,8 @@ const PrivateRoute = (props): JSX.Element => { dispatch(fetchDatasetRole(datasetId)); const listFileRole = (fileId: string | undefined) => dispatch(fetchFileRole(fileId)); - const { datasetId } = useParams<{ datasetId?: string }>(); - const { fileId } = useParams<{ fileId?: string }>(); + const {datasetId} = useParams<{ datasetId?: string }>(); + const {fileId} = useParams<{ fileId?: string }>(); // periodically call login endpoint once logged in useEffect(() => { @@ -110,31 +101,47 @@ const PrivateRoute = (props): JSX.Element => { if (fileId && reason === "") listFileRole(fileId); }, [fileId, reason]); - return <>{isAuthorized() ? children : }>; + return <>{isAuthorized() ? children : }>; }; export const AppRoutes = (): JSX.Element => { return ( - } /> + }/> {isAuthorized() ? ( - + } /> ) : ( - } /> + }/> )} + + + + } + /> + + + + } + /> - + } /> @@ -142,7 +149,7 @@ export const AppRoutes = (): JSX.Element => { path="/manage-users" element={ - + } /> @@ -150,7 +157,7 @@ export const AppRoutes = (): JSX.Element => { path="/apikeys" element={ - + } /> @@ -158,7 +165,7 @@ export const AppRoutes = (): JSX.Element => { path="/metadata-definitions" element={ - + } /> @@ -166,7 +173,15 @@ export const AppRoutes = (): JSX.Element => { path="/metadata-definitions/:metadataDefinitionId" element={ - + + + } + /> + + } /> @@ -174,7 +189,7 @@ export const AppRoutes = (): JSX.Element => { path="/create-dataset/" element={ - + } /> @@ -182,31 +197,31 @@ export const AppRoutes = (): JSX.Element => { path="/datasets/:datasetId" element={ - + } /> } + element={} /> - + } /> - } /> - } /> - } /> - } /> + }/> + }/> + }/> + }/> - + } /> @@ -214,25 +229,25 @@ export const AppRoutes = (): JSX.Element => { path="/groups/:groupId" element={ - + } /> - } /> + }/> - + } /> - } /> + }/> - + } /> @@ -240,7 +255,7 @@ export const AppRoutes = (): JSX.Element => { path="/feeds" element={ - + } /> @@ -248,7 +263,7 @@ export const AppRoutes = (): JSX.Element => { path="/feeds/:feedId" element={ - + } /> @@ -256,7 +271,7 @@ export const AppRoutes = (): JSX.Element => { path="/listeners" element={ - + } /> @@ -264,7 +279,7 @@ export const AppRoutes = (): JSX.Element => { path="/forbidden" element={ - + } /> @@ -272,7 +287,7 @@ export const AppRoutes = (): JSX.Element => { path="*" element={ - + } /> diff --git a/frontend/src/schema/projectSchema.json b/frontend/src/schema/projectSchema.json new file mode 100644 index 000000000..8a2317ea8 --- /dev/null +++ b/frontend/src/schema/projectSchema.json @@ -0,0 +1,33 @@ +{ + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + } + } + }, + "uiSchema": { + "name": { + "ui:autofocus": true, + "ui:emptyValue": "", + "ui:placeholder": "ui:emptyValue causes this field to always be valid despite being required", + "ui:description": "Name of the project." + }, + "description": { + "ui:autofocus": true, + "ui:emptyValue": "", + "ui:placeholder": "ui:emptyValue causes this field to always be valid despite being required", + "ui:description": "Description of a project.", + "ui:widget": "TextAreaWidget" + } + } +} diff --git a/frontend/src/types/action.ts b/frontend/src/types/action.ts index 54ab4f10b..4c13b4201 100644 --- a/frontend/src/types/action.ts +++ b/frontend/src/types/action.ts @@ -1,4 +1,4 @@ -import { ExtractedMetadata, FilePreview, Folder, MetadataJsonld } from "./data"; +import {ExtractedMetadata, FilePreview, Folder, MetadataJsonld} from "./data"; import { AuthorizationBase, DatasetFreezeOut, @@ -7,8 +7,8 @@ import { DatasetRoles, EventListenerJobOut, EventListenerJobUpdateOut, - FeedOut, EventListenerOut, + FeedOut, FileOut, FileOut as FileSummary, FileVersion, @@ -18,17 +18,14 @@ import { MetadataDefinitionOut as MetadataDefinition, MetadataOut as Metadata, Paged, + ProjectOut as Project, RoleType, UserAPIKeyOut, UserOut, VisualizationConfigOut, VisualizationDataOut, } from "../openapi/v2"; -import { - LIST_USERS, - PREFIX_SEARCH_USERS, - RECEIVE_USER_PROFILE, -} from "../actions/user"; +import {LIST_USERS, PREFIX_SEARCH_USERS, RECEIVE_USER_PROFILE,} from "../actions/user"; interface RECEIVE_FILES_IN_DATASET { type: "RECEIVE_FILES_IN_DATASET"; @@ -227,6 +224,25 @@ interface RECEIVE_USER_PROFILE { profile: UserOut; } +interface CREATE_PROJECT { + type: "CREATE_PROJECT"; + project: Project; +} + +interface RESET_CREATE_PROJECT { + type: "RESET_CREATE_PROJECT"; +} + +interface RECEIVE_PROJECT { + type: "RECEIVE_PROJECT"; + project: Project; +} + +interface RECEIVE_PROJECTS { + type: "RECEIVE_PROJECTS"; + projects: Paged; +} + interface CREATE_DATASET { type: "CREATE_DATASET"; dataset: Dataset; @@ -768,6 +784,10 @@ export type DataAction = | DELETE_API_KEY | GENERATE_API_KEY | RESET_API_KEY + | CREATE_PROJECT + | RESET_CREATE_PROJECT + | RECEIVE_PROJECT + | RECEIVE_PROJECTS | CREATE_DATASET | RESET_CREATE_DATASET | CREATE_FILE diff --git a/frontend/src/types/data.ts b/frontend/src/types/data.ts index e4314286d..8a95168f9 100644 --- a/frontend/src/types/data.ts +++ b/frontend/src/types/data.ts @@ -13,6 +13,7 @@ import { MetadataDefinitionOut, MetadataOut as Metadata, Paged, + ProjectOut, RoleType, UserAPIKeyOut, UserOut, @@ -120,6 +121,16 @@ export interface Thumbnail { thumbnail: string; } +export interface ProjectState { + about: ProjectOut; + newProject: ProjectOut; + projects: Paged; + datasets: DatasetOut[]; + members: DatasetRoles; + selectDatasets: Paged; + selectUsers: Paged; +} + export interface DatasetState { foldersAndFiles: Paged; files: Paged; @@ -285,6 +296,7 @@ export interface RootState { file: FileState; publicFile: PublicFileState; dataset: DatasetState; + project: ProjectState; publicDataset: PublicDatasetState; listener: ListenerState; group: GroupState;
+ Nobody has created any projects on this instance. Click + below to create a project! +