diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 8b48ddf1..52d9600c 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -6,6 +6,6 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: psf/black@stable + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - uses: psf/black@stable \ No newline at end of file diff --git a/deployment/task_defs/dev.json b/deployment/task_defs/dev.json index 49c4f7b6..22b56adc 100644 --- a/deployment/task_defs/dev.json +++ b/deployment/task_defs/dev.json @@ -43,11 +43,11 @@ "name": "GH_CLIENT_SECRET" }, { - "valueFrom": "PEPHUB_GH_REDIRECT_URI", + "valueFrom": "PEPHUB_DEV_GH_REDIRECT_URI", "name": "REDIRECT_URI" }, { - "valueFrom": "PEPHUB_BASE_URI", + "valueFrom": "PEPHUB_DEV_BASE_URI", "name": "BASE_URI" }, { diff --git a/deployment/task_defs/primary.json b/deployment/task_defs/primary.json index d39f501d..520b9949 100644 --- a/deployment/task_defs/primary.json +++ b/deployment/task_defs/primary.json @@ -7,7 +7,7 @@ "containerDefinitions": [ { "name": "pephub-container", - "cpu": 512, + "cpu": 128, "memory": 6096, "memoryReservation": 512, "portMappings": [ diff --git a/launch_docker.sh b/launch_docker.sh index 52973741..f86069d2 100755 --- a/launch_docker.sh +++ b/launch_docker.sh @@ -1,6 +1,6 @@ #!/bin/bash -docker run -p 80:80 \ +docker run -p 8000:80 \ --env POSTGRES_HOST=$POSTGRES_HOST \ --env POSTGRES_DB=$POSTGRES_DB \ --env POSTGRES_USER=$POSTGRES_USER \ @@ -9,8 +9,7 @@ docker run -p 80:80 \ --env QDRANT_PORT=$QDRANT_PORT \ --env QDRANT_ENABLED=$QDRANT_ENABLED \ --env QDRANT_API_KEY=$QDRANT_API_KEY \ - --env HF_MODEL=$HF_MODEL \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ --env GH_CLIENT_SECRET=$GH_CLIENT_SECRET \ --env BASE_URI=$BASE_URI \ - pephub \ No newline at end of file + databio/pephub:dev \ No newline at end of file diff --git a/pephub/_version.py b/pephub/_version.py index 224f1fb7..9da2f8fc 100644 --- a/pephub/_version.py +++ b/pephub/_version.py @@ -1 +1 @@ -__version__ = "0.14.4" +__version__ = "0.15.0" diff --git a/pephub/dependencies.py b/pephub/dependencies.py index 430810a5..c1f769dd 100644 --- a/pephub/dependencies.py +++ b/pephub/dependencies.py @@ -157,7 +157,7 @@ def read_authorization_header(authorization: str = Header(None)) -> Union[dict, def get_organizations_from_session_info( - session_info: Union[dict, None] = Depends(read_authorization_header) + session_info: Union[dict, None] = Depends(read_authorization_header), ) -> List[str]: organizations = [] if session_info: @@ -168,7 +168,7 @@ def get_organizations_from_session_info( def get_user_from_session_info( - session_info: Union[dict, None] = Depends(read_authorization_header) + session_info: Union[dict, None] = Depends(read_authorization_header), ) -> Union[str, None]: user = None if session_info: @@ -405,8 +405,16 @@ def get_namespace_info( @cached(TTLCache(maxsize=100, ttl=5 * 60)) -def get_pepdb_namespace_info(limit: int = 10) -> ListOfNamespaceInfo: +def get_pepdb_namespace_info( + page: int = 0, + page_size: int = 10, + order_by: str = "number_of_projects", +) -> ListOfNamespaceInfo: """ Get the information on the biggest namespaces in the database. """ - return agent.namespace.info(limit=limit) + return agent.namespace.info( + page=page, + page_size=page_size, + order_by=order_by, + ) diff --git a/pephub/helpers.py b/pephub/helpers.py index a12aa56d..a1f13613 100644 --- a/pephub/helpers.py +++ b/pephub/helpers.py @@ -6,6 +6,7 @@ import jwt import pandas as pd import yaml +import json from fastapi import Response, UploadFile from fastapi.exceptions import HTTPException from peppy.const import ( @@ -119,6 +120,28 @@ def download_yaml(content: dict, file_name: str = "unnamed.yaml") -> Response: ) +def download_json(content: dict, file_name: str = "unnamed.json") -> Response: + """ + Convert json/dict to downloading io format + + :param content: content of the file + :param file_name: name of the file + return Response: response object + """ + + json_string = json.dumps(content) + + json_bytes = io.BytesIO() + json_bytes.write(json_string.encode("utf-8")) + json_bytes.seek(0) + + return Response( + json_bytes.getvalue(), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={file_name}"}, + ) + + def build_authorization_url( client_id: str, redirect_uri: str, diff --git a/pephub/main.py b/pephub/main.py index 2c0a00e7..7b9bd491 100644 --- a/pephub/main.py +++ b/pephub/main.py @@ -79,8 +79,8 @@ # build routes app.include_router(api_base) -app.include_router(api_namespace) app.include_router(api_namespaces) +app.include_router(api_namespace) app.include_router(api_project) app.include_router(api_projects) app.include_router(api_search) diff --git a/pephub/routers/api/v1/namespace.py b/pephub/routers/api/v1/namespace.py index 68935194..787b0583 100644 --- a/pephub/routers/api/v1/namespace.py +++ b/pephub/routers/api/v1/namespace.py @@ -5,7 +5,7 @@ import peppy from dotenv import load_dotenv -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, Request from fastapi.responses import JSONResponse from pepdbagent import PEPDatabaseAgent from pepdbagent.const import DEFAULT_LIMIT_INFO @@ -18,7 +18,6 @@ ) from pepdbagent.models import ( AnnotationList, - ListOfNamespaceInfo, Namespace, NamespaceStats, TarNamespaceModelReturn, @@ -40,7 +39,12 @@ get_pepdb_namespace_info, ) from ....helpers import parse_user_file_upload, split_upload_files_on_init_file -from ...models import FavoriteRequest, ProjectJsonRequest, ProjectRawModel +from ...models import ( + FavoriteRequest, + ProjectJsonRequest, + ProjectRawModel, + NamespaceInfoReturnModel, +) # from bedms.const import AVAILABLE_SCHEMAS @@ -379,14 +383,27 @@ async def remove_from_stars( @namespaces.get( - "/info", + "", summary="Get information list of biggest namespaces", - response_model=ListOfNamespaceInfo, + response_model=NamespaceInfoReturnModel, ) async def get_namespace_information( - limit: Optional[int] = DEFAULT_LIMIT_INFO, -) -> ListOfNamespaceInfo: - return get_pepdb_namespace_info(limit) + request: Request, + page: int = 0, + page_size: int = DEFAULT_LIMIT_INFO, + order_by: Literal[ + "number_of_projects", + "number_of_schemas", + ] = "number_of_projects", +) -> NamespaceInfoReturnModel: + results = get_pepdb_namespace_info( + page=page, + page_size=page_size, + order_by=order_by, + ) + return NamespaceInfoReturnModel( + **results.model_dump(), server=f"{str(request.base_url)}api/v1/schemas" + ) @namespaces.get( diff --git a/pephub/routers/api/v1/project.py b/pephub/routers/api/v1/project.py index 5b9965cb..ea181b92 100644 --- a/pephub/routers/api/v1/project.py +++ b/pephub/routers/api/v1/project.py @@ -1,5 +1,5 @@ import logging -from typing import Annotated, Any, Callable, Dict, List, Optional, Union +from typing import Annotated, Any, Literal, Dict, List, Optional, Union import eido import numpy as np @@ -29,7 +29,7 @@ ProjectViews, HistoryAnnotationModel, ) -from peppy.const import SAMPLE_DF_KEY, SAMPLE_RAW_DICT_KEY +from peppy.const import SAMPLE_RAW_DICT_KEY # from ....const import SAMPLE_CONVERSION_FUNCTIONS from ....dependencies import ( @@ -53,12 +53,9 @@ ProjectHistoryResponse, SamplesResponseModel, ConfigResponseModel, - StandardizerResponse, ) from ....const import ( MAX_PROCESSED_PROJECT_SIZE, - BEDMS_REPO_URL, - MAX_STANDARDIZED_PROJECT_SIZE, ) from .helpers import verify_updated_project @@ -237,7 +234,7 @@ async def delete_a_pep( @project.get("/samples", response_model=Union[SamplesResponseModel, str, list, dict]) async def get_pep_samples( proj: dict = Depends(get_project), - format: Optional[str] = None, + format: Optional[Union[Literal["basic", "csv", "yaml", "json"], None]] = None, raw: Optional[bool] = True, ): """ @@ -277,9 +274,11 @@ async def get_pep_samples( "samples": [sample.to_dict() for sample in proj.samples], } elif format == "csv": - return eido.convert_project(proj, "csv")["samples"] + return PlainTextResponse(eido.convert_project(proj, "csv")["samples"]) elif format == "yaml": - return eido.convert_project(proj, "yaml-samples")["samples"] + return PlainTextResponse( + eido.convert_project(proj, "yaml-samples")["samples"] + ) elif format == "basic": return eido.convert_project(proj, "basic") diff --git a/pephub/routers/api/v1/schemas.py b/pephub/routers/api/v1/schemas.py index a8bb3dea..4a002648 100644 --- a/pephub/routers/api/v1/schemas.py +++ b/pephub/routers/api/v1/schemas.py @@ -1,40 +1,46 @@ -from typing import Optional, Union, Literal +from typing import Optional, Union, Literal, List, Dict from starlette.responses import Response +from contextlib import suppress import yaml from dotenv import load_dotenv from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import JSONResponse, PlainTextResponse from pepdbagent import PEPDatabaseAgent from pepdbagent.exceptions import ( SchemaDoesNotExistError, SchemaAlreadyExistsError, - SchemaGroupDoesNotExistError, - SchemaGroupAlreadyExistsError, + SchemaVersionDoesNotExistError, + SchemaVersionAlreadyExistsError, + SchemaTagDoesNotExistError, ) import yaml.parser import yaml.scanner from pepdbagent.models import ( SchemaSearchResult, - SchemaGroupSearchResult, - SchemaGroupAnnotation, + SchemaRecordAnnotation, + SchemaVersionSearchResult, + UpdateSchemaRecordFields, + UpdateSchemaVersionFields, ) from ...models import ( - SchemaCreateRequest, - SchemaUpdateRequest, - SchemaGroupCreateRequest, - SchemaGroupAssignRequest, - SchemaGetResponse, + NewSchemaRecordModel, + NewSchemaVersionModel, + SchemaVersionTagAddModel, ) -from ....helpers import download_yaml + +from ....helpers import download_yaml, download_json from ....dependencies import ( get_db, get_namespace_access_list, get_user_from_session_info, ) +import json + load_dotenv() groups = APIRouter(prefix="/api/v1/schema-groups", tags=["groups"]) @@ -44,18 +50,21 @@ @schemas.get("", response_model=SchemaSearchResult) async def get_all_schemas( query: Optional[str] = None, - limit: Optional[int] = 100, - offset: Optional[int] = 0, - order_by: str = "update_date", + page: Optional[int] = 0, + page_size: Optional[int] = 100, + order_by: Literal["name", "update_date"] = "update_date", order_desc: bool = False, - namespace: Optional[str] = None, agent: PEPDatabaseAgent = Depends(get_db), ): - result = agent.schema.search( - namespace=namespace, + """ + Search all schemas throughout the database. Search is performed on schema name, and description. + """ + + result = agent.schema.query_schemas( + namespace=None, search_str=query, - limit=limit, - offset=offset, + page=page, + page_size=page_size, order_by=order_by, order_desc=order_desc, ) @@ -65,49 +74,85 @@ async def get_all_schemas( @schemas.get("/{namespace}", response_model=SchemaSearchResult) async def get_schemas_in_namespace( namespace: str, - query: Optional[str] = None, - limit: Optional[int] = 100, - offset: Optional[int] = 0, + name: Optional[str] = None, + maintainer: Optional[str] = None, + lifecycle_stage: Optional[str] = None, + latest_version: Optional[str] = None, + page: Optional[int] = 0, + page_size: Optional[int] = 100, agent: PEPDatabaseAgent = Depends(get_db), - order_by: str = "update_date", + order_by: Literal["name", "update_date"] = "update_date", order_desc: bool = False, ): - result = agent.schema.search( + """ + Get schemas for specific endpoint, by providing query parameters to filter the results. + + ## NOTE: latest_version is not implemented yet. + """ + + result = agent.schema.fetch_schemas( namespace=namespace, - search_str=query, - limit=limit, - offset=offset, + name=name, + maintainer=maintainer, + lifecycle_stage=lifecycle_stage, + # latest_version=latest_version, + page_size=page_size, + page=page, order_by=order_by, order_desc=order_desc, ) return result -@schemas.post("/{namespace}/json") -async def create_schema_for_namespace( +@schemas.post("/{namespace}/files") +async def create_schema_for_namespace_by_file( namespace: str, - new_schema: SchemaCreateRequest, + schema_name: str = Form(...), + version: str = Form(...), + description: Optional[str] = Form(None), + maintainers: Optional[str] = Form(None), + lifecycle_stage: Optional[str] = Form(None), + contributors: Optional[str] = Form(None), + release_notes: Optional[str] = Form(None), + tags: Optional[Union[List[str], str, Dict[str, str], List[Dict[str, str]]]] = Form( + None + ), + schema_file: UploadFile = File(...), agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Create a new schema record with first schema version from a file + """ + if user_name not in list_of_admins: raise HTTPException( status_code=403, detail="You do not have permission to create this schema" ) + if isinstance(tags, str) and tags.startswith("{") and tags.endswith("}"): + with suppress(json.JSONDecodeError): + tags = json.loads(tags) + # parse out the schema into a dictionary try: - schema_str = new_schema.schema + schema_str = schema_file.file.read().decode("utf-8") schema_dict = yaml.safe_load(schema_str) + agent.schema.create( namespace=namespace, - name=new_schema.name, - description=new_schema.description, - schema=schema_dict, - overwrite=False, - update_only=False, + name=schema_name, + version=version, + schema_value=schema_dict, + description=description or "", + lifecycle_stage=lifecycle_stage or "", + maintainers=maintainers or "", + contributors=contributors or "", + release_notes=release_notes or "", + tags=tags, ) + return { "message": "Schema created successfully", } @@ -115,7 +160,7 @@ async def create_schema_for_namespace( except SchemaAlreadyExistsError: raise HTTPException( status_code=409, - detail=f"Schema {new_schema.name}/{namespace} already exists.", + detail=f"Schema {namespace}/{schema_name} already exists.", ) except yaml.parser.ParserError as e: raise HTTPException( @@ -124,16 +169,18 @@ async def create_schema_for_namespace( ) -@schemas.post("/{namespace}/file") -async def create_schema_for_namespace_by_file( +@schemas.post("/{namespace}/json") +async def create_schema_for_namespace_by_json( namespace: str, - name: Optional[str] = Form(...), - schema_file: UploadFile = File(...), - description: Optional[str] = Form(default=None), + schema_data: NewSchemaRecordModel, agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Create a new schema record with first schema version from a json object + """ + if user_name not in list_of_admins: raise HTTPException( status_code=403, detail="You do not have permission to create this schema" @@ -141,17 +188,18 @@ async def create_schema_for_namespace_by_file( # parse out the schema into a dictionary try: - schema_str = schema_file.file.read().decode("utf-8") - schema_dict = yaml.safe_load(schema_str) - schema_name = name or schema_file.filename agent.schema.create( namespace=namespace, - name=schema_name, - description=description, - schema=schema_dict, - overwrite=False, - update_only=False, + name=schema_data.schema_name, + version=schema_data.version, + schema_value=schema_data.schema_value, + description=schema_data.description, + lifecycle_stage=schema_data.lifecycle_stage, + maintainers=schema_data.maintainers, + contributors=schema_data.contributors, + release_notes=schema_data.release_notes, + tags=schema_data.tags, ) return { @@ -161,7 +209,7 @@ async def create_schema_for_namespace_by_file( except SchemaAlreadyExistsError: raise HTTPException( status_code=409, - detail=f"Schema {schema_name}/{namespace} already exists.", + detail=f"Schema {namespace}/{schema_data.schema_name} already exists.", ) except yaml.parser.ParserError as e: raise HTTPException( @@ -170,48 +218,58 @@ async def create_schema_for_namespace_by_file( ) -@schemas.get("/{namespace}/{schema}", response_model=Union[SchemaGetResponse, dict]) +@schemas.get("/{namespace}/{schema_name}", response_model=SchemaRecordAnnotation) async def get_schema( - namespace: str, - schema: str, - agent: PEPDatabaseAgent = Depends(get_db), - return_type: Optional[Literal["yaml", "json"]] = "json", + namespace: str, schema_name: str, agent: PEPDatabaseAgent = Depends(get_db) ): + """ + Get a schema record information + """ + try: - schema_dict = agent.schema.get(namespace=namespace, name=schema) + schema_info = agent.schema.get_schema_info( + namespace=namespace, name=schema_name + ) except SchemaDoesNotExistError: raise HTTPException( - status_code=404, detail=f"Schema {schema}/{namespace} not found." + status_code=404, detail=f"Schema {namespace}/{schema_name} not found" ) - if return_type == "yaml": - - info = agent.schema.info(namespace=namespace, name=schema) - return SchemaGetResponse( - schema=yaml.dump(schema_dict), - description=info.description, - last_update_date=info.last_update_date, - submission_date=info.submission_date, - ) - else: - return schema_dict + return schema_info -@schemas.get("/{namespace}/{schema}/file") -async def download_schema( +@schemas.patch("/{namespace}/{schema_name}") +def update_schema_info( namespace: str, - schema: str, + schema_name: str, + update_fields: UpdateSchemaRecordFields, agent: PEPDatabaseAgent = Depends(get_db), -) -> Response: + list_of_admins: Optional[list] = Depends(get_namespace_access_list), + user_name: Optional[str] = Depends(get_user_from_session_info), +): + """ + Update a schema record + """ + + if user_name not in list_of_admins: + raise HTTPException( + status_code=403, detail="You do not have permission to update this schema" + ) + try: - schema_dict = agent.schema.get(namespace=namespace, name=schema) + agent.schema.update_schema_record( + namespace=namespace, + name=schema_name, + update_fields=update_fields, + ) except SchemaDoesNotExistError: raise HTTPException( - status_code=404, detail=f"Schema {schema}/{namespace} not found." + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." ) - return download_yaml(schema_dict, file_name=f"{namespace}/{schema}.yaml") + return JSONResponse({"message": "Schema updated successfully"}) -@schemas.delete("/{namespace}/{schema}") + +@schemas.delete("/{namespace}/{schema_name}") async def delete_schema( namespace: str, schema: str, @@ -219,209 +277,359 @@ async def delete_schema( list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Delete a schema record + """ + if user_name not in list_of_admins: raise HTTPException( status_code=403, detail="You do not have permission to delete this schema" ) - - agent.schema.delete(namespace=namespace, name=schema) + try: + agent.schema.delete_schema(namespace=namespace, name=schema) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema} not found." + ) return {"message": "Schema deleted successfully"} -@schemas.patch("/{namespace}/{schema}") -async def update_schema( +@schemas.get( + "/{namespace}/{schema_name}/versions", response_model=SchemaVersionSearchResult +) +async def get_schema_versions( namespace: str, - schema: str, - updated_schema: SchemaUpdateRequest, + schema_name: str, + query: Optional[str] = "", + tag: Optional[str] = None, + page: Optional[int] = 0, + page_size: Optional[int] = 100, agent: PEPDatabaseAgent = Depends(get_db), - list_of_admins: Optional[list] = Depends(get_namespace_access_list), - user_name: Optional[str] = Depends(get_user_from_session_info), ): - if user_name not in list_of_admins: + """ + Get all versions of a schema object + """ + + try: + schema_info = agent.schema.query_schema_version( + search_str=query, + namespace=namespace, + name=schema_name, + tag=tag, + page=page, + page_size=page_size, + ) + except SchemaDoesNotExistError: raise HTTPException( - status_code=403, detail="You do not have permission to update this schema" + status_code=404, detail=f"Schema {namespace}/{schema_name} not found" ) + return schema_info - # get current version of the schema - current_schema = agent.schema.get(namespace=namespace, name=schema) - current_schema_annotation = agent.schema.info(namespace=namespace, name=schema) - new_schema = ( - yaml.safe_load(updated_schema.schema) - if updated_schema.schema - else current_schema - ) +@schemas.get( + "/{namespace}/{schema_name}/versions/{semantic_version}", + response_model=Union[dict, str], +) +async def get_schema_versions( + namespace: str, + schema_name: str, + semantic_version: str, + format: Literal["json", "yaml"] = "json", + agent: PEPDatabaseAgent = Depends(get_db), +): + """ + Get a specific version of a schema object in json or yaml format + """ try: - agent.schema.update( - namespace=namespace, - name=schema, - schema=new_schema, - description=( - updated_schema.description or current_schema_annotation.description - ), + schema_dict = agent.schema.get( + namespace=namespace, name=schema_name, version=semantic_version ) - except yaml.parser.ParserError as e: + except SchemaDoesNotExistError: raise HTTPException( - status_code=400, - detail=f"The was an error parsing the yaml: {e}", + status_code=404, + detail=f"Schema {namespace}/{schema_name}:{semantic_version} not found.", ) - except yaml.scanner.ScannerError as e: + except SchemaVersionDoesNotExistError: raise HTTPException( - status_code=400, - detail=f"The was an error scanning the yaml: {e}", + status_code=404, + detail=f"Schema version {semantic_version} not found for {namespace}/{schema_name}.", ) + if format == "yaml": + return PlainTextResponse(str(yaml.dump(schema_dict))) - return {"message": "Schema updated successfully"} + return JSONResponse(content=schema_dict) -@schemas.post("/{namespace}/{schema}/groups") -async def assign_group_to_schema( +@schemas.get("/{namespace}/{schema_name}/versions/{semantic_version}/file") +async def download_schema( namespace: str, - schema: str, - group: SchemaGroupAssignRequest, + schema_name: str, + semantic_version: str, + format: Literal["json", "yaml"] = "yaml", + agent: PEPDatabaseAgent = Depends(get_db), +) -> Response: + """ + Download specific version of schema object as a yaml file + """ + + try: + schema_dict = agent.schema.get( + namespace=namespace, name=schema_name, version=semantic_version + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." + ) + if format == "json": + return download_json(schema_dict, file_name=f"{namespace}/{schema_name}.json") + return download_yaml(schema_dict, file_name=f"{namespace}/{schema_name}.yaml") + + +@schemas.post("/{namespace}/{schema_name}/versions/files") +async def create_schema_version_file( + namespace: str, + schema_name: str, + version: str = Form(...), + contributors: Optional[str] = Form(None), + release_notes: Optional[str] = Form(None), + tags: Optional[Union[List[str], str, Dict[str, str], List[Dict[str, str]]]] = Form( + None + ), + schema_file: UploadFile = File(...), agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Create a new version of a schema record from a file + """ + + # Check if tags is a string that needs to be parsed + if isinstance(tags, str) and tags.startswith("{") and tags.endswith("}"): + try: + tags = json.loads(tags) # Parse the JSON string into a dictionary + except json.JSONDecodeError: + pass # Keep original value if parsing fails + if user_name not in list_of_admins: raise HTTPException( status_code=403, - detail="You do not have permission to assign this group to this schema", + detail="You do not have permission to create this schema version", ) - # split the group into namespace and name - group_namespace, group_name = group.group.split("/") - - # run the assignment - agent.schema.group_add_schema( - namespace=group_namespace, - name=group_name, - schema_namespace=namespace, - schema_name=schema, - ) + schema_str = schema_file.file.read().decode("utf-8") + schema_dict = yaml.safe_load(schema_str) + schema_name = schema_name or schema_file.filename - return {"message": "Group assigned to schema successfully"} + try: + agent.schema.add_version( + namespace=namespace, + name=schema_name, + version=version, + schema_value=schema_dict, + contributors=contributors or "", + release_notes=release_notes or "", + tags=tags, + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." + ) + except SchemaVersionAlreadyExistsError: + raise HTTPException( + status_code=409, + detail=f"Schema version {version} already exists for {schema_name}/{namespace}:{version}.", + ) + return JSONResponse({"message": "Schema version created successfully"}) -@schemas.delete("/{namespace}/{schema}/groups") -async def remove_group_from_schema( +@schemas.post("/{namespace}/{schema_name}/versions/json") +async def create_schema_version_json( namespace: str, - schema: str, - group: SchemaGroupAssignRequest, + schema_name: str, + schema_data: NewSchemaVersionModel, agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Create a new version of a schema record from a json object + """ + if user_name not in list_of_admins: raise HTTPException( status_code=403, - detail="You do not have permission to remove this group from this schema", + detail="You do not have permission to create this schema version", ) - # split the group into namespace and name - group_namespace, group_name = group.group.split("/") - - # run the assignment - agent.schema.group_remove_schema( - namespace=namespace, - name=group_name, - schema_namespace=group_namespace, - schema_name=schema, - ) - - return {"message": "Group removed from schema successfully"} + try: + agent.schema.add_version( + namespace=namespace, + name=schema_name, + version=schema_data.version, + schema_value=schema_data.schema_value, + contributors=schema_data.contributors, + release_notes=schema_data.release_notes, + tags=schema_data.tags, + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." + ) + except SchemaVersionAlreadyExistsError: + raise HTTPException( + status_code=409, + detail=f"Schema version {schema_data.version} already exists for {namespace}/{schema_name}:{schema_data.version}.", + ) + return JSONResponse({"message": "Schema version created successfully"}) -@groups.get("", response_model=SchemaGroupSearchResult) -async def get_all_groups( - namespace: Optional[str] = None, - query: Optional[str] = None, - limit: Optional[int] = 100, - offset: Optional[int] = 0, +@schemas.patch("/{namespace}/{schema_name}/versions/{semantic_version}") +def update_schema_version( + namespace: str, + schema_name: str, + semantic_version: str, + update_fields: UpdateSchemaVersionFields, agent: PEPDatabaseAgent = Depends(get_db), + list_of_admins: Optional[list] = Depends(get_namespace_access_list), + user_name: Optional[str] = Depends(get_user_from_session_info), ): - result = agent.schema.group_search( - namespace=namespace, - search_str=query, - limit=limit, - offset=offset, - ) - return result + """ + Update a schema version + """ + if user_name not in list_of_admins: + raise HTTPException( + status_code=403, + detail="You do not have permission to update this schema version", + ) -@groups.get("/{namespace}", response_model=SchemaGroupSearchResult) -async def get_groups_in_namespace( - namespace: str, - query: Optional[str] = None, - limit: Optional[int] = 100, - offset: Optional[int] = 0, - agent: PEPDatabaseAgent = Depends(get_db), -): - result = agent.schema.group_search( - namespace=namespace, search_str=query, limit=limit, offset=offset - ) - return result + try: + agent.schema.update_schema_version( + namespace=namespace, + name=schema_name, + version=semantic_version, + update_fields=update_fields, + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." + ) + except SchemaVersionDoesNotExistError: + raise HTTPException( + status_code=404, + detail=f"Schema version {semantic_version} not found for {namespace}/{schema_name}.", + ) + + return JSONResponse({"message": "Schema version updated successfully"}) -@groups.get("/{namespace}/{group}", response_model=SchemaGroupAnnotation) -async def get_group( +@schemas.delete("/{namespace}/{schema_name}/versions/{semantic_version}") +async def delete_schema_version( namespace: str, - group: str, + schema_name: str, + semantic_version: str, agent: PEPDatabaseAgent = Depends(get_db), + list_of_admins: Optional[list] = Depends(get_namespace_access_list), + user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Delete a schema version + """ + + if user_name not in list_of_admins: + raise HTTPException( + status_code=403, + detail="You do not have permission to delete this schema version", + ) try: - res = agent.schema.group_get(namespace=namespace, name=group) - return res - except SchemaGroupDoesNotExistError: + agent.schema.delete_version( + namespace=namespace, name=schema_name, version=semantic_version + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." + ) + except SchemaVersionDoesNotExistError: raise HTTPException( - status_code=404, detail=f"Group {group}/{namespace} not found." + status_code=404, + detail=f"Schema version {semantic_version} not found for {namespace}/{schema_name}.", ) + return {"message": "Schema version deleted successfully"} -@groups.post("/{namespace}") -async def create_group_for_namespace( +@schemas.post("/{namespace}/{schema_name}/versions/{semantic_version}/tags") +def add_tags_to_schema_version( namespace: str, - new_group: SchemaGroupCreateRequest, + schema_name: str, + semantic_version: str, + tag: SchemaVersionTagAddModel, agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Add a new tag to a schema version + """ + if user_name not in list_of_admins: raise HTTPException( status_code=403, - detail="You do not have permission to create a group in this namespace", + detail="You do not have permission to add this tag to schema version", ) - try: - agent.schema.group_create( - namespace=namespace, - name=new_group.name, - description=new_group.description, + agent.schema.add_tag_to_schema( + namespace=namespace, name=schema_name, version=semantic_version, tag=tag.tag ) - return { - "message": "Group created successfully", - } - - except SchemaGroupAlreadyExistsError: + except SchemaDoesNotExistError: raise HTTPException( - status_code=409, - detail=f"Group {new_group.name}/{namespace} already exists.", + status_code=404, detail=f"Schema {namespace}/{schema_name} not found." ) + except SchemaVersionDoesNotExistError: + raise HTTPException( + status_code=404, + detail=f"Schema version {semantic_version} not found for {namespace}/{schema_name}.", + ) + return {"message": "Tag added to schema version successfully"} -@groups.delete("/{namespace}/{group}") -async def delete_group( +# remove tags from version +@schemas.delete("/{namespace}/{schema_name}/versions/{semantic_version}/tags") +def remove_tags_from_schema_version( namespace: str, - group: str, + schema_name: str, + semantic_version: str, + tag: str, agent: PEPDatabaseAgent = Depends(get_db), list_of_admins: Optional[list] = Depends(get_namespace_access_list), user_name: Optional[str] = Depends(get_user_from_session_info), ): + """ + Remove a tag from a schema version + """ + if user_name not in list_of_admins: raise HTTPException( - status_code=403, detail="You do not have permission to delete this group" + status_code=403, + detail="You do not have permission to remove this tag from schema version", ) - - agent.schema.group_delete(namespace=namespace, name=group) - return {"message": "Group deleted successfully"} + try: + agent.schema.remove_tag_from_schema( + namespace=namespace, name=schema_name, version=semantic_version, tag=tag + ) + except SchemaDoesNotExistError: + raise HTTPException( + status_code=404, detail=f"Schema {namespace}/{schema_name}:{tag} not found." + ) + except SchemaVersionDoesNotExistError: + raise HTTPException( + status_code=404, + detail=f"Schema version {semantic_version} not found for {namespace}/{schema_name}.", + ) + except SchemaTagDoesNotExistError: + raise HTTPException( + status_code=404, + detail=f"Tag {tag} not found for {namespace}/{schema_name}:{tag}.", + ) + return {"message": "Tag removed from schema version successfully"} diff --git a/pephub/routers/auth/base.py b/pephub/routers/auth/base.py index 94b44726..ba58f1cb 100644 --- a/pephub/routers/auth/base.py +++ b/pephub/routers/auth/base.py @@ -310,7 +310,7 @@ def login_success(request: Request): @auth.get("/session") def get_session_from_jwt( - session_info: Union[dict, None] = Depends(read_authorization_header) + session_info: Union[dict, None] = Depends(read_authorization_header), ): if session_info: return session_info diff --git a/pephub/routers/models.py b/pephub/routers/models.py index 12939b5b..37bb3ee7 100644 --- a/pephub/routers/models.py +++ b/pephub/routers/models.py @@ -1,7 +1,7 @@ -from typing import List, Optional +from typing import List, Optional, Dict, Union from pepdbagent.const import DEFAULT_TAG -from pepdbagent.models import UpdateItems +from pepdbagent.models import UpdateItems, ListOfNamespaceInfo from pydantic import BaseModel, ConfigDict, Field from ..const import DEFAULT_QDRANT_SCORE_THRESHOLD @@ -163,3 +163,42 @@ class SchemaGetResponse(BaseModel): class StandardizerResponse(BaseModel): results: dict = {} + + +# Schemas: +class NewSchemaVersionModel(BaseModel): + """ + Model for creating a new schema version from json + """ + + contributors: str = None + release_notes: str = None + tags: Optional[Union[List[str], str, Dict[str, str], List[Dict[str, str]]]] = ( + None, + ) + version: str + schema_value: dict + + +class NewSchemaRecordModel(NewSchemaVersionModel): + """ + Model for creating a new schema record from json + """ + + schema_name: str + description: str = None + maintainers: str = None + lifecycle_stage: str = None + private: bool = False + + +class SchemaVersionTagAddModel(BaseModel): + """ + Model for adding a tag to a schema version + """ + + tag: Optional[Union[List[str], str, Dict[str, str], List[Dict[str, str]]]] = None + + +class NamespaceInfoReturnModel(ListOfNamespaceInfo): + server: str diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 29f11d50..8603596b 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,7 +1,7 @@ fastapi>=0.108.0 psycopg>=3.1.15 -pepdbagent>=0.11.1 -# pepdbagent @ git+https://github.com/pepkit/pepdbagent.git@schams2.0#egg=pepdbagent +pepdbagent>=0.12.0 +# pepdbagent @ git+https://github.com/pepkit/pepdbagent.git@dev#egg=pepdbagent peppy>=0.40.7 eido>=0.2.4 jinja2>=3.1.2 diff --git a/web/src/api/namespace.ts b/web/src/api/namespace.ts index 99ef85e6..933c2dff 100644 --- a/web/src/api/namespace.ts +++ b/web/src/api/namespace.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import YAML from 'yaml'; -import { BiggestNamespaceResults, Project, ProjectAnnotation, Sample } from '../../types'; +import { BiggestNamespaceResults, Project, ProjectAnnotation, Sample, PaginationResult } from '../../types'; import { constructQueryFromPaginationParams } from '../utils/etc'; const API_HOST = import.meta.env.VITE_API_HOST || ''; @@ -39,8 +39,7 @@ export interface ProjectSubmissionResponse { } export interface BiggestNamespaces { - number_of_namespaces: number; - limit: number; + pagination: PaginationResult; results: BiggestNamespaceResults[]; } @@ -98,7 +97,7 @@ export const getNamespaceInfo = (namespace: string, token: string | null = null) }; export const getBiggestNamespaces = (limit: number) => { - const url = `${API_BASE}/namespaces/info?limit=${limit}`; // note the trailing slash + const url = `${API_BASE}/namespaces?page_size=${limit}`; // note the trailing slash return axios.get(url).then((res) => res.data); }; @@ -368,4 +367,3 @@ export const getStandardizerSchemas = (namespace: string) => { const url = `${API_BASE}/namespaces/${namespace}/standardizer-schemas`; return axios.get(url).then((res) => res.data); }; - diff --git a/web/src/api/schemas.ts b/web/src/api/schemas.ts index a6772567..35e04d98 100644 --- a/web/src/api/schemas.ts +++ b/web/src/api/schemas.ts @@ -1,18 +1,11 @@ import axios from 'axios'; import { constructQueryFromPaginationParams } from '../utils/etc'; +import { PaginationResult } from '../../types'; const API_HOST = import.meta.env.VITE_API_HOST || ''; const API_BASE = `${API_HOST}/api/v1`; -export type Schema = { - namespace: string; - name: string; - last_update_date: string; - submission_date: string; - description: string | undefined; -}; - type PaginationParams = { offset?: number; limit?: number; @@ -21,25 +14,62 @@ type PaginationParams = { order?: 'asc' | 'desc'; }; +export interface Schema { + namespace: string; + schema_name: string; + description: string | undefined; + maintainers: string; + lifecycle_stage: string; + latest_version: string; + private: boolean; + last_update_date: string; +} + type GetSchemasResponse = { - count: number; - limit: number; - offset: number; + pagination: PaginationResult; results: Schema[]; }; type GetSchemaResponse = { - schema: string; + namespace: string; + name: string; description: string; - // private: boolean; + maintainers: string; + lifecycle_stage: string; + private: boolean; last_update_date: string; - submission_date: string; }; +interface SchemaWithVersion { + namespace: string; + name: string; + version: string; + contributors: string; + release_notes: string; + tags: object; + release_date: string; + last_update_date: string; +} + +interface GetSchemaVersionsResponse { + pagination: PaginationResult; + results: SchemaWithVersion[]; +} + +type GetSchemaByVersionResponse = {}; + type CreateSchemaResponse = { message: string; }; +type CreateSchemaVersionResponse = { + message: string; +}; + +type EditSchemaVersionResponse = { + message: string; +}; + type DeleteSchemaResponse = { message: string; }; @@ -49,7 +79,9 @@ type UpdateSchemaResponse = { }; type UpdateSchemaPayload = { - schema?: string; + maintainers?: string; + lifecycleStage?: string; + name?: string; description?: string; isPrivate?: boolean; }; @@ -62,11 +94,23 @@ export const getSchemas = async (params: PaginationParams) => { }; export const getSchema = async (namespace: string, name: string) => { - const url = `${API_BASE}/schemas/${namespace}/${name}?return_type=yaml`; + const url = `${API_BASE}/schemas/${namespace}/${name}?return_type=json`; const { data } = await axios.get(url); return data; }; +export const getSchemaVersions = async (namespace: string, name: string, query: string, tag: string, page: number, page_size: number) => { + const url = `${API_BASE}/schemas/${namespace}/${name}/versions?query=${query}&tag=${tag}&page=${page}&page_size=${page_size}&return_type=json`; + const { data } = await axios.get(url); + return data; +}; + +export const getSchemaByVersion = async (namespace: string, name: string, version: string) => { + const url = `${API_BASE}/schemas/${namespace}/${name}/versions/${version}?return_type=json`; + const { data } = await axios.get(url); + return data; +}; + export const getNamespaceSchemas = async (namespace: string, params: PaginationParams) => { const query = constructQueryFromPaginationParams(params); const url = `${API_BASE}/schemas/${namespace}?${query.toString()}`; @@ -76,16 +120,34 @@ export const getNamespaceSchemas = async (namespace: string, params: PaginationP export const createNewSchema = async ( namespace: string, - name: string, + schemaName: string, description: string, + schemaValue: object, isPrivate: boolean, - schema: string, + contributors: string | undefined, + maintainers: string | undefined, + tags: Record | undefined, + version: string | undefined, + releaseNotes: string | undefined, + lifecycleStage: string | undefined, jwt: string | null, ) => { const url = `${API_BASE}/schemas/${namespace}/json`; const { data } = await axios.post( url, - { namespace: namespace, name, description, schema }, + { + namespace, + schema_name: schemaName, + description, + schema_value: schemaValue, + isPrivate, + contributors: contributors, + maintainers: maintainers, + tags: tags, + version: version || '0.1.0', + release_notes: releaseNotes, + lifecycle_stage: lifecycleStage, + }, { headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` } }, ); return data; @@ -93,22 +155,47 @@ export const createNewSchema = async ( export const createNewSchemaFiles = async ( namespace: string, - name: string | undefined | null, - description: string | undefined | null, + schemaName: string, + description: string, + schemaFile: File, isPrivate: boolean, - schema: File, + contributors: string, + maintainers: string, + tags: Record, + version: string, + releaseNotes: string, + lifecycleStage: string, jwt: string | null, ) => { - const url = `${API_BASE}/schemas/${namespace}/file`; + const url = `${API_BASE}/schemas/${namespace}/files`; + + // Create FormData object for file upload const formData = new FormData(); formData.append('namespace', namespace); - formData.append('schema_file', schema); - name && formData.append('name', name); - description && formData.append('description', description); - - const { data } = await axios.post(url, formData, { - headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}`, 'Content-Type': 'multipart/form-data' }, - }); + formData.append('schema_name', schemaName || ''); + formData.append('description', description || ''); + formData.append('private', isPrivate.toString()); + formData.append('contributors', contributors || ''); + formData.append('maintainers', maintainers || ''); + formData.append('tags', tags ? JSON.stringify(tags) : '{}'); + formData.append('version', version || ''); + formData.append('release_notes', releaseNotes || ''); + formData.append('lifecycle_stage', lifecycleStage || ''); + + // Append the file with the correct field name expected by your backend + formData.append('schema_file', schemaFile); + + // Send FormData with proper headers for multipart/form-data + const { data } = await axios.post( + url, + formData, + { + headers: { + 'Authorization': `Bearer ${jwt || 'NOTAUTHORIZED'}`, + 'Content-Type': 'multipart/form-data' // Let Axios set the correct boundary + } + }, + ); return data; }; @@ -116,6 +203,9 @@ export const deleteSchema = async (namespace: string, name: string, jwt: string const url = `${API_BASE}/schemas/${namespace}/${name}`; const { data } = await axios.delete(url, { headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` }, + params: { + schema: name + } }); return data; }; @@ -123,12 +213,134 @@ export const deleteSchema = async (namespace: string, name: string, jwt: string export const updateSchema = async ( namespace: string, name: string, - updatedSchema: UpdateSchemaPayload, jwt: string | null, + maintainers?: string, + lifecycleStage?: string, + description?: string, + isPrivate?: boolean, ) => { const url = `${API_BASE}/schemas/${namespace}/${name}`; - const { data } = await axios.patch(url, updatedSchema, { + const { data } = await axios.patch(url, { + namespace, + name, + description: description || '', + private: isPrivate, + maintainers: maintainers || '', + lifecycle_stage: lifecycleStage || '' + }, { headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` }, }); return data; }; + +export const createSchemaVersion = async ( + namespace: string, + schemaName: string, + schemaValue: object, + contributors: string | undefined, + tags: Record | undefined, + version: string, + releaseNotes: string | undefined, + jwt: string | null, +) => { + const url = `${API_BASE}/schemas/${namespace}/${schemaName}/versions/json`; + const { data } = await axios.post( + url, + { + namespace, + schema_name: schemaName, + schema_value: schemaValue, + contributors: contributors, + tags: tags, + version: version || '0.1.0', + release_notes: releaseNotes, + }, + { headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` } }, + ); + return data; +}; + +export const createSchemaVersionFiles = async ( + namespace: string, + schemaName: string, + schemaFile: File, + contributors: string, + tags: Record, + version: string, + releaseNotes: string, + jwt: string | null, +) => { + const url = `${API_BASE}/schemas/${namespace}/${schemaName}/versions/files`; + + // Create FormData object for file upload + const formData = new FormData(); + formData.append('namespace', namespace); + formData.append('schema_name', schemaName || ''); + formData.append('contributors', contributors || ''); + formData.append('tags', tags ? JSON.stringify(tags) : '{}'); + formData.append('version', version || ''); + formData.append('release_notes', releaseNotes || ''); + + // Append the file with the correct field name expected by your backend + formData.append('schema_file', schemaFile); + + // Send FormData with proper headers for multipart/form-data + const { data } = await axios.post( + url, + formData, + { + headers: { + 'Authorization': `Bearer ${jwt || 'NOTAUTHORIZED'}`, + 'Content-Type': 'multipart/form-data' // Let Axios set the correct boundary + } + }, + ); + return data; +}; + + +export const updateSchemaVersion = async ( + namespace: string, + schemaName: string, + schemaValue: object | undefined, + contributors: string | undefined, + version: string, + releaseNotes: string | undefined, + jwt: string | null, +) => { + const url = `${API_BASE}/schemas/${namespace}/${schemaName}/versions/${version}`; + const { data } = await axios.patch( + url, + { + namespace, + schema_name: schemaName, + semantic_version: version, + contributors: contributors, + schema_value: schemaValue, + release_notes: releaseNotes, + }, + { headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` } }, + ); + return data; +}; + +export const deleteSchemaVersion = async ( + namespace: string, + schemaName: string, + version: string, + jwt: string | null, +) => { + const url = `${API_BASE}/schemas/${namespace}/${schemaName}/versions/${version}`; + const { data } = await axios.delete( + url, + { + headers: { Authorization: `Bearer ${jwt || 'NOTAUTHORIZED'}` }, + params: { + schema_name: schemaName, + semantic_version: version, + } + }, + + ); + return data; +}; diff --git a/web/src/components/browse/namespace-grid.tsx b/web/src/components/browse/namespace-grid.tsx index 0ca136ec..32f30611 100644 --- a/web/src/components/browse/namespace-grid.tsx +++ b/web/src/components/browse/namespace-grid.tsx @@ -22,14 +22,14 @@ export const NamespaceGrid = (props: Props) => { {namespaces && ( Object.values(namespaces).map((item, index: number) => (
-
+
diff --git a/web/src/components/browse/namespace-long-row.tsx b/web/src/components/browse/namespace-long-row.tsx index e1933e8f..de4c0167 100644 --- a/web/src/components/browse/namespace-long-row.tsx +++ b/web/src/components/browse/namespace-long-row.tsx @@ -58,18 +58,18 @@ export const NamespaceLongRow = (props: Props) => { Object.values(namespaces).map((item, index) => (
{ itemRefs.current[item.namespace] = el; }} + ref={(el) => { itemRefs.current[item.namespace_name] = el; }} className="col-xxl-2 col-lg-3 col-md-4 col-sm-6 flex-shrink-0" style={{ scrollSnapAlign: 'start' }} > -
+
diff --git a/web/src/components/forms/components/combined-error-message.tsx b/web/src/components/forms/components/combined-error-message.tsx index a0780292..9463ce68 100644 --- a/web/src/components/forms/components/combined-error-message.tsx +++ b/web/src/components/forms/components/combined-error-message.tsx @@ -11,9 +11,9 @@ export const CombinedErrorMessage: FC = ({ errors, fo const nameError = errors.name?.message; let msg = null; if (nameError === 'empty') { - msg = 'Schema Name must not be empty.'; + msg = 'Schema Name must not be empty'; } else if (nameError === 'invalid') { - msg = "Schema Name must contain only alphanumeric characters, '.', '-', or '_'."; + msg = "Schema Name must contain only alphanumeric characters, '.', '-', or '_'"; } if (nameError) { return

{msg}

; @@ -24,15 +24,15 @@ export const CombinedErrorMessage: FC = ({ errors, fo let msg = null; if (nameError === 'empty' && !tagError) { - msg = 'Project Name must not be empty.'; + msg = 'Project Name must not be empty'; } else if (nameError === 'invalid' && !tagError) { - msg = "Project Name must contain only alphanumeric characters, '-', or '_'."; + msg = "Project Name must contain only alphanumeric characters, '-', or '_'"; } else if (nameError === 'empty' && tagError === 'invalid') { - msg = "Project Name must not be empty and Tag must contain only alphanumeric characters, '-', or '_'."; + msg = "Project Name must not be empty and Tag must contain only alphanumeric characters, '-', or '_'"; } else if (nameError === 'invalid' && tagError === 'invalid') { - msg = "Project Name and Tag must contain only alphanumeric characters, '-', or '_'."; + msg = "Project Name and Tag must contain only alphanumeric characters, '-', or '_'"; } else if (!nameError && tagError === 'invalid') { - msg = "Project Tag must contain only alphanumeric characters, '-', or '_'."; + msg = "Project Tag must contain only alphanumeric characters, '-', or '_'"; } if (nameError || tagError) { diff --git a/web/src/components/forms/components/key-value-input.tsx b/web/src/components/forms/components/key-value-input.tsx new file mode 100644 index 00000000..3bee1970 --- /dev/null +++ b/web/src/components/forms/components/key-value-input.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; + +// Define types for tag values +type TagValue = string | number | boolean; + +interface KeyValueInputProps { + tags: Record; + onAddTag: (key: string, value: string) => void; + onRemoveTag: (key: string) => void; +} + +export const KeyValueInput = ({ tags, onAddTag, onRemoveTag }: KeyValueInputProps) => { + const [tagKey, setTagKey] = useState(''); + const [tagValue, setTagValue] = useState(''); + + const handleAddTag = () => { + if (tagKey.trim()) { + // Call parent's handler + onAddTag(tagKey, tagValue); + + // Reset inputs + setTagKey(''); + setTagValue(''); + } + }; + + return ( +
+
+ Key + setTagKey(e.target.value)} + /> + + Value + setTagValue(e.target.value)} + /> + + +
+ + {/* Display current tags */} + {Object.keys(tags).length > 0 && ( +
+ {Object.entries(tags).map(([key, value]) => ( + + {String(key)} + {String(value) && : {String(value)}} + onRemoveTag(key)}> + + + + ))} +
+ )} +
+ ); +}; diff --git a/web/src/components/forms/components/schemas-databio-dropdown.tsx b/web/src/components/forms/components/schemas-databio-dropdown.tsx index 359efe91..0cd62e33 100644 --- a/web/src/components/forms/components/schemas-databio-dropdown.tsx +++ b/web/src/components/forms/components/schemas-databio-dropdown.tsx @@ -15,16 +15,18 @@ const SchemaDropdown: FC = ({ value, onChange, showDownload = true }) => const { data: schemas, isFetching: isLoading } = useAllSchemas({}); const options = (schemas?.results || []).map((schema) => ({ - label: `${schema.namespace}/${schema.name}`, - value: `${schema.namespace}/${schema.name}`, + label: `${schema.namespace}/${schema.schema_name}`, + value: `${schema.namespace}/${schema.schema_name}`, })); + const defaultSchema = 'databio/pep-2.1.0'; const valueForSelect = options.find((option) => option.value === value); return (
+
*/} + +
+ + +
+
+
+ + / +
+
+ +
+
+ + + + +