Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/server-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
working-directory: ${{ github.workspace }}/server
CLIENT_ID: ""
CLIENT_SECRET: ""
DATABASE_URI: ""
DATABASE_URI: "sqlite:///test.db"
steps:
- uses: actions/checkout@v4

Expand Down
143 changes: 142 additions & 1 deletion server/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ ruff = "^0.11.0"
bandit = "^1.8.3"
testcontainers = {extras = ["postgres"], version = "^4.10.0"}
coverage = "^7.10.2"
pyquery = "^2.0.1"

[tool.poetry.scripts]
start = "ttfd.main:main"
Expand Down
29 changes: 25 additions & 4 deletions server/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from pathlib import Path

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from testcontainers.postgres import PostgresContainer

from ttfd import abomination, crud
from ttfd.deps import get_db
from ttfd.main import app
from ttfd.models import Base


Expand All @@ -25,7 +28,7 @@ def setup_postgres(request):
@pytest.fixture(scope="session")
def database(setup_postgres):
"""Populate the database with random data."""
engine = create_engine(os.environ["DATABASE_URI"], echo=True, future=True)
engine = create_engine(os.environ["DATABASE_URI"], echo=False, future=True)
Base.metadata.create_all(engine)
# Add data here
# 1. Sample 1000 entries from TTFD data
Expand All @@ -46,9 +49,14 @@ def database(setup_postgres):
database=session,
name=metabolite,
common_name=metabolite,
size=0,
size=1,
human=False,
mol={},
mol={
"atoms": [{"x": 0.0, "y": 0.0, "element": "N"}],
"bonds": [],
"width": 0.0,
"height": 0.0,
},
categories=[],
)

Expand Down Expand Up @@ -112,3 +120,16 @@ def regression_database(setup_postgres):
yield session

engine.dispose()


@pytest.fixture
def test_client(database: Session):
"""Provide a http client to access the app in a test environment."""
def get_db_override():
return database

app.app.dependency_overrides[get_db] = get_db_override
client = TestClient(app)

