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}