diff --git a/backend/.gitignore b/backend/.gitignore index 912e757d..b9a0cc6c 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,4 @@ __pycache__/ .venv -foldseek/ \ No newline at end of file +foldseek/ +tmalign/ \ No newline at end of file diff --git a/backend/init.sql b/backend/init.sql index 5d505c5e..e1a9245c 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -11,6 +11,8 @@ -- Generated columns: -- https://www.postgresql.org/docs/current/ddl-generated-columns.html +CREATE EXTENSION pg_trgm; -- for trigram matching fuzzy search similarity() func + /* * Species Table */ @@ -25,13 +27,14 @@ CREATE TABLE species ( CREATE TABLE proteins ( id serial PRIMARY KEY, name text NOT NULL UNIQUE, -- user specified name of the protein (TODO: consider having a string limit) - description text, + description text, 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 + content text, -- stored markdown for the protein article (TODO: consider having a limit to how big this can be) + refs text, -- bibtex references mentioned in the content/article species_id integer NOT NULL, thumbnail bytea, -- thumbnail image of the protein in base64 format + date_published timestamp with time zone DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (species_id) REFERENCES species(id) ON UPDATE CASCADE ON DELETE CASCADE ); @@ -46,12 +49,61 @@ CREATE TABLE users ( admin boolean NOT NULL ); + +/* +* Articles Table +*/ +CREATE TABLE articles ( + id serial PRIMARY KEY, + title text NOT NULL UNIQUE, + description text, + date_published timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + refs text -- bibtex references mentioned in the content/article +); +CREATE TABLE components ( + id serial PRIMARY KEY, + article_id integer NOT NULL, + component_order integer NOT NULL, -- where this component is within a particular article + FOREIGN KEY (article_id) REFERENCES articles(id) ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE TABLE text_components ( + id serial PRIMARY KEY, + component_id integer NOT NULL, + + -- component specific info + markdown text DEFAULT '', + + FOREIGN KEY (component_id) REFERENCES components(id) ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE TABLE protein_components ( + id serial PRIMARY KEY, + component_id integer NOT NULL, + + -- component specific info + name text NOT NULL, + aligned_with_name text, + + FOREIGN KEY (component_id) REFERENCES components(id) ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE TABLE image_components ( + id serial PRIMARY KEY, + component_id integer NOT NULL, + + -- component specific info + src bytea NOT NULL, --bytes of the image + width integer, + height integer, + + FOREIGN KEY (component_id) REFERENCES components(id) ON UPDATE CASCADE ON DELETE CASCADE +); + + /* * Inserts example species into species table */ -INSERT INTO species(name) VALUES ('ganaspis hookeri'); -INSERT INTO species(name) VALUES ('leptopilina boulardi'); -INSERT INTO species(name) VALUES ('leptopilina heterotoma'); +INSERT INTO species(name) VALUES ('ganaspis hookeri'); +INSERT INTO species(name) VALUES ('leptopilina boulardi'); +INSERT INTO species(name) VALUES ('leptopilina heterotoma'); INSERT INTO species(name) VALUES ('unknown'); /* @@ -59,4 +111,4 @@ INSERT INTO species(name) VALUES ('unknown'); * Email: test * Password: test */ -INSERT INTO users(username, email, pword, admin) VALUES ('test', 'test', '$2b$12$PFoNf7YM0KNIPP8WGsJjveIOhiJjitsMtfwRcCjdcyTuqjdk/q//u', '1'); \ No newline at end of file +INSERT INTO users(username, email, pword, admin) VALUES ('test', 'test', '$2b$12$PFoNf7YM0KNIPP8WGsJjveIOhiJjitsMtfwRcCjdcyTuqjdk/q//u', '1'); diff --git a/backend/src/api/articles.py b/backend/src/api/articles.py new file mode 100644 index 00000000..07db99d5 --- /dev/null +++ b/backend/src/api/articles.py @@ -0,0 +1,558 @@ +from fastapi import APIRouter +from ..api_types import CamelModel +from ..db import Database, bytea_to_str, str_to_bytea +from fastapi.exceptions import HTTPException +from ..auth import requires_authentication +from fastapi.requests import Request +from .protein import format_protein_name +from typing import Literal + +router = APIRouter() + + +class ArticleImageComponent(CamelModel): + id: int + component_type: str = "image" + component_order: int + src: str + width: int | None = None + height: int | None = None + + +class ArticleTextComponent(CamelModel): + id: int + component_type: str = "text" + component_order: int + markdown: str + + +class ArticleProteinComponent(CamelModel): + id: int + component_type: str = "protein" + component_order: int + name: str + aligned_with_name: str | None = None + + +class Article(CamelModel): + id: int + title: str + description: str | None = None + date_published: str | None = None + refs: str | None = None + ordered_components: list[ + ArticleTextComponent | ArticleProteinComponent | ArticleImageComponent + ] + + +def get_text_components(db: Database, title: str): + query = """SELECT components.id, components.component_order, text_components.markdown FROM components + JOIN text_components ON text_components.component_id = components.id + WHERE components.article_id = (SELECT id FROM articles WHERE title = %s); + """ + res = db.execute_return(query, [title]) + if res is not None: + return [ + ArticleTextComponent(id=i, component_order=c, markdown=m) + for [i, c, m] in res + ] + return [] + + +def get_protein_components(db: Database, title: str): + query = """SELECT components.id, components.component_order, protein_components.name, protein_components.aligned_with_name FROM components + JOIN protein_components ON protein_components.component_id = components.id + WHERE components.article_id = (SELECT id FROM articles WHERE title = %s); + """ + res = db.execute_return(query, [title]) + if res is not None: + return [ + ArticleProteinComponent( + id=i, component_order=c, name=n, aligned_with_name=a + ) + for [i, c, n, a] in res + ] + return [] + + +def get_image_components(db: Database, title: str): + query = """SELECT components.id, components.component_order, image_components.src, image_components.width, image_components.height FROM components + JOIN image_components ON image_components.component_id = components.id + WHERE components.article_id = (SELECT id FROM articles WHERE title = %s); + """ + res = db.execute_return(query, [title]) + if res is not None: + return [ + ArticleImageComponent( + id=i, + component_order=c, + src=bytea_to_str(src_bytes), + width=width, + height=height, + ) + for [i, c, src_bytes, width, height] in res + ] + return [] + + +def get_article_metadata(db: Database, title: str) -> tuple[int, str, str, str]: + query = """SELECT id, description, date_published, refs FROM articles WHERE title = %s;""" + out = db.execute_return(query, [title]) + if out is not None: + [id, description, date_published, refs] = out[0] + return id, description, str(date_published), refs + else: + raise Exception("Nothing returned") + + +@router.get("/article/meta/{title:str}", response_model=Article) +def get_article(title: str): + """get_article + + Args: + title (str): title of the article + + Raises: + HTTPException: status 404 if the article is not found by the given title + HTTPException: status 500 if any other errors occur + + Returns: + Article + """ + + with Database() as db: + try: + # this will fail if the article title does not exist + id, description, date_published, refs = get_article_metadata(db, title) + except Exception as e: + raise HTTPException(404, detail=str(e)) + + try: + text_components = get_text_components(db, title) + protein_components = get_protein_components(db, title) + image_components = get_image_components(db, title) + ordered_components = sorted( + [*text_components, *protein_components, *image_components], + key=lambda x: x.component_order, + ) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + return Article( + id=id, + title=title, + ordered_components=ordered_components, + description=description, + date_published=date_published, + refs=refs, + ) + + +@router.get("/article/all/meta", response_model=list[Article]) +def get_all_articles_metadata(): + with Database() as db: + try: + res = db.execute_return( + """SELECT id, title, description, date_published FROM articles + ORDER BY date_published ASC;""" + ) + if res is not None: + return [ + Article( + id=id, + title=title, + description=description, + date_published=str(date_published), + ordered_components=[], + ) + for [id, title, description, date_published] in res + ] + return [] + except Exception: + raise HTTPException(500, "Error in with selecting articles") + + +class ArticleUpload(CamelModel): + title: str + description: str | None = None + + +@router.post("/article/meta/upload") +def upload_article(body: ArticleUpload, req: Request): + requires_authentication(req) + with Database() as db: + try: + query = """INSERT INTO articles (title, description) VALUES (%s, %s);""" + db.execute(query, [body.title, body.description]) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +@router.delete("/article/meta/{title:str}") +def delete_article(title: str, req: Request): + requires_authentication(req) + with Database() as db: + try: + query = """DELETE FROM articles WHERE title=%s;""" + db.execute(query, [title]) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class EditArticleMetadata(CamelModel): + article_title: str + new_article_title: str | None = None + new_description: str | None = None + new_refs: str | None = None + + +@router.put("/article/meta") +def edit_article_metadata(body: EditArticleMetadata, req: Request): + requires_authentication(req) + with Database() as db: + try: + if ( + body.new_article_title is not None + and body.new_article_title != body.article_title + ): + db.execute( + """UPDATE articles SET title = %s WHERE title = %s;""", + [body.new_article_title, body.article_title], + ) + # for the other queries later + body.article_title = body.new_article_title + + db.execute( + """UPDATE articles SET description = %s WHERE title = %s;""", + [body.new_description, body.article_title], + ) + db.execute( + """UPDATE articles SET refs = %s WHERE title = %s;""", + [body.new_refs, body.article_title], + ) + except Exception: + raise HTTPException(501, detail="Article not unique") + + +def dec_order(db: Database, article_id: int, component_order: int): + # I want to dec components >= component_order at the article_id + db.execute( + """UPDATE components set component_order = component_order - 1 + WHERE article_id = %s AND component_order >= %s;""", + [article_id, component_order], + ) + + +@router.delete("/article/{article_id:int}/component/{component_id:int}") +def delete_article_component(article_id: int, component_id: int, req: Request): + requires_authentication(req) + with Database() as db: + try: + order = get_order_from_component_id(db, component_id) + query = """DELETE FROM components WHERE id=%s;""" + db.execute(query, [component_id]) + dec_order(db, article_id, order) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +def get_last_component_order(db: Database, article_id: int): + out = db.execute_return( + """SELECT coalesce(max(components.component_order) + 1, 0) FROM components + WHERE article_id=%s""", + [article_id], + ) + if out is not None: + return out[0][0] + else: + raise Exception("Not found") + + +def get_component_id_from_order(db: Database, article_id: int, component_order: int): + out = db.execute_return( + """SELECT id FROM components WHERE article_id = %s AND component_order = %s;""", + [article_id, component_order], + ) + if out is not None: + return out[0][0] + else: + raise Exception("Not found") + + +def insert_component(db: Database, article_id: int, component_order: int): + query = """INSERT INTO components (article_id, component_order) + VALUES (%s, %s);""" + db.execute(query, [article_id, component_order]) + return get_component_id_from_order(db, article_id, component_order) + + +def insert_component_to_end(db: Database, article_id: int): + last = get_last_component_order(db, article_id) + return insert_component(db, article_id, last) + + +class UploadArticleTextComponent(CamelModel): + article_id: int + markdown: str + + +@router.post("/article/component/text") +def upload_article_text_component(body: UploadArticleTextComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + component_id = insert_component_to_end(db, body.article_id) + query = """INSERT INTO text_components (component_id, markdown) + VALUES (%s, %s);""" + db.execute(query, [component_id, body.markdown]) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class EditArticleTextComponent(CamelModel): + component_id: int + new_markdown: str + + +@router.put("/article/component/text") +def edit_article_text_component(body: EditArticleTextComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + query = """UPDATE text_components SET markdown=%s WHERE component_id=%s;""" + db.execute(query, [body.new_markdown, body.component_id]) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class UploadArticleProteinComponent(CamelModel): + article_id: int + name: str + aligned_with_name: str | None = None + + +@router.post("/article/component/protein") +def upload_article_protein_component(body: UploadArticleProteinComponent, req: Request): + requires_authentication(req) + + # replaces spaces with underscore, which is how proteins are stored in the DB + body.name = format_protein_name(body.name) + if body.aligned_with_name is not None: + body.aligned_with_name = format_protein_name(body.aligned_with_name) + + with Database() as db: + try: + component_id = insert_component_to_end(db, body.article_id) + query = """INSERT INTO protein_components (component_id, name, aligned_with_name) + VALUES (%s, %s, %s);""" + db.execute(query, [component_id, body.name, body.aligned_with_name]) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class EditArticleProteinComponent(CamelModel): + component_id: int + new_name: str | None = None + new_aligned_with_name: str | None = None + + +@router.put("/article/component/protein") +def edit_article_protein_component(body: EditArticleProteinComponent, req: Request): + requires_authentication(req) + + # replaces spaces with underscore, which is how proteins are stored in the DB + if body.new_name is not None: + body.new_name = format_protein_name(body.new_name) + if body.new_aligned_with_name is not None: + body.new_aligned_with_name = format_protein_name(body.new_aligned_with_name) + + with Database() as db: + try: + query = """UPDATE protein_components SET name=%s, aligned_with_name=%s WHERE component_id=%s;""" + db.execute( + query, + [body.new_name, body.new_aligned_with_name, body.component_id], + ) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class UploadArticleImageComponent(CamelModel): + article_id: int + src: str + width: int | None = None + height: int | None = None + + +@router.post("/article/component/image") +def upload_article_image_component(body: UploadArticleImageComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + component_id = insert_component_to_end(db, body.article_id) + query = """INSERT INTO image_components (component_id, src, height, width) + VALUES (%s, %s, %s, %s);""" + db.execute( + query, + [ + component_id, + str_to_bytea(body.src), + body.width, + body.height, + ], + ) + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +class EditArticleImageComponent(CamelModel): + component_id: int + new_src: str | None = None + new_height: int | None = None + new_width: int | None = None + + +@router.put("/article/component/image") +def edit_article_image_component(body: EditArticleImageComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + if body.new_src is not None: + db.execute( + """UPDATE image_components SET src=%s WHERE component_id=%s;""", + [body.new_src, body.component_id], + ) + + db.execute( + """UPDATE image_components SET width=%s, height=%s WHERE component_id=%s;""", + [body.new_width, body.new_height, body.component_id], + ) + + except Exception as e: + raise HTTPException(500, detail=str(e)) + + +def inc_order(db: Database, article_id: int, component_order: int): + # I want to increment all components >= component_order at the article_id + db.execute( + """UPDATE components set component_order = component_order + 1 + WHERE article_id = %s AND component_order >= %s;""", + [article_id, component_order], + ) + + +def get_order_from_component_id(db: Database, component_id: int): + res = db.execute_return( + """SELECT component_order FROM components WHERE id=%s;""", [component_id] + ) + if res is not None: + return res[0][0] + else: + raise Exception("Couldn't find component") + + +def insert_component_and_shift_rest_down( + db: Database, article_id: int, component_id: int +): + order = get_order_from_component_id(db, component_id) + # shift all the other ones down + inc_order(db, article_id, order) + # then insert at the old place + return insert_component(db, article_id, order) + + +ComponentType = Literal["text", "image", "protein"] + + +def insert_blank_component( + db: Database, component_id: int, component_type: ComponentType +): + if component_type == "text": + db.execute( + "INSERT INTO text_components (component_id, markdown) VALUES (%s, %s);", + [component_id, ""], + ) + elif component_type == "protein": + db.execute( + """INSERT INTO protein_components (component_id, name) VALUES (%s, %s);""", + [component_id, ""], + ) + elif component_type == "image": + db.execute( + """INSERT INTO image_components (component_id, src) VALUES (%s, %s);""", + [component_id, str_to_bytea("")], + ) + + +class InsertComponent(CamelModel): + article_id: int + component_id: int + component_type: ComponentType = "text" + + +@router.post("/article/component/insert/above") +def insert_component_above(body: InsertComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + id = insert_component_and_shift_rest_down( + db, body.article_id, body.component_id + ) + insert_blank_component(db, id, body.component_type) + + except Exception: + raise HTTPException(500, "order shift failed") + + +class InsertBlankComponentEnd(CamelModel): + article_id: int + component_type: ComponentType = "text" + + +@router.post("/article/component/insert/blank") +def insert_blank_component_end(body: InsertBlankComponentEnd): + with Database() as db: + try: + id = insert_component_to_end(db, body.article_id) + insert_blank_component(db, id, body.component_type) + except Exception: + raise HTTPException(500, "order shift failed") + + +def article_length(db: Database, article_id: int): + res = db.execute_return( + """SELECT count(*) FROM components WHERE article_id=%s""", [article_id] + ) + if res is not None: + return res[0][0] + else: + raise Exception("fail db length") + + +class MoveComponent(CamelModel): + article_id: int + component_id: int + direction: Literal["up", "down"] + + +@router.put("/article/component/move") +def move_component(body: MoveComponent, req: Request): + requires_authentication(req) + with Database() as db: + try: + cur_order = get_order_from_component_id(db, body.component_id) + new_order = cur_order + (1 if body.direction == "down" else -1) + if new_order < 0 or new_order >= article_length(db, body.article_id): + raise Exception("cant move out of bounds, so don't swap at all") + + # update the other component with the current one + db.execute( + """UPDATE components SET component_order=%s WHERE article_id=%s AND component_order=%s""", + [cur_order, body.article_id, new_order], + ) + db.execute( + """UPDATE components SET component_order=%s WHERE id=%s""", + [new_order, body.component_id], + ) + except Exception: + raise HTTPException(500, "order shift failed") diff --git a/backend/src/api/protein.py b/backend/src/api/protein.py index f35f9a3e..4e55a169 100644 --- a/backend/src/api/protein.py +++ b/backend/src/api/protein.py @@ -5,9 +5,11 @@ from Bio.PDB import PDBParser from Bio.SeqUtils import molecular_weight, seq1 from ..db import Database, bytea_to_str, str_to_bytea +from fastapi.exceptions import HTTPException from ..api_types import ProteinEntry, UploadBody, UploadError, EditBody, CamelModel -from ..auth import requiresAuthentication +from ..tmalign import tm_align_return +from ..auth import requires_authentication from io import BytesIO from fastapi import APIRouter from fastapi.responses import FileResponse, StreamingResponse @@ -95,11 +97,38 @@ def pdb_to_fasta(pdb: PDB): return ">{}\n{}".format(pdb.name, "".join(pdb.amino_acids())) +def str_as_file_stream(input_str: str, filename_as: str) -> StreamingResponse: + return StreamingResponse( + BytesIO(input_str.encode()), + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename={filename_as}"}, + ) + + +def get_residue_bfactors(pdb: PDB): + chains = {} + for chain in pdb.structure.get_chains(): + chains[chain.get_id()] = [] + for r in chain.get_residues(): + for a in r.get_atoms(): + chains[chain.get_id()].append(a.bfactor) + break + return chains + + """ ENDPOINTS TODO: add the other protein types here instead of in api_types.py """ +@router.get("/protein/pLDDT/{protein_name:str}", response_model=dict[str, list[float]]) +def get_pLDDT_given_protein(protein_name: str): + if protein_name_found(protein_name): + pdb = parse_protein_pdb(protein_name, encoding="file") + return get_residue_bfactors(pdb) + return {} + + class UploadPNGBody(CamelModel): protein_name: str base64_encoding: str @@ -135,18 +164,18 @@ def get_protein_entry(protein_name: str): with Database() as db: try: query = """SELECT proteins.name, - proteins.description, + proteins.description, proteins.length, proteins.mass, proteins.content, proteins.refs, species.name, - proteins.thumbnail + proteins.thumbnail, + proteins.date_published 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: @@ -161,16 +190,17 @@ def get_protein_entry(protein_name: str): refs, species_name, thumbnail, + date_published, ) = 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) if thumbnail is not None: + # if byte arrays are present, decode them into a string thumbnail = bytea_to_str(thumbnail) + if date_published is not None: + # forces the datetime object into a linux utc string + date_published = str(date_published) + return ProteinEntry( name=name, description=description, @@ -180,6 +210,7 @@ def get_protein_entry(protein_name: str): refs=refs, species_name=species_name, thumbnail=thumbnail, + date_published=date_published, ) except Exception as e: @@ -189,7 +220,7 @@ def get_protein_entry(protein_name: str): # 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, req: Request): - requiresAuthentication(req) + requires_authentication(req) # Todo, have a meaningful error if the delete fails with Database() as db: # remove protein @@ -206,7 +237,8 @@ def delete_protein_entry(protein_name: str, req: Request): @router.post("/protein/upload/png", response_model=None) -def upload_protein_png(body: UploadPNGBody): +def upload_protein_png(body: UploadPNGBody, req: Request): + requires_authentication(req) with Database() as db: try: query = """UPDATE proteins SET thumbnail = %s WHERE name = %s""" @@ -218,7 +250,7 @@ def upload_protein_png(body: UploadPNGBody): # None return means success @router.post("/protein/upload", response_model=UploadError | None) def upload_protein_entry(body: UploadBody, req: Request): - requiresAuthentication(req) + requires_authentication(req) body.name = format_protein_name(body.name) # check that the name is not already taken in the DB @@ -263,8 +295,8 @@ def upload_protein_entry(body: UploadBody, req: Request): body.description, pdb.num_amino_acids, pdb.mass_daltons, - str_to_bytea(body.content), - str_to_bytea(body.refs), + body.content, + body.refs, body.species_name, ], ) @@ -273,13 +305,25 @@ def upload_protein_entry(body: UploadBody, req: Request): return UploadError.QUERY_ERROR -# TODO: add more edits, now only does name and content edits -@router.put("/protein/edit", response_model=UploadError | None) +class ProteinEditSuccess(CamelModel): + edited_name: str + + +@router.put("/protein/edit", response_model=ProteinEditSuccess) def edit_protein_entry(body: EditBody, req: Request): + """edit_protein_entry + Returns: On successful edit, will return an object with editedName + If not successful will through an HTTP status 500 + """ + # 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 - requiresAuthentication(req) + requires_authentication(req) try: + # replace spaces in the name with underscores + body.old_name = format_protein_name(body.old_name) + body.new_name = format_protein_name(body.new_name) + if body.new_name != body.old_name: os.rename( stored_pdb_file_name(body.old_name), stored_pdb_file_name(body.new_name) @@ -310,7 +354,7 @@ def edit_protein_entry(body: EditBody, req: Request): db.execute( """UPDATE proteins SET content = %s WHERE name = %s""", [ - str_to_bytea(body.new_content), + body.new_content, body.old_name if not name_changed else body.new_name, ], ) @@ -319,7 +363,7 @@ def edit_protein_entry(body: EditBody, req: Request): db.execute( """UPDATE proteins SET refs = %s WHERE name = %s""", [ - str_to_bytea(body.new_refs), + body.new_refs, body.old_name if not name_changed else body.new_name, ], ) @@ -332,6 +376,24 @@ def edit_protein_entry(body: EditBody, req: Request): body.old_name if not name_changed else body.new_name, ], ) - + return ProteinEditSuccess(edited_name=body.new_name) except Exception: - return UploadError.WRITE_ERROR + raise HTTPException(500, "Edit failed, git gud") + + +# /pdb with two attributes returns both PDBs, superimposed and with different colors. +@router.get("/protein/pdb/{proteinA:str}/{proteinB:str}", response_model=str) +def align_proteins(proteinA: str, proteinB: str): + if not protein_name_found(proteinA) or not protein_name_found(proteinB): + raise HTTPException( + status_code=404, detail="One of the proteins provided is not found in DB" + ) + + try: + filepath_pdbA = stored_pdb_file_name(proteinA) + filepath_pdbB = stored_pdb_file_name(proteinB) + superimposed_pdb = tm_align_return(filepath_pdbA, filepath_pdbB) + return str_as_file_stream(superimposed_pdb, f"{proteinA}_{proteinB}.pdb") + except Exception as e: + log.error(e) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/src/api/search.py b/backend/src/api/search.py index b35488d7..7fc1447f 100644 --- a/backend/src/api/search.py +++ b/backend/src/api/search.py @@ -14,6 +14,9 @@ class SimilarProtein(CamelModel): prob: float evalue: float description: str = "" + qstart: int + qend: int + alntmscore: int class RangeFilter(CamelModel): @@ -26,6 +29,8 @@ class SearchProteinsBody(CamelModel): species_filter: str | None = None length_filter: RangeFilter | None = None mass_filter: RangeFilter | None = None + proteinsPerPage: int | None = None + page: int | None = None class SearchProteinsResults(CamelModel): @@ -74,41 +79,72 @@ def get_descriptions(protein_names: list[str]): def gen_sql_filters( + species_table: str, + proteins_table: str, 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), + category_where_clause(f"{species_table}.name", species_filter), + range_where_clause(f"{proteins_table}.length", length_filter), + range_where_clause(f"{proteins_table}.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) + text_query = sanitize_query(body.query) + limit = 10000 # Limit defaulted to exceed the number of entries in db to grab whole thing. + offset = 0 with Database() as db: try: + # If both the number requested and the page number are present in the request, the limit and offset are set. + # Otherwise, defaults to entire database. + if body.proteinsPerPage is not None and body.page is not None: + limit = body.proteinsPerPage + offset = limit * body.page + filter_clauses = gen_sql_filters( - body.species_filter, body.length_filter, body.mass_filter + "species", + "proteins_scores", + body.species_filter, + body.length_filter, + body.mass_filter, + ) + threshold = 0 + score_filter = ( + f"(proteins_scores.name_score >= {threshold} OR proteins_scores.desc_score >= {threshold} OR proteins_scores.content_score >= {threshold})" # show only the scores > 0 + if len(text_query) > 0 + else "TRUE" # show all scores ) - entries_query = """SELECT proteins.name, - proteins.description, - proteins.length, - proteins.mass, + # cursed shit, edit this at some point + # note that we have a sub query since postgres can't do where clauses on aliased tables + entries_query = """SELECT proteins_scores.name, + proteins_scores.description, + proteins_scores.length, + proteins_scores.mass, species.name, - proteins.thumbnail - FROM proteins - JOIN species ON species.id = proteins.species_id - WHERE proteins.name ILIKE %s""" + proteins_scores.thumbnail + FROM (SELECT *, + similarity(name, %s) as name_score, + similarity(description, %s) as desc_score, + similarity(content, %s) as content_score + FROM proteins) as proteins_scores + JOIN species ON species.id = proteins_scores.species_id + WHERE {} {} + ORDER BY (proteins_scores.name_score*4 + proteins_scores.desc_score*2 + proteins_scores.content_score) DESC + LIMIT {} + OFFSET {}; + """.format( + score_filter, filter_clauses, limit, offset + ) # numbers in order by correspond to weighting + log.warn("EQ:" + entries_query) log.warn(filter_clauses) entries_result = db.execute_return( - sanitize_query(entries_query + filter_clauses), - [ - f"%{title_query}%", - ], + sanitize_query(entries_query), + [text_query, text_query, text_query], ) if entries_result is not None: return SearchProteinsResults( @@ -180,14 +216,21 @@ def search_venome_similar(protein_name: str): similar = easy_search( stored_pdb_file_name(protein_name), venome_folder, - out_format="target,prob,evalue", - )[1:] + out_format="target,prob,evalue,qstart,qend", + ) # qend,qstart refer to alignment formatted = [ - SimilarProtein(name=name.rstrip(".pdb"), prob=prob, evalue=evalue) - for [name, prob, evalue] in similar + SimilarProtein( + name=name.rstrip(".pdb"), + prob=prob, + evalue=evalue, + qstart=qstart, + qend=qend, + alntmscore=0, + ) + for [name, prob, evalue, qstart, qend] in similar ] except Exception: - raise HTTPException(404, "Foldseek not found on the system") + raise HTTPException(404, "Error in 'foldseek easy-search' command") try: # populate protein descriptions for the similar proteins @@ -199,3 +242,42 @@ def search_venome_similar(protein_name: str): raise HTTPException(500, "Error getting protein descriptions") return formatted + + +@router.get( + "/search/venome/similar/{protein_name:str}/{protein_compare:str}", + response_model=SimilarProtein, +) +def search_venome_similar_compare(protein_name: str, protein_compare: str): + target = stored_pdb_file_name(protein_compare) + # ignore the first since it's itself as the most similar + try: + similar = easy_search( + stored_pdb_file_name(protein_name), + target, + out_format="target,prob,evalue,qstart,qend", + ) # qend,qstart refer to alignment + formatted = [ + SimilarProtein( + name=name.rstrip(".pdb"), + prob=prob, + evalue=evalue, + qstart=qstart, + qend=qend, + alntmscore=0, + ) + for [name, prob, evalue, qstart, qend] in similar + ] + except Exception: + raise HTTPException(404, "Error in 'foldseek easy-search' command") + + try: + # populate protein descriptions for the similar proteins + descriptions = get_descriptions([s.name for s in formatted]) + if descriptions is not None: + for f, d in zip(formatted, descriptions): + f.description = d + except Exception: + raise HTTPException(500, "Error getting protein descriptions") + + return formatted[0] diff --git a/backend/src/api/tutorials.py b/backend/src/api/tutorials.py deleted file mode 100644 index 446a22ad..00000000 --- a/backend/src/api/tutorials.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter -from ..api_types import CamelModel - -router = APIRouter() - - -class Tutorial(CamelModel): - title: str - - -@router.get("/tutorials", response_model=list[Tutorial]) -def get_all_tutorials(): - return [ - Tutorial(title="Tutorial 1"), - Tutorial(title="Tutorial 2"), - Tutorial(title="Tutorial 3"), - Tutorial(title="Tutorial 4"), - ] diff --git a/backend/src/api/users.py b/backend/src/api/users.py index 5d96d4d8..72847a8d 100644 --- a/backend/src/api/users.py +++ b/backend/src/api/users.py @@ -3,7 +3,7 @@ from passlib.hash import bcrypt from ..api_types import LoginBody, LoginResponse from ..db import Database -from ..auth import generateAuthToken +from ..auth import generate_auth_token router = APIRouter() @@ -24,7 +24,7 @@ def login(body: LoginBody): if entry_sql is None or len(entry_sql) == 0: return LoginResponse(token="", error="Invalid Email or Password") - # Grabs the stored hash and admin. + # Grabs the stored hash and admin status. password_hash, admin = entry_sql[0] # Returns "incorrect email/password" message if password is incorrect. @@ -32,8 +32,10 @@ def login(body: LoginBody): return LoginResponse(token="", error="Invalid Email or Password") # Generates the token and returns - token = generateAuthToken(email, admin) - log.warn("Giving token:", token) + token = generate_auth_token(email, admin) + log.warn( + f"Giving token: {token}", + ) return LoginResponse(token=token, error="") except Exception as e: diff --git a/backend/src/api_types.py b/backend/src/api_types.py index c15061e1..200bd00e 100644 --- a/backend/src/api_types.py +++ b/backend/src/api_types.py @@ -32,6 +32,7 @@ class ProteinEntry(CamelModel): refs: str | None = None thumbnail: str | None = None description: str | None = None + date_published: str | None = None class AllEntries(CamelModel): @@ -76,3 +77,12 @@ class LoginBody(CamelModel): class LoginResponse(CamelModel): token: str error: str + + +class CompareBody(CamelModel): + proteinA: str + proteinB: str + + +class CompareResponse(CamelModel): + file: list[str] diff --git a/backend/src/auth.py b/backend/src/auth.py index 9d491449..b39249c7 100644 --- a/backend/src/auth.py +++ b/backend/src/auth.py @@ -9,22 +9,22 @@ secret_key = "SuperSecret" -def generateAuthToken(userId, admin): +def generate_auth_token(user_id, admin): payload = { - "email": userId, + "email": user_id, "admin": admin, "exp": datetime.now(tz=timezone.utc) + timedelta(hours=24), } return jwt.encode(payload, secret_key, algorithm="HS256") -def authenticateToken(token): +def authenticate_token(token): # Return the decoded token if it's valid. try: # Valid token is always is in the form "Bearer [token]", so we need to slice off the "Bearer" portion. sliced_token = token[7:] log.warn(sliced_token) - decoded = jwt.decode(sliced_token, secret_key, algorithms="HS256") + decoded = jwt.decode(sliced_token, secret_key, algorithms=["HS256"]) log.warn("Valid token") log.warn(decoded) return decoded @@ -36,9 +36,14 @@ def authenticateToken(token): # Use this function with a request if you want. -def requiresAuthentication(req: Request): - userInfo = authenticateToken(req.headers["authorization"]) - if not userInfo or not userInfo.get("admin"): +def requires_authentication(req: Request): + # no header at all + if "authorization" not in req.headers: + raise HTTPException(status_code=403, detail="Unauthorized") + + # verify token is good if provided + user_info = authenticate_token(req.headers["authorization"]) + if not user_info or not user_info.get("admin"): log.error("Unauthorized User") raise HTTPException(status_code=403, detail="Unauthorized") else: diff --git a/backend/src/db.py b/backend/src/db.py index 211fe2ee..6962534b 100644 --- a/backend/src/db.py +++ b/backend/src/db.py @@ -37,12 +37,12 @@ def disconnect(self): self.conn = None def execute( - 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 nothing""" try: if self.cur is not None: - self.cur.execute(query, params) + self.cur.execute(query, params) # type: ignore except (Exception, psycopg.DatabaseError) as error: raise Exception(error) from error diff --git a/backend/src/foldseek.py b/backend/src/foldseek.py index d9a0a706..40373493 100644 --- a/backend/src/foldseek.py +++ b/backend/src/foldseek.py @@ -69,7 +69,7 @@ def parse_easy_search_output(filepath: str) -> list[list]: def easy_search( query: str, target: str, - out_format: str = "query, target, prob", + out_format: str = "query,target,prob", print_loading_info=False, ) -> list[list]: """easy_search just calls foldseek easy-search under the hood diff --git a/backend/src/server.py b/backend/src/server.py index 082548e7..0058d1ff 100644 --- a/backend/src/server.py +++ b/backend/src/server.py @@ -1,11 +1,11 @@ import os from .setup import disable_cors, init_fastapi_app, serve_endpoints -from .api import users, search, protein, tutorials +from .api import users, search, protein, articles app = init_fastapi_app() disable_cors(app, origins=[os.environ["PUBLIC_FRONTEND_URL"]]) -serve_endpoints(app, modules=[users, search, protein, tutorials]) +serve_endpoints(app, modules=[users, search, protein, articles]) def export_app_for_docker(): diff --git a/backend/src/setup.py b/backend/src/setup.py index 96083991..7caf927e 100644 --- a/backend/src/setup.py +++ b/backend/src/setup.py @@ -33,4 +33,5 @@ def init_fastapi_app() -> FastAPI: app = FastAPI( title="Venome Backend", generate_unique_id_function=custom_generate_unique_id ) + app = disable_cors(app) return app diff --git a/backend/src/tmalign.py b/backend/src/tmalign.py new file mode 100644 index 00000000..ad0f727d --- /dev/null +++ b/backend/src/tmalign.py @@ -0,0 +1,135 @@ +import subprocess +import logging as log +import os + + +def bash_cmd(cmd: str | list[str]) -> str: + return subprocess.check_output(cmd, shell=True).decode() + + +TMALIGN_LOCATION = "/app/tmalign" +TMALIGN_EXECUTABLE = f"{TMALIGN_LOCATION}/tmalign" + + +def assert_tmalign_installed(): + if os.path.exists(TMALIGN_EXECUTABLE): + return + else: + raise ImportError( + "tm align executable not installed. Please install manually - Automatic install TODO." + ) + + +temp_dirs_active = 0 + + +class UniqueTempDir: + """ + on opening scope will create directory of the given name + on closing scope will delete directory of the given name + uses the global `active_caches` above to create a unique dir name + """ + + def __init__(self, base_path): + self.base_path = base_path + + def __enter__(self): + global temp_dirs_active + temp_dirs_active += 1 + self.temp_dir = os.path.join(self.base_path, f"temp_dir_{temp_dirs_active}") + + # create the directory (and override existing one if exists) + bash_cmd("rm -rf " + self.temp_dir) + bash_cmd(f"mkdir {self.temp_dir}") + + return self.temp_dir + + def __exit__(self, *args): + global temp_dirs_active + + # get rid of the temp directory + temp_dirs_active -= 1 + bash_cmd("rm -rf " + self.temp_dir) + + +def tm_align( + protein_A: str, pdbA: str, protein_B: str, pdbB: str, type: str = "_all_atm" +): + """ + Description: + Returns two overlaid, aligned, and colored PDB structures in a single PDB file. + The ones without extensions appear to be PDB files. + + Params: + protein_A: + The name of the first protein. + pdbA: + The filepath of the first protein. + protein_B: + The name of the second protein. + pdbB: + The filepath of the second protein. + type: + The kind of file you want. Experiment with these! Defaults to _all_atm, + which shows alpha helices and beta sheets. Valid options include: + "", "_all", "_all_atm", "_all_atm_lig", "_atm", + ".pml", "_all.pml", "_all_atm.pml", "all_atm_lig.pml", "_atm.pml" + """ + dir_name = protein_A + "-" + protein_B + full_path = f"{TMALIGN_LOCATION}/{dir_name}" + out_file = full_path + "/output" + desired_file = out_file + type + + # If the directory already exists, then we've already run TM align for this protein pair. We can just return the file. + if os.path.exists(full_path): + log.warn(f"Path {full_path} already exists. Do not need to run TM align.") + + # If the directory doesn't exist, then we need to run TM align and generate the files. + else: + log.warn(f"Path {full_path} does not exist. Creating directory and returning.") + cmd = f"{TMALIGN_EXECUTABLE} {pdbA} {pdbB} -o {out_file}" + try: + bash_cmd(f"mkdir {full_path}") + log.warn(f"Attempting to align now with cmd {cmd}") + stdout = bash_cmd(cmd) + log.warn(stdout) + + except Exception as e: + log.warn(e) + raise e + + return desired_file + + +def tm_align_return(pdbA: str, pdbB: str) -> str: + """ + Description: + Returns two overlaid, aligned, and colored PDB structures in a single PDB file. + The ones without extensions appear to be PDB files. + + Params: + pdbA: + The filepath of the first protein. + pdbB: + The filepath of the second protein. + + Returns: the str contents of the pdbA superimposed on pdbB with TMAlgin + """ + + assert_tmalign_installed() + + with UniqueTempDir(base_path=TMALIGN_LOCATION) as temp_dir_path: + try: + output_location = os.path.join(temp_dir_path, "output") + cmd = f"{TMALIGN_EXECUTABLE} {pdbA} {pdbB} -o {output_location}" + bash_cmd(cmd) + + tmalign_pdb_path = f"{output_location}_all_atm" + + with open(tmalign_pdb_path, "r") as tmalign_pdb_file: + tmalign_pdb_file_str = tmalign_pdb_file.read() + return tmalign_pdb_file_str + + except Exception as e: + log.warn(e) + raise e diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 00000000..eafdaa14 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,52 @@ +# Authentication +Venome uses a first-party authentication scheme. + +## Login Flow +1. User goes to login page and provides username and password and presses "Log In". Client sends POST request to backend's */users/login* API endpoint. The request contains JSON with the username and password. See *submitForm()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) +2. Backend verifies provided information against database's username and hashed/salted password If verified, returns a JSON Web Token (JWT) to the frontend, and if not verified, sends an error. See *login()* in [`users.py`](../backend/src/api/users.py) +3. Frontend, if the user is verified: + * Stores the JWT into the browser as a cookie. See *Cookies.set()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + * Sets *user* svelte store **loggedIn** attributes to true. See *$user.loggedIn = true* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + * The JWT cookie and user store are used to access restricted functionality in the website and API. See [`Impelentation Tips`](#implementation-tips). +4. When the user reloads the website, the Frontend checks to see if they're logged in by looking at the browser cookie and sets the *user* store accordingly. See *onMount()* in [`Header.svelte`](../frontend/src/routes/Login.svelte) +5. When the user presses "log out", it sends them back to the login page. This clears the auth cookie from the browser and unsets the *user* store attributes. See *onMount()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + +## Implementation Tips +There are a few functions we created to make it easy to lock elements and endpoints behind authentication. + +### Backend: Locking an API call behind authentication +1. Import *requires_authentication()* from auth.py +2. Call *requires_authentication()* at the top of the API call you want to restrict. + +In /backend/src/auth.py, *requires_authentication()* takes in a Request object as a parameter, checks if it has an authorization header, and validates the contained JWT against the database to determine if the user is an admin. If they aren't an admin, it raises an HTTP Exception; Otherwise, the API call proceeds as normal. + +For an example, see the *upload_tutorial()* in [`tutorials.py`](../backend/src/api/tutorials.py). + +### Frontend: Accessing a locked API call +1. import *setToken()* /lib/backend.ts +2. Call *setToken()* before making the restricted API call. + +In /frontend/src/lib/backend.ts, *setToken()* reads the authentication JWT stored in the user's browser cookie, and sets the TOKEN header for outgoing HTTP requests to that token. + +For an example, look at [`Edit.svelte`](../frontend/src/routes/Edit.svelte) and search for setToken. + +### Frontend: Hiding pages or elements if the user isn't logged in +1. Check if *$user.loggedIn* is true or false. +2. Hide the element or redirect as needed. + +To track whether a user is logged in for the purposes of hiding elements, we use a Svelte store called "user" (defined in [`user.ts`](../frontend/stores/user.ts)). This store has an attribute called **loggedIn.** The **loggedIn** attribute is set to *true* either when the user has just logged in, or if they open the website while they have an authentication cookie stored. The attribute is set to *false* when the user logs out and defaults to *false* if the site is reloaded. + +Svelte provides an easy shorthand to interface with a svelte store; You can simply type in "$user.*attribute*" to look at the contents of any store attribute. The other user store attributes (*username*, *admin*, etc.) could be used in a similar way in the future, but we are not using them at this time. + +You can see some examples of use-cases in [`Header.svelte`](../frontend/src/lib/Header.svelte) and [`Upload.svelte`](../frontend/src/routes/Upload.svelte); Just search for *$user*. + +## Concerns and To-Dos +* Originally, we had a choice between using first-party or third-party authentication. We settled on using first-party authentication because it was quicker and easier to implement, but third-party authentication through something like Google would be a more secure choice. The Oauth 2 protocol (which services like Google use for third-party authentication) uses JWT-based authentication similar to what we did here, so some of the code we've already written could be adapted for use in third-party authentication. +* Our frontend and backend currently communicate over HTTP instead of HTTPS. This is mostly a concern where we send usernames and passwords (e.g. the /users/login API endpoint), but this should probably be changed even if switching to third-party authentication. +* The authentication token has an "exp" which lists a 24 hour expiration date (see generate_auth_token() in auth.py), but we don't use this anywhere. This can either be used in the future, or *probably* safely removed. +* Right now, we're only using the **loggedIn** attribute of the user store. While we do have attributes like **admin** and **username**, they are untested and probably have bugs. +* We implemented this authentication scheme with limited security experience. As such, there may be more flaws than listed here which we did not consider. + +## Library References +* [`PyJWT`](https://github.com/jpadilla/pyjwt) for JWT generation and verification in the backend. +* [`js-cookie`](https://github.com/js-cookie/js-cookie) for securely storing JWTs in browser cookies in the frontend. \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index b222465a..9c8f2a20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "bibtex": "^0.9.0", + "d3": "^7.9.0", "js-cookie": "^3.0.5", "marked": "^12.0.0", "svelte-routing": "^2.11.0" diff --git a/frontend/src/Router.svelte b/frontend/src/Router.svelte index c22c1d5b..b5be3728 100644 --- a/frontend/src/Router.svelte +++ b/frontend/src/Router.svelte @@ -3,13 +3,18 @@ import Header from "./lib/Header.svelte"; import Home from "./routes/Home.svelte"; import Search from "./routes/Search.svelte"; - import Upload from "./routes/Upload.svelte"; + import UploadProtein from "./routes/UploadProtein.svelte"; import Login from "./routes/Login.svelte"; import Protein from "./routes/Protein.svelte"; import Error from "./routes/Error.svelte"; import Edit from "./routes/Edit.svelte"; - import Tutorials from "./routes/Tutorials.svelte"; import ForceUploadThumbnails from "./routes/ForceUploadThumbnails.svelte"; + import Align from "./routes/Align.svelte"; + import UploadArticle from "./routes/UploadArticle.svelte"; + import Article from "./routes/Article.svelte"; + import Articles from "./routes/Articles.svelte"; + import EditArticle from "./routes/EditArticle.svelte"; + import Upload from "./routes/Upload.svelte"; @@ -17,16 +22,45 @@
+ - - + + + - + + + + + + +
+
+ + + + + +
+ + diff --git a/frontend/src/app.pcss b/frontend/src/app.pcss index 64866d6a..4f4e1053 100644 --- a/frontend/src/app.pcss +++ b/frontend/src/app.pcss @@ -4,25 +4,24 @@ @tailwind base; :root { - --darkblue-hsl: 205, 57%, 23%; - --darkblue: hsla(var(--darkblue-hsl), 1); - - --lightblue-hsl: 198, 41%, 54%; - --lightblue: hsla(var(--lightblue-hsl), 1); - - --darkorange-hsl: 27, 77%, 55%; - --darkorange: hsla(var(--darkorange-hsl), 1); - - --lightorange-hsl: 38, 83%, 60%; - --lightorange: hsla(var(--lightorange-hsl), 1); + /* color range which defines flowbite and tailwind primary colors (see tailwind.config.cjs) */ + --primary-50: #f2f8fd; + --primary-100: #e4f0fa; + --primary-200: #c4e1f3; + --primary-300: #8fc8ea; + --primary-400: #54abdc; + --primary-500: #2d91ca; + --primary-600: #1e74ab; + /* flowbite uses this 700 for 'primary' variants on components */ + --primary-700: #194f73; + --primary-800: #194f73; + --primary-900: #19405c; + --primary-950: #112b40; } :root { --font-body: "Inter Variable", sans-serif; --font-mono: "Fira Mono", monospace; - --color-bg-0: rgb(202, 216, 228); - --color-bg-1: hsl(209, 36%, 86%); - --color-bg-2: hsl(224, 44%, 95%); --color-text: rgb(69, 64, 60); --column-width: 42rem; --column-margin-top: 4rem; @@ -71,7 +70,7 @@ p { } a { - color: var(--lightblue); + color: var(--primary-600); } a:hover { @@ -120,5 +119,11 @@ button:focus:not(:focus-visible) { white-space: nowrap; } +.large-text { + font-size: 2.45rem; + font-weight: 500; + color: var(--primary-700); +} + @tailwind components; @tailwind utilities; diff --git a/frontend/src/lib/AlignBlock.svelte b/frontend/src/lib/AlignBlock.svelte new file mode 100644 index 00000000..08400bab --- /dev/null +++ b/frontend/src/lib/AlignBlock.svelte @@ -0,0 +1,42 @@ + + + + + + {qend} + {qstart} + diff --git a/frontend/src/lib/DelayedSpinner.svelte b/frontend/src/lib/DelayedSpinner.svelte index beeacbcf..fb22c054 100644 --- a/frontend/src/lib/DelayedSpinner.svelte +++ b/frontend/src/lib/DelayedSpinner.svelte @@ -4,6 +4,7 @@ export let msDelay = 500; export let spinnerProps = {}; export let text = ""; + export let textRight = false; let showSpinner = false; @@ -15,5 +16,13 @@ {#if showSpinner} - {text} +
+ {#if textRight} + +

{text}

+ {:else} +

{text}

+ + {/if} +
{/if} diff --git a/frontend/src/lib/Dot.svelte b/frontend/src/lib/Dot.svelte new file mode 100644 index 00000000..ecf185d1 --- /dev/null +++ b/frontend/src/lib/Dot.svelte @@ -0,0 +1,24 @@ + + +
+ + + +
+ + diff --git a/frontend/src/lib/EntryCard.svelte b/frontend/src/lib/EntryCard.svelte index 00011d09..ffefbe73 100644 --- a/frontend/src/lib/EntryCard.svelte +++ b/frontend/src/lib/EntryCard.svelte @@ -7,6 +7,6 @@ class="max-w-full mt-5" style="padding-top: 15px; color: var(--color-text);" > -

{title}

+

{title}

diff --git a/frontend/src/lib/Header.svelte b/frontend/src/lib/Header.svelte index a869b4c5..bbda5bba 100644 --- a/frontend/src/lib/Header.svelte +++ b/frontend/src/lib/Header.svelte @@ -3,24 +3,29 @@ import { links } from "svelte-routing"; import { onMount } from "svelte"; import { - UploadOutline, UserOutline, - TableRowOutline, - BookOutline, + SearchOutline, + NewspapperSolid, + UploadSolid, + SearchSolid, } from "flowbite-svelte-icons"; - import {user} from "./stores/user" - import Cookies from "js-cookie" + import { user } from "./stores/user"; + import Cookies from "js-cookie"; + import ProteinIcon from "../lib/ProteinIcon.svelte"; - // Checking if the user has a cookie. - // If they do, set user status for the whole site. onMount(async () => { + /** + * Checking if the user has a cookie. If they do, set user svelte store loggin attribute. + * Done here because the header is loaded first, which means user can still directly navigate + * to restricted pages if they refresh while logged in. + */ if (Cookies.get("auth")) { - $user.loggedIn = true + $user.loggedIn = true; } }); -
+
- {#if $user.loggedIn} - Logout - {:else} - Login - {/if} - + {#if $user.loggedIn} + Logout + {:else} + Login + {/if} +
@@ -91,9 +95,9 @@ } a { - color: var(--darkblue); + color: var(--primary-700); } a:hover { - color: var(--darkblue); + color: var(--primary-800); } diff --git a/frontend/src/lib/ListProteins.svelte b/frontend/src/lib/ListProteins.svelte index c6ecfeb5..5b604ede 100644 --- a/frontend/src/lib/ListProteins.svelte +++ b/frontend/src/lib/ListProteins.svelte @@ -22,6 +22,7 @@ >
thumbnail .prot-container { - --border-opacity: 0.3; + --border-opacity: 0.2; display: flex; - outline: hsla(var(--darkblue-hsl), var(--border-opacity)) 1px solid; + outline: hsla(0, 0%, 0%, var(--border-opacity)) 1px solid; border-radius: 5px; width: 500px; padding-left: 15px; @@ -67,7 +68,7 @@ } .prot-container:hover { transform: scale(1.02); - --border-opacity: 0.5; + --border-opacity: 0.3; box-shadow: 0 1px 2px 2px #00000010; cursor: pointer; } @@ -77,7 +78,7 @@ } .prot-name { font-size: 1.5em; - color: var(--darkblue); + color: var(--primary-700); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/frontend/src/lib/Molstar.svelte b/frontend/src/lib/Molstar.svelte new file mode 100644 index 00000000..e4d67fd1 --- /dev/null +++ b/frontend/src/lib/Molstar.svelte @@ -0,0 +1,103 @@ + + +
+ + diff --git a/frontend/src/lib/ProteinIcon.svelte b/frontend/src/lib/ProteinIcon.svelte new file mode 100644 index 00000000..c02bfb2d --- /dev/null +++ b/frontend/src/lib/ProteinIcon.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/frontend/src/lib/ProteinLinkCard.svelte b/frontend/src/lib/ProteinLinkCard.svelte new file mode 100644 index 00000000..7eb3b919 --- /dev/null +++ b/frontend/src/lib/ProteinLinkCard.svelte @@ -0,0 +1,47 @@ + + + + +
+ {entry.description} +
+
+ Organism: {entry.speciesName} +
+
+ Length: {entry.length} residues +
+
+ Mass: {numberWithCommas(entry.mass, 0)} Da +
+
+ + diff --git a/frontend/src/lib/ProteinVis.svelte b/frontend/src/lib/ProteinVis.svelte deleted file mode 100644 index 98943dee..00000000 --- a/frontend/src/lib/ProteinVis.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - -
- - diff --git a/frontend/src/lib/References.svelte b/frontend/src/lib/References.svelte index fb713b08..acd49686 100644 --- a/frontend/src/lib/References.svelte +++ b/frontend/src/lib/References.svelte @@ -7,6 +7,7 @@ $: { try { bib = parseBibFile(bibtex); + console.log(bib.content); } catch (e) { console.log("error in syntax"); } @@ -35,11 +36,11 @@ {#if bib} {#each bib.entries_raw as entry, i}
0 ? "mt-5" : ""} id={entry._id}> -
- [{entry._id}] -
+ + [{entry._id}] + {#if entry.getFieldAsString("url")} diff --git a/frontend/src/lib/SimilarProteins.svelte b/frontend/src/lib/SimilarProteins.svelte index a7d797f7..2be3d358 100644 --- a/frontend/src/lib/SimilarProteins.svelte +++ b/frontend/src/lib/SimilarProteins.svelte @@ -1,50 +1,116 @@ -
- - - - - - - - {#each similarProteins as protein} - - - - - +{#if similarProteins === undefined && !errorEncountered} + +{:else if similarProteins !== undefined} +
+
Name Probability Match E-Value Description
- - - {undoFormatProteinName(protein.name)} - {protein.prob}{protein.evalue}{protein.description}
+ + + + + + - {/each} -
Name E-Value Prob. Match Region of Similarity TMAlign
-
+ {#each similarProteins as protein, i} + + +
+ + +
+ + {protein.evalue.toExponential()} +
+ +
+ + {protein.prob} +
+ +
+ +
+ + +
+ Align +
+ + + {/each} + +
+{:else} + Error in the in the backend. Please contact admins. +{/if} diff --git a/frontend/src/lib/article/ArticleRenderer.svelte b/frontend/src/lib/article/ArticleRenderer.svelte new file mode 100644 index 00000000..2fd3ef13 --- /dev/null +++ b/frontend/src/lib/article/ArticleRenderer.svelte @@ -0,0 +1,30 @@ + + +{JSON.stringify(article, null, 4)} + + + + diff --git a/frontend/src/lib/article/CreateComponent.svelte b/frontend/src/lib/article/CreateComponent.svelte new file mode 100644 index 00000000..73356401 --- /dev/null +++ b/frontend/src/lib/article/CreateComponent.svelte @@ -0,0 +1,9 @@ + + +
{defaultType}
+ + diff --git a/frontend/src/lib/article/EditMode.svelte b/frontend/src/lib/article/EditMode.svelte new file mode 100644 index 00000000..4a9aba7e --- /dev/null +++ b/frontend/src/lib/article/EditMode.svelte @@ -0,0 +1,216 @@ + + + + +
{ + if (!editMode) { + revealEdit = true; + } + }} + on:mouseleave={() => { + revealEdit = false; + dropdownOpen = false; + }} + class:editing={editMode} + class="edit-container" + style={revealEdit && !forceHideEdit + ? "background-color: var(--primary-100);" + : ""} +> + {#if editMode && !forceHideEdit} + +
+
+ + +
+ +
+ {:else} + + {/if} + {#if revealEdit && !forceHideEdit} +
+
+ + + + + {#each Object.entries(InsertComponent.componentType) as [name, t]} + { + try { + setToken(); + await Backend.insertComponentAbove({ + articleId, + componentId, + componentType: t, + }); + dropdownOpen = false; + revealEdit = false; + dispatch("change"); + } catch (e) { + console.error(e); + } + }}>{name} Component + {/each} + + + + +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/article/ImageComponent.svelte b/frontend/src/lib/article/ImageComponent.svelte new file mode 100644 index 00000000..439ca466 --- /dev/null +++ b/frontend/src/lib/article/ImageComponent.svelte @@ -0,0 +1,92 @@ + + + { + try { + setToken(); + await Backend.editArticleImageComponent({ + componentId: id, + newSrc: file ? await fileToBase64String(file) : NO_FILE_INPUT, + newHeight: hasNoInput(editedHeight) ? AUTO_SIZE : +editedHeight, + newWidth: hasNoInput(editedWidth) ? AUTO_SIZE : +editedWidth, + }); + dispatch("change"); + } catch (e) { + console.error(e); + } + }} + on:change={() => { + dispatch("change"); + }} +> + + {#if src !== ""} + + {:else} + + {/if} + + +
+
+ + +
+
+ width +
+
+ height +
+
+
+
+ + diff --git a/frontend/src/lib/article/Placeholder.svelte b/frontend/src/lib/article/Placeholder.svelte new file mode 100644 index 00000000..7b6dc824 --- /dev/null +++ b/frontend/src/lib/article/Placeholder.svelte @@ -0,0 +1,25 @@ + + +
+ {name} placeholder +
+ + diff --git a/frontend/src/lib/article/ProteinComponent.svelte b/frontend/src/lib/article/ProteinComponent.svelte new file mode 100644 index 00000000..c8356349 --- /dev/null +++ b/frontend/src/lib/article/ProteinComponent.svelte @@ -0,0 +1,129 @@ + + + { + setToken(); + await Backend.editArticleProteinComponent({ + componentId: id, + newName: editedName ?? undefined, + newAlignedWithName: editedAlignedWithName ?? undefined, + }); + dispatch("change"); + }} + on:cancel={() => { + editedName = name; + editedAlignedWithName = alignedWithName; + }} + on:change={() => { + dispatch("change"); + }} +> + + {#if entry !== null} +
+
+ {#if entry} +
+ +
+ {/if} + {#if alignEntry} +
+ +
+ {/if} +
+
+ +
+
+ {:else} + + {/if} +
+
+
+ Name + Aligned With Name +
+
+
diff --git a/frontend/src/lib/article/ProteinEntryComponent.svelte b/frontend/src/lib/article/ProteinEntryComponent.svelte new file mode 100644 index 00000000..b5e3ebac --- /dev/null +++ b/frontend/src/lib/article/ProteinEntryComponent.svelte @@ -0,0 +1,49 @@ + + +
+
+ {#if entry} +
+ +
+ {/if} + {#if alignEntry} +
+ +
+ {/if} +
+
+ +
+
diff --git a/frontend/src/lib/article/TextComponent.svelte b/frontend/src/lib/article/TextComponent.svelte new file mode 100644 index 00000000..889f15a9 --- /dev/null +++ b/frontend/src/lib/article/TextComponent.svelte @@ -0,0 +1,50 @@ + + + { + try { + setToken(); + await Backend.editArticleTextComponent({ + newMarkdown: editedMarkdown, + componentId: id, + }); + } catch (e) { + console.error(e); + } + dispatch("change"); + }} + on:change={() => { + dispatch("change"); + }} +> + + {#if markdown.length > 0} + + {:else} + + {/if} + + +