yield client
app.app.dependency_overrides.clear()
66 changes: 66 additions & 0 deletions server/test/test_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Test the UI as the browser would see it."""

from fastapi import status
from pyquery import PyQuery as pq


def test_ui_index_login_button(test_client):
response = test_client.get("/")
assert response.status_code == status.HTTP_200_OK
assert response.headers["content-type"] == "text/html; charset=utf-8"
doc = pq(response.text)
assert doc(".vib-auth-button").text() == "Login"

# Ensure the rest of the document is displayed too
assert "Theoretical Tracer Fate Detection" in doc.text()


def test_ui_404_page(test_client):
response = test_client.get("/does-not-exist")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.headers["content-type"] == "text/html; charset=utf-8"
doc = pq(response.text)
assert "Oops!" in doc.text()

# Ensure the rest of the document is displayed too
assert "Theoretical Tracer Fate Detection" in doc.text()


def test_ui_existing_metabolite(test_client):
response = test_client.get("/atoms/L-LACTATE")
assert response.status_code == status.HTTP_200_OK
assert response.headers["content-type"] == "text/html; charset=utf-8"
doc = pq(response.text)
assert doc(".ttfd-button").text() == "Back"
assert len(doc(".ttfd-metabolite-container > svg")) == 1

# Ensure the rest of the document is displayed too
assert "Theoretical Tracer Fate Detection" in doc.text()


def test_ui_nonexisting_metabolite(test_client):
response = test_client.get("/atoms/NOEXIST")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.headers["content-type"] == "text/html; charset=utf-8"
doc = pq(response.text)
assert "Oops!" in doc.text()
assert "NOEXIST" in doc.text()

# Ensure the rest of the document is displayed too
assert "Theoretical Tracer Fate Detection" in doc.text()


def test_ui_select_gauge(test_client):
response = test_client.get("/gauge/ALPHA-GLUCOSE/3,4")
assert response.status_code == status.HTTP_200_OK
assert response.headers["content-type"] == "text/html; charset=utf-8"
doc = pq(response.text)

assert {title.text for title in doc(".ttfd-metabolite-title")} == {
"GLT",
"UMP",
"L-LACTATE",
}

# Ensure the rest of the document is displayed too
assert "Theoretical Tracer Fate Detection" in doc.text()
6 changes: 3 additions & 3 deletions server/ttfd/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,13 @@ def logout(
if user is None:
return response

if (session := request.session.get("token")) is None:
if (token := request.session.get("token")) is None:
return response
if (login_session := crud.get_session(database, session)) is None:
if (session := crud.get_session_by_token(database, token)) is None:
return response

del request.session["token"]
database.delete(login_session)
database.delete(session)
database.commit()
return response

Expand Down
24 changes: 11 additions & 13 deletions server/ttfd/auth_html_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,59 +8,57 @@
from starlette.templating import _TemplateResponse

from ttfd import crud
from ttfd.auth import current_user
from ttfd.context import user_context
from ttfd.context import Context, user_context
from ttfd.deps import get_db
from ttfd.html_controllers import templates
from ttfd.models import User
from ttfd.templates import templates

router = APIRouter()


@router.get("/me", response_model=None)
def me(
request: Request,
user: Annotated[User | None, Depends(current_user)],
context: Annotated[Context, Depends(user_context)],
) -> _TemplateResponse | RedirectResponse:
"""Display the user info page for a logged in user."""
if user is None:
if "user" not in context:
return RedirectResponse("/")
return templates.TemplateResponse(
request=request, name="me.html", context=user_context(user)
request=request, name="me.html", context=dict(context)
)


@router.get("/me/apikey", response_model=None)
def make_api_key_arguments(
request: Request,
user: Annotated[User | None, Depends(current_user)],
context: Annotated[Context, Depends(user_context)],
) -> _TemplateResponse | RedirectResponse:
"""Display the API key creation form to the user."""
if user is None:
if "user" not in context:
return RedirectResponse("/")

return templates.TemplateResponse(
request=request,
name="create_api_key.html",
context=user_context(user),
context=dict(context),
)


@router.post("/me/apikey", response_model=None)
def make_api_key(
request: Request,
user: Annotated[User | None, Depends(current_user)],
context: Annotated[Context, Depends(user_context)],
session: Annotated[Session, Depends(get_db)],
keyname: Annotated[str, Form()],
) -> _TemplateResponse | RedirectResponse:
"""Create an API key."""
if user is None:
if (user := context.get("user")) is None:
return RedirectResponse("/")

api_key = crud.create_api_key(session, name=keyname, user=user)

return templates.TemplateResponse(
request=request,
name="new_api_key.html",
context={"api_key": api_key} | user_context(user),
context={"api_key": api_key} | context,
)
53 changes: 44 additions & 9 deletions server/ttfd/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@

from dataclasses import dataclass
from itertools import islice
from typing import TYPE_CHECKING, Self, TypedDict, Unpack
from typing import TYPE_CHECKING, Annotated, Self, TypedDict, Unpack

from pydantic.tools import parse_obj_as
from fastapi import Depends, Request

from ttfd import molecule, schemas
# This import is needed at _runtime_ by pydantic/fastapi
from sqlalchemy.orm import Session # noqa: TC002

from ttfd import crud, deps, molecule, schemas
from ttfd.auth import current_user
from ttfd.config import settings

if TYPE_CHECKING:
from collections.abc import Callable, Iterable
Expand All @@ -29,6 +34,20 @@
]


class AppContext(TypedDict, total=True):
"""Context that all templates require."""

version: str
maintenance_mode: bool


class Context(TypedDict, total=False):
"""TTFD template context."""

ttfd: AppContext
user: User | None


class HistoryData(TypedDict, total=False):
"""The data required to build a history context."""

Expand Down Expand Up @@ -297,7 +316,7 @@ def metabolite_image(
"name": metabolite.name,
"common_name": metabolite.common_name,
"image": molecule.render(
parse_obj_as(schemas.Molecule, metabolite.mol),
schemas.Molecule.model_validate(metabolite.mol),
selected=selected,
labels=None,
width=200,
Expand Down Expand Up @@ -328,9 +347,25 @@ def _selected_atoms_query(atoms: list[int]) -> Iterable[tuple[str, str]]:
return (("selected", str(atom)) for atom in atoms)


def user_context(user: User | None) -> dict[str, User]:
"""Put the user into the context dictionary."""
if not user:
return {}
def app_context(
session: Annotated[Session, Depends(deps.get_db)],
) -> Context:
"""Create the app context."""
return {
"ttfd": {
"version": settings.server_version,
"maintenance_mode": crud.in_maintenance_mode(session),
}
}


def user_context(
request: Request,
app: Annotated[Context, Depends(app_context)],
session: Annotated[Session, Depends(deps.get_db)],
) -> Context:
"""Create the user context."""
if (user := current_user(request, session)) is None:
return app

return {"user": user}
return app | {"user": user}
10 changes: 8 additions & 2 deletions server/ttfd/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from operator import itemgetter
from typing import TYPE_CHECKING

from pydantic.tools import parse_obj_as
from sqlalchemy import delete, select, text, update
from sqlalchemy.dialects.postgresql import aggregate_order_by, insert
from sqlalchemy.exc import SQLAlchemyError
Expand Down Expand Up @@ -577,7 +576,7 @@ def metabolites_for_path(
name=metabolite.name,
common_name=metabolite.common_name,
input_for_next_step=use,
mol=parse_obj_as(schemas.Molecule, metabolite.mol),
mol=schemas.Molecule.model_validate(metabolite.mol),
)
for (_, use, metabolite) in index_group
]
Expand Down Expand Up @@ -1007,6 +1006,13 @@ def get_session(database: Session, session_id: int) -> LoginSession | None:
return database.execute(qry).scalar_one_or_none()


def get_session_by_token(database: Session, token: str) -> LoginSession | None:
"""Fetch a login session by its token."""
qry = select(LoginSession).where(LoginSession.token == token)

return database.execute(qry).scalar_one_or_none()


def delete_all_sessions(database: Session, user_id: int) -> None:
"""Delete all login sessions for a user."""
qry = delete(LoginSession).where(LoginSession.user_id == user_id)
Expand Down
Loading