From d1d795b878e9e13aa643316610872616400d1a42 Mon Sep 17 00:00:00 2001 From: Max Burnette Date: Fri, 6 Sep 2024 13:00:14 -0500 Subject: [PATCH 01/11] migrate over projects and tests --- backend/app/main.py | 24 ++- backend/app/models/projects.py | 35 ++++ backend/app/routers/projects.py | 305 +++++++++++++++++++++++++++++ backend/app/tests/test_projects.py | 66 +++++++ backend/app/tests/utils.py | 55 ++++-- 5 files changed, 459 insertions(+), 26 deletions(-) create mode 100644 backend/app/models/projects.py create mode 100644 backend/app/routers/projects.py create mode 100644 backend/app/tests/test_projects.py 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/projects.py b/backend/app/models/projects.py new file mode 100644 index 000000000..a457543d5 --- /dev/null +++ b/backend/app/models/projects.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import List, Optional + +from beanie import Document, PydanticObjectId +from pydantic import BaseModel, Field + +from app.models.users import UserOut + + +class ProjectMember(BaseModel): + user: UserOut + editor: bool = False + + +class ProjectBase(BaseModel): + creator: UserOut + created: datetime = Field(default_factory=datetime.utcnow) + name: str + description: Optional[str] = None + users: List[ProjectMember] = [] + dataset_ids: Optional[List[PydanticObjectId]] = [] + + +class ProjectDB(Document, ProjectBase): + 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..28fe8d109 --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,305 @@ +import os +from typing import Optional + +from beanie import PydanticObjectId +from beanie.operators import Or +from elasticsearch import Elasticsearch +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPBearer + +from app import dependencies +from app.keycloak_auth import get_current_user, get_user +from app.models.datasets import DatasetDB +from app.models.files import FileDB +from app.models.folders import FolderDB +from app.models.pages import Paged, _construct_page_metadata, _get_page_query +from app.models.projects import ProjectMember, 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), + es: Elasticsearch = Depends(dependencies.get_elasticsearchclient), +): + project = ProjectDB(**project_in.dict()) + await project.insert() + + # TODO Add new entry to elasticsearch + return project.dict() + + +@router.post("/{project_id}/add_dataset/{dataset_id}", response_model=ProjectOut) +async def add_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 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.post("/{project_id}/add_folder/{folder_id}", response_model=ProjectOut) +async def add_folder( + project_id: str, + folder_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + folder := await FolderDB.find_one( + Or( + FolderDB.id == FolderDB(PydanticObjectId(folder_id)), + ) + ) + ) is not None: + if folder_id not in project.folder_ids: + project.folder_ids.append(PydanticObjectId(folder_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"Folder {folder_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_folder/{folder_id}", response_model=ProjectOut) +async def remove_folder( + project_id: str, + folder_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + folder := await FolderDB.find_one( + Or( + FolderDB.id == FolderDB(PydanticObjectId(folder_id)), + ) + ) + ) is not None: + if folder_id in project.folder_ids: + project.folder_ids.remove(PydanticObjectId(folder_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"Folder {folder_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/add_file/{file_id}", response_model=ProjectOut) +async def add_file( + project_id: str, + file_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + file := await FolderDB.find_one( + Or( + FileDB.id == FileDB(PydanticObjectId(file_id)), + ) + ) + ) is not None: + if file_id not in project.file_ids: + project.file_ids.append(PydanticObjectId(file_id)) + await project.replace() + return project.dict() + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + +@router.post("/{project_id}/remove_file/{file_id}", response_model=ProjectOut) +async def remove_file( + project_id: str, + file_id: str, +): + if ( + project := await ProjectDB.find_one( + Or( + ProjectDB.id == PydanticObjectId(project_id), + ) + ) + ) is not None: + if ( + file := await FolderDB.find_one( + Or( + FileDB.id == FileDB(PydanticObjectId(file_id)), + ) + ) + ) is not None: + if file_id in project.file_ids: + project.file_ids.remove(PydanticObjectId(file_id)) + await project.replace() + return project.dict() + else: + return project.dict() + raise HTTPException(status_code=404, detail=f"File {file_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] = None, +): + """Add a new user to a group.""" + if (user := await UserDB.find_one(UserDB.email == username)) is not None: + new_member = ProjectMember(user=UserOut(**user.dict())) + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + found_already = False + for u in project.users: + if u.user.email == username: + found_already = True + break + if not found_already: + # If user is already in the group, skip directly to returning the group + # else add role and attach this member + project.users.append(new_member) + await project.replace() + 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, +): + """Remove a user from a group.""" + + if (project := await ProjectDB.get(PydanticObjectId(project_id))) is not None: + # Is the user actually in the group already? + found_user = None + for u in project.users: + if u.user.email == username: + found_user = u + if not found_user: + # TODO: User wasn't in group, should this throw an error instead? Either way, the user is removed... + return project + # Update group itself + project.users.remove(found_user) + await project.replace() + 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} From a1ce1a08ab74919090e60e1197730b2b17096f2d Mon Sep 17 00:00:00 2001 From: Max Burnette Date: Fri, 6 Sep 2024 13:04:32 -0500 Subject: [PATCH 02/11] codegen --- frontend/src/openapi/v2/index.ts | 4 + frontend/src/openapi/v2/models/ProjectIn.ts | 15 + .../src/openapi/v2/models/ProjectMember.ts | 10 + frontend/src/openapi/v2/models/ProjectOut.ts | 29 ++ .../openapi/v2/services/ProjectsService.ts | 265 ++++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 frontend/src/openapi/v2/models/ProjectIn.ts create mode 100644 frontend/src/openapi/v2/models/ProjectMember.ts create mode 100644 frontend/src/openapi/v2/models/ProjectOut.ts create mode 100644 frontend/src/openapi/v2/services/ProjectsService.ts 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 From 280671fe72236915e9e670cfc7eca10e2ddbdb5d Mon Sep 17 00:00:00 2001 From: Max Burnette Date: Fri, 6 Sep 2024 13:20:00 -0500 Subject: [PATCH 03/11] move over frontend placeholder --- frontend/src/actions/project.js | 103 +++++++++ frontend/src/app.config.ts | 8 +- frontend/src/components/Layout.tsx | 130 ++++++----- .../components/datasets/DatasetTableEntry.tsx | 67 ++++++ frontend/src/components/projects/Project.tsx | 215 ++++++++++++++++++ .../src/components/projects/ProjectCard.tsx | 121 ++++++++++ .../components/projects/ProjectDetails.tsx | 36 +++ .../src/components/projects/ProjectTable.tsx | 130 +++++++++++ frontend/src/components/projects/Projects.tsx | 111 +++++++++ frontend/src/reducers/index.ts | 4 +- frontend/src/reducers/project.ts | 29 +++ frontend/src/routes.tsx | 158 ++++++------- 12 files changed, 972 insertions(+), 140 deletions(-) create mode 100644 frontend/src/actions/project.js create mode 100644 frontend/src/components/datasets/DatasetTableEntry.tsx create mode 100644 frontend/src/components/projects/Project.tsx create mode 100644 frontend/src/components/projects/ProjectCard.tsx create mode 100644 frontend/src/components/projects/ProjectDetails.tsx create mode 100644 frontend/src/components/projects/ProjectTable.tsx create mode 100644 frontend/src/components/projects/Projects.tsx create mode 100644 frontend/src/reducers/project.ts diff --git a/frontend/src/actions/project.js b/frontend/src/actions/project.js new file mode 100644 index 000000000..9950d6328 --- /dev/null +++ b/frontend/src/actions/project.js @@ -0,0 +1,103 @@ +// import { V2 } from "../openapi"; +// import { handleErrors } from "./common"; + +export const RECEIVE_PROJECTS = "RECEIVE_PROJECTS"; + +export function fetchProjects(skip = 0, limit = 12) { + return (dispatch) => { + dispatch({ + type: RECEIVE_PROJECTS, + projects: { + metadata: { + total_count: 3, + skip: skip, + limit: limit, + }, + data: [ + { + id: "60f9f8c8c23f5c45d8f0e0e2", + name: "Sample Project", + description: "A description of the sample project", + created: "2024-07-29T12:00:00Z", + modified: "2024-07-29T12:00:00Z", + dataset_ids: [ + "669ea731d559628438e5785d", + "669ea746d559628438e5788f", + ], + folder_ids: ["66a085640c20e43f5c50b059"], + file_ids: ["669fcf4c78f3222201e18a0f"], + creator: { + id: "60f9f8c8c23f5c45d8f0e0c6", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + { + id: "60f9f8c8c23f5c45d8f0e0d1", + name: "Sample Project 2", + description: "A description of the second sample project", + created: "2024-07-28T12:00:00Z", + modified: "2024-07-28T12:00:00Z", + dataset_ids: ["669fcf3978f3222201e18a0d"], + folder_ids: [ + "66a085640c20e43f5c50b059", + "66a80284cf77abbb78b4435f", + ], + file_ids: ["669ea735d559628438e57865", "669ea733d559628438e57862"], + creator: { + id: "669ea726d559628438e57841", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + { + id: "60f9f8c8c23f5c45d8f0e0e2", + name: "Sample Project 3", + description: "A description of the third sample project", + created: "2024-07-27T12:00:00Z", + modified: "2024-07-27T12:00:00Z", + dataset_ids: [], + folder_ids: ["66a80284cf77abbb78b4435f"], + file_ids: [], + creator: { + id: "669ea726d559628438e57841", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + ], + }, + receivedAt: Date.now(), + }); + }; +} + +export const RECEIVE_PROJECT = "RECEIVE_PROJECT"; + +export function fetchProject(id) { + return (dispatch) => { + dispatch({ + type: RECEIVE_PROJECT, + project: { + id: id, + name: "Sample Project", + description: "A description of the sample project", + created: "2024-07-29T12:00:00Z", + modified: "2024-07-29T12:00:00Z", + dataset_ids: ["669ea731d559628438e5785d", "669ea746d559628438e5788f"], + folder_ids: ["66a085640c20e43f5c50b059"], + file_ids: ["669fcf4c78f3222201e18a0f"], + creator: { + id: "60f9f8c8c23f5c45d8f0e0c6", + first_name: "Chen", + last_name: "Wang", + email: "cwang138@illinois.edu", + }, + }, + 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/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}
-