diff --git a/docs/changelog.md b/docs/changelog.md index dd448749..7e87f6b1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,17 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. +## [0.10.5] - 12-04-2023 + +### Changed + +- optimized web interface fetching of PEP annotation data. + +### Added + +- project annotation endpoint (#234) + + # [0.10.4] - 10-02-2023 ### Fixed diff --git a/pephub/_version.py b/pephub/_version.py index d9b054ab..a67aac09 100644 --- a/pephub/_version.py +++ b/pephub/_version.py @@ -1 +1 @@ -__version__ = "0.10.4" +__version__ = "0.10.5" diff --git a/pephub/const.py b/pephub/const.py index 1f2bba5e..7cb3566c 100644 --- a/pephub/const.py +++ b/pephub/const.py @@ -7,7 +7,6 @@ from platform import python_version from fastapi import __version__ as fastapi_version from pepdbagent import __version__ as pepdbagent_version -from pepdbagent.const import DEFAULT_TAG from secrets import token_hex @@ -131,3 +130,4 @@ CALLBACK_ENDPOINT = "/auth/callback" DEFAULT_PEP_SCHEMA = "pep/2.1.0" +DEFAULT_TAG = "default" diff --git a/pephub/dependencies.py b/pephub/dependencies.py index 6628784f..1effc478 100644 --- a/pephub/dependencies.py +++ b/pephub/dependencies.py @@ -12,18 +12,19 @@ from typing import Union, List, Optional, Dict, Any from datetime import datetime, timedelta -from fastapi import Depends, Header, Form +from fastapi import Depends, Header from fastapi.exceptions import HTTPException from fastapi.security import HTTPBearer from pydantic import BaseModel from pepdbagent import PEPDatabaseAgent from pepdbagent.const import DEFAULT_TAG from pepdbagent.exceptions import ProjectNotFoundError +from pepdbagent.models import AnnotationModel, Namespace from qdrant_client import QdrantClient from qdrant_client.http.exceptions import ResponseHandlingException from sentence_transformers import SentenceTransformer -from .routers.models import AnnotationModel, NamespaceList, Namespace, ForkRequest +from .routers.models import ForkRequest from .const import ( DEFAULT_POSTGRES_HOST, DEFAULT_POSTGRES_PASSWORD, @@ -213,7 +214,6 @@ def get_project_annotation( agent: PEPDatabaseAgent = Depends(get_db), namespace_access_list: List[str] = Depends(get_namespace_access_list), ) -> AnnotationModel: - # TODO: Is just grabbing the first annotation the right thing to do? try: anno = agent.annotation.get( namespace, project, tag, admin=namespace_access_list @@ -226,14 +226,6 @@ def get_project_annotation( ) -# TODO: This isn't used; do we still need it? -def get_namespaces( - agent: PEPDatabaseAgent = Depends(get_db), - user: str = Depends(get_user_from_session_info), -) -> List[NamespaceList]: - yield agent.namespace.get(admin=user) - - def verify_user_can_write_namespace( namespace: str, session_info: Union[dict, None] = Depends(read_authorization_header), @@ -257,8 +249,8 @@ def verify_user_can_write_namespace( def verify_user_can_read_project( - project: str, namespace: str, + project: str, tag: Optional[str] = DEFAULT_TAG, project_annotation: AnnotationModel = Depends(get_project_annotation), session_info: Union[dict, None] = Depends(read_authorization_header), @@ -323,13 +315,13 @@ def verify_user_can_write_project( if session_info is None: raise HTTPException( 401, - f"Please authenticate before editing project.", + "Please authenticate before editing project.", ) # AUTHORIZATION REQUIRED if session_info["login"] != namespace and namespace not in orgs: raise HTTPException( 403, - f"The current authenticated user does not have permission to edit this project.", + "The current authenticated user does not have permission to edit this project.", ) diff --git a/pephub/helpers.py b/pephub/helpers.py index 053a2a92..58ef3183 100644 --- a/pephub/helpers.py +++ b/pephub/helpers.py @@ -1,7 +1,6 @@ -import json from datetime import date from typing import List, Union, Tuple -from fastapi import Response, Depends, UploadFile +from fastapi import Response, UploadFile from fastapi.exceptions import HTTPException from ubiquerg import VersionInHelpParser @@ -54,7 +53,7 @@ def add_subparser(cmd, description): "--config", required=False, dest="config", - help=f"A path to the pepserver config file", + help="A path to the pepserver config file", ) sps["serve"].add_argument( diff --git a/pephub/main.py b/pephub/main.py index fbd37c50..cf236b6c 100644 --- a/pephub/main.py +++ b/pephub/main.py @@ -1,16 +1,12 @@ import logging -import sys -import uvicorn import coloredlogs from fastapi import FastAPI -from fastapi.middleware import Middleware from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from ._version import __version__ as server_v from .const import PKG_NAME, TAGS_METADATA, SPA_PATH -from .helpers import build_parser from .routers.api.v1.base import api as api_base from .routers.api.v1.namespace import namespace as api_namespace from .routers.api.v1.project import project as api_project @@ -18,13 +14,12 @@ from .routers.api.v1.search import search as api_search from .routers.auth.base import auth as auth_router from .routers.eido.eido import router as eido_router -from .middleware import SPA, EnvironmentMiddleware -from .const import LOG_LEVEL_MAP +from .middleware import SPA _LOGGER_PEPDBAGENT = logging.getLogger("pepdbagent") coloredlogs.install( logger=_LOGGER_PEPDBAGENT, - level=logging.WARNING, + level=logging.INFO, datefmt="%b %d %Y %H:%M:%S", fmt="[%(levelname)s] [%(asctime)s] [PEPDBAGENT] %(message)s", ) @@ -32,7 +27,7 @@ _LOGGER_PEPPY = logging.getLogger("peppy") coloredlogs.install( logger=_LOGGER_PEPPY, - level=logging.WARNING, + level=logging.ERROR, datefmt="%b %d %Y %H:%M:%S", fmt="[%(levelname)s] [%(asctime)s] [PEPPY] %(message)s", ) diff --git a/pephub/middleware.py b/pephub/middleware.py index 3f6dcf22..9596ccd6 100644 --- a/pephub/middleware.py +++ b/pephub/middleware.py @@ -4,7 +4,7 @@ from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import Send from .const import SPA_PATH diff --git a/pephub/routers/api/v1/base.py b/pephub/routers/api/v1/base.py index 97ef97a2..2745c861 100644 --- a/pephub/routers/api/v1/base.py +++ b/pephub/routers/api/v1/base.py @@ -1,6 +1,5 @@ from fastapi import APIRouter -from ....dependencies import * from ....const import ALL_VERSIONS diff --git a/pephub/routers/api/v1/namespace.py b/pephub/routers/api/v1/namespace.py index 7002810f..24ac00a1 100644 --- a/pephub/routers/api/v1/namespace.py +++ b/pephub/routers/api/v1/namespace.py @@ -1,17 +1,30 @@ import tempfile import shutil +import json +from typing import List, Optional, Union -from fastapi import APIRouter, File, UploadFile, Request, Depends, Form, Body +import peppy + +from fastapi import APIRouter, File, UploadFile, Request, Depends, Form from fastapi.responses import JSONResponse from peppy import Project from peppy.const import DESC_KEY, NAME_KEY +from pepdbagent import PEPDatabaseAgent from pepdbagent.exceptions import ProjectUniqueNameError from pepdbagent.const import DEFAULT_LIMIT_INFO -from pepdbagent.models import ListOfNamespaceInfo +from pepdbagent.models import ListOfNamespaceInfo, Namespace from typing import Literal from typing_extensions import Annotated -from ....dependencies import * +from ....dependencies import ( + get_db, + get_namespace_info, + get_user_from_session_info, + read_authorization_header, + get_organizations_from_session_info, + get_namespace_access_list, + verify_user_can_write_namespace, +) from ....helpers import parse_user_file_upload, split_upload_files_on_init_file from ....const import ( DEFAULT_TAG, @@ -292,7 +305,7 @@ async def upload_raw_pep( except Exception as e: return JSONResponse( content={ - "message": f"Incorrect raw project was provided. Couldn't initiate peppy object.", + "message": "Incorrect raw project was provided. Couldn't initiate peppy object.", "error": f"{e}", "status_code": 417, }, @@ -313,7 +326,7 @@ async def upload_raw_pep( return JSONResponse( content={ "message": f"Project '{namespace}/{p_project.name}:{tag}' already exists in namespace", - "error": f"Set overwrite=True to overwrite or update project", + "error": "Set overwrite=True to overwrite or update project", "status_code": 409, }, status_code=409, diff --git a/pephub/routers/api/v1/project.py b/pephub/routers/api/v1/project.py index e3fe1231..9b118462 100644 --- a/pephub/routers/api/v1/project.py +++ b/pephub/routers/api/v1/project.py @@ -1,9 +1,10 @@ import eido import yaml import pandas as pd -from io import StringIO -from typing import Callable, Literal, Union -from fastapi import APIRouter +import peppy +from typing import Callable, Literal, Union, Optional +from fastapi import APIRouter, Depends +from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse, PlainTextResponse from peppy import Project from peppy.const import ( @@ -13,15 +14,27 @@ SUBSAMPLE_RAW_LIST_KEY, ) -from ...models import ProjectOptional, ProjectRawModel -from ....helpers import zip_conv_result, get_project_sample_names, zip_pep -from ....dependencies import * -from ....const import SAMPLE_CONVERSION_FUNCTIONS, VALID_UPDATE_KEYS - +from pepdbagent import PEPDatabaseAgent from pepdbagent.exceptions import ProjectUniqueNameError +from pepdbagent.models import AnnotationModel from dotenv import load_dotenv + +from ...models import ProjectOptional, ProjectRawModel, ForkRequest +from ....helpers import zip_conv_result, get_project_sample_names, zip_pep +from ....dependencies import ( + get_db, + get_project, + get_project_annotation, + get_namespace_access_list, + verify_user_can_fork, + verify_user_can_read_project, + DEFAULT_TAG, +) +from ....const import SAMPLE_CONVERSION_FUNCTIONS, VALID_UPDATE_KEYS + + load_dotenv() project = APIRouter( @@ -51,7 +64,7 @@ async def get_a_pep( try: raw_project = ProjectRawModel(**proj) except Exception: - raise HTTPException(500, f"Unexpected project error!") + raise HTTPException(500, "Unexpected project error!") return raw_project.dict(by_alias=False) samples = [s.to_dict() for s in proj.samples] sample_table_index = proj.sample_table_index @@ -92,8 +105,8 @@ async def get_a_pep( summary="Update a PEP", ) async def update_a_pep( - project: str, namespace: str, + project: str, updated_project: ProjectOptional, tag: Optional[str] = DEFAULT_TAG, agent: PEPDatabaseAgent = Depends(get_db), @@ -160,10 +173,10 @@ async def update_a_pep( eido.validate_project( new_project, "http://schema.databio.org/pep/2.1.0.yaml" ) - except Exception as e: + except Exception as _: raise HTTPException( status_code=400, - detail=f"", + detail="Could not validate PEP. Please check your PEP and try again.", ) # if we get through all samples, then update project in the database @@ -308,7 +321,7 @@ async def get_pep_samples( @project.get("/config", summary="Get project configuration file") -async def get_pep_samples( +async def get_pep_config( proj: Union[peppy.Project, dict] = Depends(get_project), format: Optional[Literal["JSON", "String"]] = "JSON", raw: Optional[bool] = False, @@ -507,3 +520,13 @@ async def fork_pep_to_namespace( }, status_code=202, ) + + +@project.get("/annotation") +async def get_project_annotation( + proj_annotation: AnnotationModel = Depends(get_project_annotation), +): + """ + Get project annotation from a certain project and namespace + """ + return proj_annotation diff --git a/pephub/routers/api/v1/search.py b/pephub/routers/api/v1/search.py index dea1f069..42198767 100644 --- a/pephub/routers/api/v1/search.py +++ b/pephub/routers/api/v1/search.py @@ -1,14 +1,20 @@ -from enum import Enum -from fastapi import APIRouter, __version__ as fastapi_version +from typing import List + +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -from peppy import __version__ as peppy_version -from platform import python_version from pepdbagent import PEPDatabaseAgent -from ...._version import __version__ as pephub_version -from ....dependencies import * +from sentence_transformers import SentenceTransformer +from qdrant_client import QdrantClient + +from ....dependencies import ( + get_db, + get_qdrant, + get_sentence_transformer, + get_namespace_access_list, +) from ...models import SearchQuery -from ....const import DEFAULT_QDRANT_COLLECTION_NAME, ALL_VERSIONS +from ....const import DEFAULT_QDRANT_COLLECTION_NAME from dotenv import load_dotenv diff --git a/pephub/routers/api/v1/user.py b/pephub/routers/api/v1/user.py index a44da8e8..4c7c68cf 100644 --- a/pephub/routers/api/v1/user.py +++ b/pephub/routers/api/v1/user.py @@ -1,10 +1,15 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse, JSONResponse from platform import python_version +from pepdbagent import PEPDatabaseAgent + from ...._version import __version__ as pephub_version -from ....dependencies import * +from ....dependencies import ( + get_db, + read_authorization_header, +) from ....const import BASE_TEMPLATES_PATH user = APIRouter(prefix="/api/v1/me", tags=["profile"]) @@ -36,7 +41,7 @@ def profile_data( @user.get("/data") -def profile_data( +def profile_data2( request: Request, session_info=Depends(read_authorization_header), agent: PEPDatabaseAgent = Depends(get_db), diff --git a/pephub/routers/auth/base.py b/pephub/routers/auth/base.py index 4b2d6e16..b0fde5a7 100644 --- a/pephub/routers/auth/base.py +++ b/pephub/routers/auth/base.py @@ -5,7 +5,7 @@ import time from typing import Union from fastapi import APIRouter, Request, Header, BackgroundTasks, Depends -from fastapi.responses import RedirectResponse, Response +from fastapi.responses import RedirectResponse from fastapi.exceptions import HTTPException from fastapi.templating import Jinja2Templates from dotenv import load_dotenv @@ -136,7 +136,7 @@ def callback( if state.get("device"): DEVICE_CODES[state["device_code"]]["token"] = token - return f"/login/device/success" + return "/login/device/success" else: # create random auth code diff --git a/pephub/routers/eido/eido.py b/pephub/routers/eido/eido.py index 9e787ab7..09fcf8ee 100644 --- a/pephub/routers/eido/eido.py +++ b/pephub/routers/eido/eido.py @@ -5,14 +5,16 @@ import shutil import yaml -from fastapi import UploadFile, Form, APIRouter +from fastapi import UploadFile, Form, APIRouter, Depends +from fastapi.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse -from typing import List, Tuple +from typing import List, Tuple, Optional +from pepdbagent import PEPDatabaseAgent from pepdbagent.utils import registry_path_converter from ...helpers import parse_user_file_upload, split_upload_files_on_init_file -from ...dependencies import * +from ...dependencies import get_db, DEFAULT_TAG schemas_url = "https://schema.databio.org/list.json" diff --git a/pephub/routers/models.py b/pephub/routers/models.py index 70ced651..f0ac6f37 100644 --- a/pephub/routers/models.py +++ b/pephub/routers/models.py @@ -1,6 +1,6 @@ -from typing import Optional -from pydantic import BaseModel -from pepdbagent.models import * +from typing import Optional, List +from pydantic import BaseModel, Field, Extra +from pepdbagent.models import UpdateItems from pepdbagent.const import DEFAULT_TAG from ..const import DEFAULT_PEP_SCHEMA diff --git a/pephub/routers/profile.py b/pephub/routers/profile.py deleted file mode 100644 index e998d1dc..00000000 --- a/pephub/routers/profile.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi import APIRouter, Request -from fastapi.templating import Jinja2Templates -from fastapi.responses import RedirectResponse -from platform import python_version - -from .._version import __version__ as pephub_version - -from ..dependencies import * -from ..view_dependencies import * -from ..const import BASE_TEMPLATES_PATH - -router = APIRouter(prefix="/profile", tags=["profile"]) - -templates = Jinja2Templates(directory=BASE_TEMPLATES_PATH) - - -@router.get("/") -def profile( - request: Request, - session_info=Depends(read_session_cookie), - agent: PEPDatabaseAgent = Depends(get_db), -): - """ - Display the user's profile page. - """ - if session_info is None: - return RedirectResponse(url="/auth/login") - else: - namespace_info = agent.namespace.get( - namespace=session_info["login"], admin=session_info["login"] - ) - return templates.TemplateResponse( - "profile.html", - { - "request": request, - "session_info": session_info, - "python_version": python_version(), - "pephub_version": pephub_version, - "logged_in": session_info is not None, - "namespace_info": namespace_info, - "projects": namespace_info.projects, - }, - ) diff --git a/pephub/routers/views/eido.py b/pephub/routers/views/eido.py index f62f5956..0154c844 100644 --- a/pephub/routers/views/eido.py +++ b/pephub/routers/views/eido.py @@ -1,18 +1,14 @@ import jinja2 import eido -import peppy from fastapi import APIRouter, Request -from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.responses import HTMLResponse from starlette.templating import Jinja2Templates from peppy import __version__ as peppy_version -from peppy.const import SAMPLE_RAW_DICT_KEY, CONFIG_KEY from platform import python_version from dotenv import load_dotenv from ..._version import __version__ as pephub_version -from ...dependencies import * -from ...view_dependencies import * from ...const import EIDO_TEMPLATES_PATH diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 33d32f40..415a032c 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -9,7 +9,7 @@ tqdm uvicorn python-dotenv pepdbagent>=0.6.0 -peppy>=0.40.0a4 +peppy>=0.40.0a5 qdrant-client requests aiofiles diff --git a/web/src/api/project.ts b/web/src/api/project.ts index 014d7e22..61e328d1 100644 --- a/web/src/api/project.ts +++ b/web/src/api/project.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -import { Project, ProjectConfigResponse, Sample } from '../../types'; +import { Project, ProjectAnnotation, ProjectConfigResponse, Sample } from '../../types'; const API_HOST = import.meta.env.VITE_API_HOST || ''; const API_BASE = `${API_HOST}/api/v1`; @@ -43,6 +43,20 @@ export const getProject = ( } }; +export const getProjectAnnotation = ( + namespace: string, + projectName: string, + tag: string = 'default', + token: string | null = null, +) => { + const url = `${API_BASE}/projects/${namespace}/${projectName}/annotation?tag=${tag}`; + if (!token) { + return axios.get<ProjectAnnotation>(url).then((res) => res.data); + } else { + return axios.get<ProjectAnnotation>(url, { headers: { Authorization: `Bearer ${token}` } }).then((res) => res.data); + } +}; + export const getSampleTable = ( namespace: string, projectName: string, diff --git a/web/src/hooks/queries/useProjectAnnotation.ts b/web/src/hooks/queries/useProjectAnnotation.ts new file mode 100644 index 00000000..0092c953 --- /dev/null +++ b/web/src/hooks/queries/useProjectAnnotation.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getProjectAnnotation } from '../../api/project'; + +export const useProjectAnnotation = ( + namespace: string | undefined, + project: string | undefined, + tag: string | undefined, + token: string | null, +) => { + const query = useQuery({ + queryKey: [namespace, project, tag, 'annotation'], + queryFn: () => getProjectAnnotation(namespace || '', project || '', tag, token), + enabled: namespace !== undefined || project !== undefined, + }); + return query; +}; diff --git a/web/src/pages/Project.tsx b/web/src/pages/Project.tsx index 0fabbf37..4e9ff5b9 100644 --- a/web/src/pages/Project.tsx +++ b/web/src/pages/Project.tsx @@ -19,7 +19,7 @@ import { useConfigMutation } from '../hooks/mutations/useConfigMutation'; import { useSampleTableMutation } from '../hooks/mutations/useSampleTableMutation'; import { useSubsampleTableMutation } from '../hooks/mutations/useSubsampleTableMutation'; import { useTotalProjectChangeMutation } from '../hooks/mutations/useTotalProjectChangeMutation'; -import { useProject } from '../hooks/queries/useProject'; +import { useProjectAnnotation } from '../hooks/queries/useProjectAnnotation'; import { useProjectConfig } from '../hooks/queries/useProjectConfig'; import { useSampleTable } from '../hooks/queries/useSampleTable'; import { useSubsampleTable } from '../hooks/queries/useSubsampleTable'; @@ -65,7 +65,11 @@ export const ProjectPage: FC = () => { const fork = searchParams.get('fork'); // fetch data - const { data: projectInfo, isLoading: projectInfoIsLoading, error } = useProject(namespace, project || '', tag, jwt); + const { + data: projectInfo, + isLoading: projectInfoIsLoading, + error, + } = useProjectAnnotation(namespace, project || '', tag, jwt); const { data: projectSamples } = useSampleTable(namespace, project, tag, jwt); const { data: projectSubsamples } = useSubsampleTable(namespace, project, tag, jwt); const { data: projectConfig, isLoading: projectConfigIsLoading } = useProjectConfig(