diff --git a/backend/init.sql b/backend/init.sql index afb7be22..2525add2 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -11,40 +11,27 @@ -- Generated columns: -- https://www.postgresql.org/docs/current/ddl-generated-columns.html -/* - * Proteins Table - */ -CREATE TABLE proteins ( - id serial PRIMARY KEY, - name text NOT NULL UNIQUE, -- user specified name of the protein (TODO: consider having a string limit) - length integer, -- length of amino acid sequence - mass numeric, -- mass in amu/daltons - content bytea, -- stored markdown for the protein article (TODO: consider having a limit to how big this can be) - refs bytea -- bibtex references mentioned in the content/article -); - /* * Species Table */ CREATE TABLE species ( id serial PRIMARY KEY, name text NOT NULL UNIQUE -- combined genus and species name, provided for now by the user - -- -- removed now to reduce complexity for v0 - -- tax_genus text NOT NULL, - -- tax_species text NOT NULL, - -- scientific_name text UNIQUE GENERATED ALWAYS AS (tax_genus || ' ' || tax_species) STORED, - -- content bytea ); /* - * Table: species_proteins - * Description: Join table for N:M connection between Species and Proteins + * Proteins Table */ - CREATE TABLE species_proteins ( - species_id serial references species(id) ON UPDATE CASCADE ON DELETE CASCADE, - protein_id serial references proteins(id) ON UPDATE CASCADE ON DELETE CASCADE, - PRIMARY KEY (species_id, protein_id) - ); +CREATE TABLE proteins ( + id serial PRIMARY KEY, + name text NOT NULL UNIQUE, -- user specified name of the protein (TODO: consider having a string limit) + length integer, -- length of amino acid sequence + mass numeric, -- mass in amu/daltons + content bytea, -- stored markdown for the protein article (TODO: consider having a limit to how big this can be) + refs bytea, -- bibtex references mentioned in the content/article + species_id integer NOT NULL, + FOREIGN KEY (species_id) REFERENCES species(id) ON UPDATE CASCADE ON DELETE CASCADE +); /* * Users Table diff --git a/backend/src/api/protein.py b/backend/src/api/protein.py new file mode 100644 index 00000000..10cb5d42 --- /dev/null +++ b/backend/src/api/protein.py @@ -0,0 +1,271 @@ +import logging as log +import os +from base64 import b64decode +from io import StringIO +from Bio.PDB import PDBParser +from Bio.SeqUtils import molecular_weight, seq1 +from ..db import Database, bytea_to_str, str_to_bytea + +from ..api_types import ProteinEntry, UploadBody, UploadError, EditBody +from io import BytesIO +from fastapi import APIRouter +from fastapi.responses import FileResponse, StreamingResponse + +router = APIRouter() + + +class PDB: + def __init__(self, file_contents, name=""): + self.name = name + self.file_contents = file_contents + + try: + self.parser = PDBParser() + self.structure = self.parser.get_structure( + id=name, file=StringIO(file_contents) + ) + except Exception as e: + raise e # raise to the user who calls this PDB class + + @property + def num_amino_acids(self) -> int: + return len(self.amino_acids()) + + @property + def mass_daltons(self): + return molecular_weight(seq="".join(self.amino_acids()), seq_type="protein") + + def amino_acids(self, one_letter_code=True): + return [ + seq1(residue.resname) if one_letter_code else residue.resname + for residue in self.structure.get_residues() + ] + + +def decode_base64(b64_header_and_data: str): + """Converts a base64 string to bytes""" + # only decode after the header (data:application/octet-stream;base64,) + end_of_header = b64_header_and_data.index(",") + b64_data_only = b64_header_and_data[end_of_header:] + return b64decode(b64_data_only).decode("utf-8") + + +def pdb_file_name(protein_name: str): + return os.path.join("src/data/pdbAlphaFold", protein_name) + ".pdb" + + +def parse_protein_pdb(name: str, file_contents: str = "", encoding="str"): + if encoding == "str": + return PDB(file_contents, name) + elif encoding == "b64": + return PDB(decode_base64(file_contents), name) + elif encoding == "file": + return PDB(open(pdb_file_name(name), "r").read(), name) + else: + raise ValueError(f"Invalid encoding: {encoding}") + + +def protein_name_found(name: str): + """Checks if a protein name already exists in the database + Returns: True if exists | False if not exists + """ + with Database() as db: + try: + entry_sql = db.execute_return( + """SELECT name FROM proteins + WHERE name = %s""", + [name], + ) + + # if we got a result back + return entry_sql is not None and len(entry_sql) != 0 + + except Exception: + return False + + +def pdb_to_fasta(pdb: PDB): + return ">{}\n{}".format(pdb.name, "".join(pdb.amino_acids())) + + +@router.get("/protein/pdb/{protein_name:str}") +def get_pdb_file(protein_name: str): + if protein_name_found(protein_name): + return FileResponse(pdb_file_name(protein_name), filename=protein_name + ".pdb") + + +@router.get("/protein/fasta/{protein_name:str}") +def get_fasta_file(protein_name: str): + if protein_name_found(protein_name): + pdb = parse_protein_pdb(protein_name, encoding="file") + fasta = pdb_to_fasta(pdb) + return StreamingResponse( + BytesIO(fasta.encode()), + media_type="text/plain", + headers={ + "Content-Disposition": f"attachment; filename={protein_name}.fasta" + }, + ) + + +@router.get("/protein/entry/{protein_name:str}", response_model=ProteinEntry | None) +def get_protein_entry(protein_name: str): + """Get a single protein entry by its id + Returns: ProteinEntry if found | None if not found + """ + with Database() as db: + try: + query = """SELECT proteins.name, proteins.length, proteins.mass, proteins.content, proteins.refs, species.name as species_name FROM proteins + JOIN species ON species.id = proteins.species_id + WHERE proteins.name = %s;""" + entry_sql = db.execute_return(query, [protein_name]) + log.warn(entry_sql) + + # if we got a result back + if entry_sql is not None and len(entry_sql) != 0: + # return the only entry + only_returned_entry = entry_sql[0] + name, length, mass, content, refs, species_name = only_returned_entry + + # if byte arrays are present, decode them into a string + if content is not None: + content = bytea_to_str(content) + if refs is not None: + refs = bytea_to_str(refs) + + return ProteinEntry( + name=name, + length=length, + mass=mass, + content=content, + refs=refs, + species_name=species_name, + ) + + except Exception as e: + log.error(e) + + +# TODO: add permissions so only the creator can delete not just anyone +@router.delete("/protein/entry/{protein_name:str}", response_model=None) +def delete_protein_entry(protein_name: str): + # Todo, have a meaningful error if the delete fails + with Database() as db: + # remove protein + try: + db.execute( + """DELETE FROM proteins + WHERE name = %s""", + [protein_name], + ) + # delete the file from the data/ folder + os.remove(pdb_file_name(protein_name)) + except Exception as e: + log.error(e) + + +# None return means success +@router.post("/protein/upload", response_model=UploadError | None) +def upload_protein_entry(body: UploadBody): + # check that the name is not already taken in the DB + if protein_name_found(body.name): + return UploadError.NAME_NOT_UNIQUE + + # if name is unique, save the pdb file and add the entry to the database + try: + # TODO: consider somehow sending the file as a stream instead of a b64 string or send as regular string + pdb = parse_protein_pdb(body.name, body.pdb_file_str) + except Exception: + return UploadError.PARSE_ERROR + + try: + # write to file to data/ folder + with open(pdb_file_name(pdb.name), "w") as f: + f.write(pdb.file_contents) + except Exception: + log.warn("Failed to write to file") + return UploadError.WRITE_ERROR + + # save to db + with Database() as db: + try: + # first add the species if it doesn't exist + db.execute( + """INSERT INTO species (name) VALUES (%s) ON CONFLICT DO NOTHING;""", + [body.species_name], + ) + except Exception: + log.warn("Failed to insert into species table") + return UploadError.QUERY_ERROR + + try: + # add the protein itself + query = """INSERT INTO proteins (name, length, mass, content, refs, species_id) + VALUES (%s, %s, %s, %s, %s, (SELECT id FROM species WHERE name = %s));""" + db.execute( + query, + [ + pdb.name, + pdb.num_amino_acids, + pdb.mass_daltons, + str_to_bytea(body.content), + str_to_bytea(body.refs), + body.species_name, + ], + ) + except Exception: + log.warn("Failed to insert into proteins table") + return UploadError.QUERY_ERROR + + +# TODO: add more edits, now only does name and content edits +@router.put("/protein/edit", response_model=UploadError | None) +def edit_protein_entry(body: EditBody): + # check that the name is not already taken in the DB + # TODO: check if permission so we don't have people overriding other people's names + + try: + if body.new_name != body.old_name: + os.rename(pdb_file_name(body.old_name), pdb_file_name(body.new_name)) + + with Database() as db: + name_changed = False + if body.new_name != body.old_name: + db.execute( + """UPDATE proteins SET name = %s WHERE name = %s""", + [ + body.new_name, + body.old_name, + ], + ) + name_changed = True + + if body.new_species_name != body.old_species_name: + db.execute( + """UPDATE proteins SET species_id = (SELECT id FROM species WHERE name = %s) WHERE name = %s""", + [ + body.new_species_name, + body.old_name if not name_changed else body.new_name, + ], + ) + + if body.new_content is not None: + db.execute( + """UPDATE proteins SET content = %s WHERE name = %s""", + [ + str_to_bytea(body.new_content), + body.old_name if not name_changed else body.new_name, + ], + ) + + if body.new_refs is not None: + db.execute( + """UPDATE proteins SET refs = %s WHERE name = %s""", + [ + str_to_bytea(body.new_refs), + body.old_name if not name_changed else body.new_name, + ], + ) + + except Exception: + return UploadError.WRITE_ERROR diff --git a/backend/src/api/search.py b/backend/src/api/search.py new file mode 100644 index 00000000..65f4be78 --- /dev/null +++ b/backend/src/api/search.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter +from fastapi.exceptions import HTTPException +import logging as log +from ..db import Database +from ..api_types import CamelModel, ProteinEntry + +router = APIRouter() + + +class RangeFilter(CamelModel): + min: int | float + max: int | float + + +class SearchProteinsBody(CamelModel): + query: str + species_filter: str | None = None + length_filter: RangeFilter | None = None + mass_filter: RangeFilter | None = None + + +class SearchProteinsResults(CamelModel): + total_found: int + protein_entries: list[ProteinEntry] + + +def sanitize_query(query: str) -> str: + log.warn("todo: sanitize query so we don't get sql injectioned in search.py") + return query + + +def range_where_clause(column_name: str, filter: RangeFilter | None = None) -> str: + if filter is None: + return "" + return f"{column_name} BETWEEN {filter.min} AND {filter.max}" + + +def category_where_clause(column_name: str, filter: str | None = None) -> str: + if filter is None: + return "" + return f"{column_name} = '{filter}'" + + +def combine_where_clauses(clauses: list[str]) -> str: + result = "" + for i, c in enumerate(clauses): + if c != "": + result += c + if i < len(clauses) - 1: + result += " AND " + return result + + +def gen_sql_filters( + species_filter: str | None, + length_filter: RangeFilter | None = None, + mass_filter: RangeFilter | None = None, +) -> str: + filters = [ + category_where_clause("species.name", species_filter), + range_where_clause("proteins.length", length_filter), + range_where_clause("proteins.mass", mass_filter), + ] + return " AND " + combine_where_clauses(filters) if any(filters) else "" + + +@router.post("/search/proteins", response_model=SearchProteinsResults) +def search_proteins(body: SearchProteinsBody): + title_query = sanitize_query(body.query) + with Database() as db: + try: + filter_clauses = gen_sql_filters( + body.species_filter, body.length_filter, body.mass_filter + ) + entries_query = """SELECT proteins.name, proteins.length, proteins.mass, species.name as species_name FROM proteins + JOIN species ON species.id = proteins.species_id + WHERE proteins.name ILIKE %s""" + + log.warn(filter_clauses) + entries_result = db.execute_return( + sanitize_query(entries_query + filter_clauses), + [ + f"%{title_query}%", + ], + ) + if entries_result is not None: + return SearchProteinsResults( + protein_entries=[ + ProteinEntry( + name=name, + length=length, + mass=mass, + species_name=species_name, + ) + for name, length, mass, species_name in entries_result + ], + total_found=len(entries_result), + ) + else: + raise HTTPException(status_code=500) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/search/range/length") +def search_range_length(): + try: + with Database() as db: + query = """SELECT min(length), max(length) FROM proteins""" + entry_sql = db.execute_return(query) + if entry_sql is not None: + return RangeFilter(min=entry_sql[0][0], max=entry_sql[0][1]) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/search/range/mass") +def search_range_mass(): + try: + with Database() as db: + query = """SELECT min(mass), max(mass) FROM proteins""" + entry_sql = db.execute_return(query) + if entry_sql is not None: + return RangeFilter(min=entry_sql[0][0], max=entry_sql[0][1]) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/search/species", response_model=list[str] | None) +def search_species(): + try: + with Database() as db: + query = """SELECT name as species_name FROM species""" + entry_sql = db.execute_return(query) + if entry_sql is not None: + return [d[0] for d in entry_sql] + except Exception: + return diff --git a/backend/src/db.py b/backend/src/db.py index 6287c1c0..211fe2ee 100644 --- a/backend/src/db.py +++ b/backend/src/db.py @@ -47,11 +47,11 @@ def execute( raise Exception(error) from error def execute_return( - self, query: LiteralString | sql.Composed, params: list[Any] | None = None + self, query: LiteralString | sql.Composed | str, params: list[Any] | None = None ): """Executes an SQL query and returns all of the results""" if self.cur is not None: - self.execute(query, params) + self.execute(query, params) # type: ignore return self.cur.fetchall() def __enter__(self): diff --git a/backend/src/protein.py b/backend/src/protein.py deleted file mode 100644 index ab314d66..00000000 --- a/backend/src/protein.py +++ /dev/null @@ -1,104 +0,0 @@ -import logging as log -import os -from base64 import b64decode -from io import StringIO -from Bio.PDB import PDBParser -from Bio.SeqUtils import molecular_weight, seq1 -from .db import Database - - -class PDB: - def __init__(self, file_contents, name=""): - self.name = name - self.file_contents = file_contents - - try: - self.parser = PDBParser() - self.structure = self.parser.get_structure( - id=name, file=StringIO(file_contents) - ) - except Exception as e: - raise e # raise to the user who calls this PDB class - - @property - def num_amino_acids(self) -> int: - return len(self.amino_acids()) - - @property - def mass_daltons(self): - return molecular_weight(seq="".join(self.amino_acids()), seq_type="protein") - - def amino_acids(self, one_letter_code=True): - return [ - seq1(residue.resname) if one_letter_code else residue.resname - for residue in self.structure.get_residues() - ] - - -def decode_base64(b64_header_and_data: str): - """Converts a base64 string to bytes""" - # only decode after the header (data:application/octet-stream;base64,) - end_of_header = b64_header_and_data.index(",") - b64_data_only = b64_header_and_data[end_of_header:] - return b64decode(b64_data_only).decode("utf-8") - - -def pdb_file_name(protein_name: str): - return os.path.join("src/data/pdbAlphaFold", protein_name) + ".pdb" - - -def parse_protein_pdb(name: str, file_contents: str = "", encoding="str"): - if encoding == "str": - return PDB(file_contents, name) - elif encoding == "b64": - return PDB(decode_base64(file_contents), name) - elif encoding == "file": - return PDB(open(pdb_file_name(name), "r").read(), name) - else: - raise ValueError(f"Invalid encoding: {encoding}") - - -def protein_name_found(name: str): - """Checks if a protein name already exists in the database - Returns: True if exists | False if not exists - """ - with Database() as db: - try: - entry_sql = db.execute_return( - """SELECT name FROM proteins - WHERE name = %s""", - [name], - ) - - # if we got a result back - return entry_sql is not None and len(entry_sql) != 0 - - except Exception: - return False - - -def save_protein(pdb: PDB): - path = pdb_file_name(pdb.name) - try: - with open(path, "w") as f: - f.write(pdb.file_contents) - except Exception: - log.warn("could not save") - raise Exception("Could not save pdb file") - - with Database() as db: - try: - db.execute( - """INSERT INTO proteins (name, length, mass) VALUES (%s, %s, %s);""", - [ - pdb.name, - pdb.num_amino_acids, - pdb.mass_daltons, - ], - ) - except Exception as e: - raise e - - -def pdb_to_fasta(pdb: PDB): - return ">{}\n{}".format(pdb.name, "".join(pdb.amino_acids())) diff --git a/backend/src/server.py b/backend/src/server.py index 2c4badb2..5ba52c1d 100644 --- a/backend/src/server.py +++ b/backend/src/server.py @@ -1,275 +1,11 @@ -import logging as log import os -from io import BytesIO -from fastapi.responses import FileResponse, StreamingResponse -from .api_types import ProteinEntry, UploadBody, UploadError, EditBody -from .db import Database, bytea_to_str, str_to_bytea -from .protein import parse_protein_pdb, pdb_file_name, protein_name_found, pdb_to_fasta -from .setup import disable_cors, init_fastapi_app -from .api import users +from .setup import disable_cors, init_fastapi_app, serve_endpoints +from .api import users, search, protein app = init_fastapi_app() disable_cors(app, origins=[os.environ["PUBLIC_FRONTEND_URL"]]) - -app.include_router(users.router) - - -@app.get("/pdb/{protein_name:str}") -def get_pdb_file(protein_name: str): - if protein_name_found(protein_name): - return FileResponse(pdb_file_name(protein_name), filename=protein_name + ".pdb") - - -@app.get("/fasta/{protein_name:str}") -def get_fasta_file(protein_name: str): - if protein_name_found(protein_name): - pdb = parse_protein_pdb(protein_name, encoding="file") - fasta = pdb_to_fasta(pdb) - return StreamingResponse( - BytesIO(fasta.encode()), - media_type="text/plain", - headers={ - "Content-Disposition": f"attachment; filename={protein_name}.fasta" - }, - ) - - -# important to note the return type (response_mode) so frontend can generate that type through `./run.sh api` -@app.get("/all-entries", response_model=list[ProteinEntry] | None) -def get_all_entries(): - """Gets all protein entries from the database - Returns: list[ProteinEntry] if found | None if not found - """ - with Database() as db: - try: - query = """SELECT proteins.name, proteins.length, proteins.mass, species.name as species_name FROM species_proteins - JOIN proteins ON species_proteins.protein_id = proteins.id - JOIN species ON species_proteins.species_id = species.id;""" - entries_sql = db.execute_return(query) - log.warn(entries_sql) - - # if we got a result back - if entries_sql is not None: - return [ - ProteinEntry( - name=name, length=length, mass=mass, species_name=species_name - ) - for name, length, mass, species_name in entries_sql - ] - except Exception as e: - log.error(e) - - -@app.get("/search-entries/{protein_name:str}", response_model=list[ProteinEntry] | None) -def search_entries(protein_name: str): - """Gets a list of protein entries by a search string - Returns: list[ProteinEntry] if found | None if not found - """ - with Database() as db: - try: - query = """SELECT proteins.name, proteins.length, proteins.mass, species.name as species_name FROM species_proteins - JOIN proteins ON species_proteins.protein_id = proteins.id - JOIN species ON species_proteins.species_id = species.id - WHERE proteins.name ILIKE %s;""" - entries_sql = db.execute_return(query, [f"%{protein_name}%"]) - log.warn(entries_sql) - - # if we got a result back - if entries_sql is not None: - return [ - ProteinEntry( - name=name, length=length, mass=mass, species_name=species_name - ) - for name, length, mass, species_name in entries_sql - ] - except Exception as e: - log.error(e) - - -@app.get("/protein-entry/{protein_name:str}", response_model=ProteinEntry | None) -def get_protein_entry(protein_name: str): - """Get a single protein entry by its id - Returns: ProteinEntry if found | None if not found - """ - with Database() as db: - try: - query = """SELECT proteins.name, proteins.length, proteins.mass, proteins.content, proteins.refs, species.name as species_name FROM species_proteins - JOIN proteins ON species_proteins.protein_id = proteins.id - JOIN species ON species_proteins.species_id = species.id - WHERE proteins.name = %s;""" - entry_sql = db.execute_return(query, [protein_name]) - log.warn(entry_sql) - - # if we got a result back - if entry_sql is not None and len(entry_sql) != 0: - # return the only entry - only_returned_entry = entry_sql[0] - name, length, mass, content, refs, species_name = only_returned_entry - - # if byte arrays are present, decode them into a string - if content is not None: - content = bytea_to_str(content) - if refs is not None: - refs = bytea_to_str(refs) - - return ProteinEntry( - name=name, - length=length, - mass=mass, - content=content, - refs=refs, - species_name=species_name, - ) - - except Exception as e: - log.error(e) - - -# TODO: add permissions so only the creator can delete not just anyone -@app.delete("/protein-entry/{protein_name:str}", response_model=None) -def delete_protein_entry(protein_name: str): - # Todo, have a meaningful error if the delete fails - with Database() as db: - # remove protein - try: - db.execute( - """DELETE FROM proteins - WHERE name = %s""", - [protein_name], - ) - # delete the file from the data/ folder - os.remove(pdb_file_name(protein_name)) - except Exception as e: - log.error(e) - - -# None return means success -@app.post("/protein-upload", response_model=UploadError | None) -def upload_protein_entry(body: UploadBody): - # check that the name is not already taken in the DB - if protein_name_found(body.name): - return UploadError.NAME_NOT_UNIQUE - - # if name is unique, save the pdb file and add the entry to the database - try: - # TODO: consider somehow sending the file as a stream instead of a b64 string or send as regular string - pdb = parse_protein_pdb(body.name, body.pdb_file_str) - except Exception: - return UploadError.PARSE_ERROR - - try: - # write to file to data/ folder - with open(pdb_file_name(pdb.name), "w") as f: - f.write(pdb.file_contents) - except Exception: - log.warn("Failed to write to file") - return UploadError.WRITE_ERROR - - # save to db - with Database() as db: - try: - # first add the species if it doesn't exist - db.execute( - """INSERT INTO species (name) VALUES (%s) ON CONFLICT DO NOTHING;""", - [body.species_name], - ) - except Exception: - log.warn("Failed to insert into species table") - return UploadError.QUERY_ERROR - - try: - # add the protein itself - db.execute( - """INSERT INTO proteins (name, length, mass, content, refs) VALUES (%s, %s, %s, %s, %s);""", - [ - pdb.name, - pdb.num_amino_acids, - pdb.mass_daltons, - str_to_bytea(body.content), - str_to_bytea(body.refs), - ], - ) - except Exception: - log.warn("Failed to insert into proteins table") - return UploadError.QUERY_ERROR - - try: - # connect them with the intermediate table - db.execute( - """INSERT INTO species_proteins (species_id, protein_id) - VALUES ((SELECT id FROM species WHERE name = %s), - (SELECT id FROM proteins WHERE name = %s));""", - [body.species_name, body.name], - ) - except Exception: - log.warn("Failed to insert into join table") - return UploadError.QUERY_ERROR - - -# TODO: add more edits, now only does name and content edits -@app.put("/protein-edit", response_model=UploadError | None) -def edit_protein_entry(body: EditBody): - # check that the name is not already taken in the DB - # TODO: check if permission so we don't have people overriding other people's names - - try: - if body.new_name != body.old_name: - os.rename(pdb_file_name(body.old_name), pdb_file_name(body.new_name)) - - with Database() as db: - name_changed = False - if body.new_name != body.old_name: - db.execute( - """UPDATE proteins SET name = %s WHERE name = %s""", - [ - body.new_name, - body.old_name, - ], - ) - name_changed = True - - if body.new_species_name != body.old_species_name: - db.execute( - """UPDATE species_proteins SET species_id = (SELECT id FROM species WHERE name = %s) WHERE protein_id = (SELECT id FROM proteins WHERE name = %s)""", - [ - body.new_species_name, - body.old_name if not name_changed else body.new_name, - ], - ) - - if body.new_content is not None: - db.execute( - """UPDATE proteins SET content = %s WHERE name = %s""", - [ - str_to_bytea(body.new_content), - body.old_name if not name_changed else body.new_name, - ], - ) - - if body.new_refs is not None: - db.execute( - """UPDATE proteins SET refs = %s WHERE name = %s""", - [ - str_to_bytea(body.new_refs), - body.old_name if not name_changed else body.new_name, - ], - ) - - except Exception: - return UploadError.WRITE_ERROR - - -@app.get("/all-species", response_model=list[str] | None) -def get_all_species(): - try: - with Database() as db: - query = """SELECT name as species_name FROM species""" - entry_sql = db.execute_return(query) - if entry_sql is not None: - return [d[0] for d in entry_sql] - except Exception: - return +serve_endpoints(app, modules=[users, search, protein]) def export_app_for_docker(): diff --git a/backend/src/setup.py b/backend/src/setup.py index 14f11b50..96083991 100644 --- a/backend/src/setup.py +++ b/backend/src/setup.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from fastapi.routing import APIRouter from fastapi.middleware.cors import CORSMiddleware from fastapi.routing import APIRoute @@ -14,6 +15,15 @@ def disable_cors(app: FastAPI, origins=["*"]): return app +def serve_endpoints(app: FastAPI, modules): + include_routers(app, [m.router for m in modules]) + + +def include_routers(app: FastAPI, routers: list[APIRouter]): + for r in routers: + app.include_router(r) + + # https://github.com/zeno-ml/zeno/blob/main/zeno/server.py#L52 def custom_generate_unique_id(route: APIRoute): return route.name diff --git a/frontend/src/lib/DelayedSpinner.svelte b/frontend/src/lib/DelayedSpinner.svelte new file mode 100644 index 00000000..beeacbcf --- /dev/null +++ b/frontend/src/lib/DelayedSpinner.svelte @@ -0,0 +1,19 @@ + + +{#if showSpinner} + {text} +{/if} diff --git a/frontend/src/lib/ListProteins.svelte b/frontend/src/lib/ListProteins.svelte index 39291fc5..91e89d77 100644 --- a/frontend/src/lib/ListProteins.svelte +++ b/frontend/src/lib/ListProteins.svelte @@ -56,6 +56,7 @@ padding-top: 15px; box-sizing: border-box; transition: all 0.2s ease-in-out; + align-self: start; } .prot-container:hover { transform: scale(1.02); @@ -87,7 +88,6 @@ display: flex; gap: 20px; flex-wrap: wrap; - overflow-y: scroll; padding: 10px; margin-left: 10px; height: calc(100vh - 100px); diff --git a/frontend/src/lib/ProteinVis.svelte b/frontend/src/lib/ProteinVis.svelte index 26e03b2d..b143184c 100644 --- a/frontend/src/lib/ProteinVis.svelte +++ b/frontend/src/lib/ProteinVis.svelte @@ -50,5 +50,6 @@ #myViewer { float: left; position: relative; + z-index: 999; } diff --git a/frontend/src/lib/RangerFilter.svelte b/frontend/src/lib/RangerFilter.svelte new file mode 100644 index 00000000..ee0d57cf --- /dev/null +++ b/frontend/src/lib/RangerFilter.svelte @@ -0,0 +1,40 @@ + + +
+
+ + { + dispatch("change", { min: +selectedMin, max: +selectedMax }); + }} + /> +
+ +
+ + { + dispatch("change", { min: +selectedMin, max: +selectedMax }); + }} + /> +
+
diff --git a/frontend/src/lib/customStores.ts b/frontend/src/lib/customStores.ts index 10c37def..e69de29b 100644 --- a/frontend/src/lib/customStores.ts +++ b/frontend/src/lib/customStores.ts @@ -1,3 +0,0 @@ -import { writable } from "svelte/store"; - -export const searchBy = writable(""); diff --git a/frontend/src/openapi/index.ts b/frontend/src/openapi/index.ts index f16baf35..010f8998 100644 --- a/frontend/src/openapi/index.ts +++ b/frontend/src/openapi/index.ts @@ -12,6 +12,9 @@ export type { HTTPValidationError } from './models/HTTPValidationError'; export type { LoginBody } from './models/LoginBody'; export type { LoginResponse } from './models/LoginResponse'; export type { ProteinEntry } from './models/ProteinEntry'; +export type { RangeFilter } from './models/RangeFilter'; +export type { SearchProteinsBody } from './models/SearchProteinsBody'; +export type { SearchProteinsResults } from './models/SearchProteinsResults'; export type { UploadBody } from './models/UploadBody'; export { UploadError } from './models/UploadError'; export type { ValidationError } from './models/ValidationError'; diff --git a/frontend/src/openapi/models/EditBody.ts b/frontend/src/openapi/models/EditBody.ts index 214bf290..b0bf7ae9 100644 --- a/frontend/src/openapi/models/EditBody.ts +++ b/frontend/src/openapi/models/EditBody.ts @@ -11,3 +11,4 @@ export type EditBody = { newContent?: (string | null); newRefs?: (string | null); }; + diff --git a/frontend/src/openapi/models/HTTPValidationError.ts b/frontend/src/openapi/models/HTTPValidationError.ts index e218a9f3..c0bcc87c 100644 --- a/frontend/src/openapi/models/HTTPValidationError.ts +++ b/frontend/src/openapi/models/HTTPValidationError.ts @@ -8,3 +8,4 @@ import type { ValidationError } from './ValidationError'; export type HTTPValidationError = { detail?: Array; }; + diff --git a/frontend/src/openapi/models/LoginBody.ts b/frontend/src/openapi/models/LoginBody.ts index bdd5b19b..c1d3fa38 100644 --- a/frontend/src/openapi/models/LoginBody.ts +++ b/frontend/src/openapi/models/LoginBody.ts @@ -7,3 +7,4 @@ export type LoginBody = { email: string; password: string; }; + diff --git a/frontend/src/openapi/models/LoginResponse.ts b/frontend/src/openapi/models/LoginResponse.ts index f3ecf279..fc7826cf 100644 --- a/frontend/src/openapi/models/LoginResponse.ts +++ b/frontend/src/openapi/models/LoginResponse.ts @@ -7,3 +7,4 @@ export type LoginResponse = { token: string; error: string; }; + diff --git a/frontend/src/openapi/models/ProteinEntry.ts b/frontend/src/openapi/models/ProteinEntry.ts index dcfd6f59..95c53a8b 100644 --- a/frontend/src/openapi/models/ProteinEntry.ts +++ b/frontend/src/openapi/models/ProteinEntry.ts @@ -11,3 +11,4 @@ export type ProteinEntry = { content?: (string | null); refs?: (string | null); }; + diff --git a/frontend/src/openapi/models/RangeFilter.ts b/frontend/src/openapi/models/RangeFilter.ts new file mode 100644 index 00000000..09dbf51c --- /dev/null +++ b/frontend/src/openapi/models/RangeFilter.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type RangeFilter = { + min: number; + max: number; +}; + diff --git a/frontend/src/openapi/models/SearchProteinsBody.ts b/frontend/src/openapi/models/SearchProteinsBody.ts new file mode 100644 index 00000000..d87dd3fb --- /dev/null +++ b/frontend/src/openapi/models/SearchProteinsBody.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { RangeFilter } from './RangeFilter'; + +export type SearchProteinsBody = { + query: string; + speciesFilter?: (string | null); + lengthFilter?: (RangeFilter | null); + massFilter?: (RangeFilter | null); +}; + diff --git a/frontend/src/openapi/models/SearchProteinsResults.ts b/frontend/src/openapi/models/SearchProteinsResults.ts new file mode 100644 index 00000000..014e4688 --- /dev/null +++ b/frontend/src/openapi/models/SearchProteinsResults.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ProteinEntry } from './ProteinEntry'; + +export type SearchProteinsResults = { + totalFound: number; + proteinEntries: Array; +}; + diff --git a/frontend/src/openapi/models/UploadBody.ts b/frontend/src/openapi/models/UploadBody.ts index 1b8e388b..2619843c 100644 --- a/frontend/src/openapi/models/UploadBody.ts +++ b/frontend/src/openapi/models/UploadBody.ts @@ -10,3 +10,4 @@ export type UploadBody = { refs: string; pdbFileStr: string; }; + diff --git a/frontend/src/openapi/models/ValidationError.ts b/frontend/src/openapi/models/ValidationError.ts index 0a3e90e9..18997ec7 100644 --- a/frontend/src/openapi/models/ValidationError.ts +++ b/frontend/src/openapi/models/ValidationError.ts @@ -8,3 +8,4 @@ export type ValidationError = { msg: string; type: string; }; + diff --git a/frontend/src/openapi/services/DefaultService.ts b/frontend/src/openapi/services/DefaultService.ts index aa3f7c6d..75a25941 100644 --- a/frontend/src/openapi/services/DefaultService.ts +++ b/frontend/src/openapi/services/DefaultService.ts @@ -6,6 +6,8 @@ import type { EditBody } from '../models/EditBody'; import type { LoginBody } from '../models/LoginBody'; import type { LoginResponse } from '../models/LoginResponse'; import type { ProteinEntry } from '../models/ProteinEntry'; +import type { SearchProteinsBody } from '../models/SearchProteinsBody'; +import type { SearchProteinsResults } from '../models/SearchProteinsResults'; import type { UploadBody } from '../models/UploadBody'; import type { UploadError } from '../models/UploadError'; @@ -17,13 +19,13 @@ export class DefaultService { /** * Login - * @param requestBody - * @returns LoginResponse Successful Response + * @param requestBody + * @returns any Successful Response * @throws ApiError */ public static login( -requestBody: LoginBody, -): CancelablePromise { + requestBody: LoginBody, + ): CancelablePromise<(LoginResponse | null)> { return __request(OpenAPI, { method: 'POST', url: '/users/login', @@ -36,20 +38,19 @@ requestBody: LoginBody, } /** - * Get Pdb File - * @param proteinName - * @returns any Successful Response + * Search Proteins + * @param requestBody + * @returns SearchProteinsResults Successful Response * @throws ApiError */ - public static getPdbFile( -proteinName: string, -): CancelablePromise { + public static searchProteins( + requestBody: SearchProteinsBody, + ): CancelablePromise { return __request(OpenAPI, { - method: 'GET', - url: '/pdb/{protein_name}', - path: { - 'protein_name': proteinName, - }, + method: 'POST', + url: '/search/proteins', + body: requestBody, + mediaType: 'application/json', errors: { 422: `Validation Error`, }, @@ -57,54 +58,74 @@ proteinName: string, } /** - * Get Fasta File - * @param proteinName + * Search Range Length * @returns any Successful Response * @throws ApiError */ - public static getFastaFile( -proteinName: string, -): CancelablePromise { + public static searchRangeLength(): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/fasta/{protein_name}', - path: { - 'protein_name': proteinName, - }, - errors: { - 422: `Validation Error`, - }, + url: '/search/range/length', + }); + } + + /** + * Search Range Mass + * @returns any Successful Response + * @throws ApiError + */ + public static searchRangeMass(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/search/range/mass', }); } /** - * Get All Entries - * Gets all protein entries from the database - * Returns: list[ProteinEntry] if found | None if not found + * Search Species * @returns any Successful Response * @throws ApiError */ - public static getAllEntries(): CancelablePromise<(Array | null)> { + public static searchSpecies(): CancelablePromise<(Array | null)> { return __request(OpenAPI, { method: 'GET', - url: '/all-entries', + url: '/search/species', }); } /** - * Search Entries - * Gets a list of protein entries by a search string - * Returns: list[ProteinEntry] if found | None if not found - * @param proteinName + * Get Pdb File + * @param proteinName + * @returns any Successful Response + * @throws ApiError + */ + public static getPdbFile( + proteinName: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/protein/pdb/{protein_name}', + path: { + 'protein_name': proteinName, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Get Fasta File + * @param proteinName * @returns any Successful Response * @throws ApiError */ - public static searchEntries( -proteinName: string, -): CancelablePromise<(Array | null)> { + public static getFastaFile( + proteinName: string, + ): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/search-entries/{protein_name}', + url: '/protein/fasta/{protein_name}', path: { 'protein_name': proteinName, }, @@ -117,17 +138,17 @@ proteinName: string, /** * Get Protein Entry * Get a single protein entry by its id - * Returns: ProteinEntry if found | None if not found - * @param proteinName + * Returns: ProteinEntry if found | None if not found + * @param proteinName * @returns any Successful Response * @throws ApiError */ public static getProteinEntry( -proteinName: string, -): CancelablePromise<(ProteinEntry | null)> { + proteinName: string, + ): CancelablePromise<(ProteinEntry | null)> { return __request(OpenAPI, { method: 'GET', - url: '/protein-entry/{protein_name}', + url: '/protein/entry/{protein_name}', path: { 'protein_name': proteinName, }, @@ -139,16 +160,16 @@ proteinName: string, /** * Delete Protein Entry - * @param proteinName + * @param proteinName * @returns any Successful Response * @throws ApiError */ public static deleteProteinEntry( -proteinName: string, -): CancelablePromise { + proteinName: string, + ): CancelablePromise { return __request(OpenAPI, { method: 'DELETE', - url: '/protein-entry/{protein_name}', + url: '/protein/entry/{protein_name}', path: { 'protein_name': proteinName, }, @@ -160,16 +181,16 @@ proteinName: string, /** * Upload Protein Entry - * @param requestBody + * @param requestBody * @returns any Successful Response * @throws ApiError */ public static uploadProteinEntry( -requestBody: UploadBody, -): CancelablePromise<(UploadError | null)> { + requestBody: UploadBody, + ): CancelablePromise<(UploadError | null)> { return __request(OpenAPI, { method: 'POST', - url: '/protein-upload', + url: '/protein/upload', body: requestBody, mediaType: 'application/json', errors: { @@ -180,16 +201,16 @@ requestBody: UploadBody, /** * Edit Protein Entry - * @param requestBody + * @param requestBody * @returns any Successful Response * @throws ApiError */ public static editProteinEntry( -requestBody: EditBody, -): CancelablePromise<(UploadError | null)> { + requestBody: EditBody, + ): CancelablePromise<(UploadError | null)> { return __request(OpenAPI, { method: 'PUT', - url: '/protein-edit', + url: '/protein/edit', body: requestBody, mediaType: 'application/json', errors: { @@ -198,16 +219,4 @@ requestBody: EditBody, }); } - /** - * Get All Species - * @returns any Successful Response - * @throws ApiError - */ - public static getAllSpecies(): CancelablePromise<(Array | null)> { - return __request(OpenAPI, { - method: 'GET', - url: '/all-species', - }); - } - } diff --git a/frontend/src/routes/Header.svelte b/frontend/src/routes/Header.svelte index 9f6354e4..bcd97ad7 100644 --- a/frontend/src/routes/Header.svelte +++ b/frontend/src/routes/Header.svelte @@ -1,13 +1,10 @@
@@ -19,37 +16,19 @@ -
+
diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte index af95a379..1a039726 100644 --- a/frontend/src/routes/upload/+page.svelte +++ b/frontend/src/routes/upload/+page.svelte @@ -16,7 +16,7 @@ let species: string[] | null; let selectedSpecies: string = "unknown"; onMount(async () => { - species = await Backend.getAllSpecies(); + species = await Backend.searchSpecies(); }); let name: string = ""; diff --git a/galaxy/upload_all.py b/galaxy/upload_all.py index 38764e82..09da32e6 100644 --- a/galaxy/upload_all.py +++ b/galaxy/upload_all.py @@ -25,7 +25,7 @@ def upload_protein_file(path, name, species_name, content="", refs=""): "refs": refs, "pdb_file_str": pdb_file_str, } - out = requests.post("http://localhost:8000/protein-upload", json=payload) + out = requests.post("http://localhost:8000/protein/upload", json=payload) return out