From 8915cda2384d965a60cc90378783166d113a2b21 Mon Sep 17 00:00:00 2001 From: James Collier Date: Fri, 8 Aug 2025 17:00:40 +0200 Subject: [PATCH 1/3] Add some UI tests --- server/poetry.lock | 143 +++++++++++++++++++++++++++++++- server/pyproject.toml | 1 + server/test/conftest.py | 29 ++++++- server/test/test_ui.py | 66 +++++++++++++++ server/ttfd/context.py | 4 +- server/ttfd/crud.py | 3 +- server/ttfd/html_controllers.py | 54 ++++++------ server/ttfd/image.py | 3 +- server/ttfd/main.py | 24 +++++- server/ttfd/metabolites.py | 3 +- server/ttfd/templates.py | 38 +++++++++ 11 files changed, 325 insertions(+), 43 deletions(-) create mode 100644 server/test/test_ui.py create mode 100644 server/ttfd/templates.py diff --git a/server/poetry.lock b/server/poetry.lock index 8aefb21d..2a027467 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -522,6 +522,18 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cssselect" +version = "1.3.0" +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d"}, + {file = "cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7"}, +] + [[package]] name = "dill" version = "0.4.0" @@ -847,6 +859,116 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "lxml" +version = "6.0.0" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35bc626eec405f745199200ccb5c6b36f202675d204aa29bb52e27ba2b71dea8"}, + {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:246b40f8a4aec341cbbf52617cad8ab7c888d944bfe12a6abd2b1f6cfb6f6082"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2793a627e95d119e9f1e19720730472f5543a6d84c50ea33313ce328d870f2dd"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:46b9ed911f36bfeb6338e0b482e7fe7c27d362c52fde29f221fddbc9ee2227e7"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b4790b558bee331a933e08883c423f65bbcd07e278f91b2272489e31ab1e2b4"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2030956cf4886b10be9a0285c6802e078ec2391e1dd7ff3eb509c2c95a69b76"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d23854ecf381ab1facc8f353dcd9adeddef3652268ee75297c1164c987c11dc"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:43fe5af2d590bf4691531b1d9a2495d7aab2090547eaacd224a3afec95706d76"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74e748012f8c19b47f7d6321ac929a9a94ee92ef12bc4298c47e8b7219b26541"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:43cfbb7db02b30ad3926e8fceaef260ba2fb7df787e38fa2df890c1ca7966c3b"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34190a1ec4f1e84af256495436b2d196529c3f2094f0af80202947567fdbf2e7"}, + {file = "lxml-6.0.0-cp310-cp310-win32.whl", hash = "sha256:5967fe415b1920a3877a4195e9a2b779249630ee49ece22021c690320ff07452"}, + {file = "lxml-6.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3389924581d9a770c6caa4df4e74b606180869043b9073e2cec324bad6e306e"}, + {file = "lxml-6.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:522fe7abb41309e9543b0d9b8b434f2b630c5fdaf6482bee642b34c8c70079c8"}, + {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ee56288d0df919e4aac43b539dd0e34bb55d6a12a6562038e8d6f3ed07f9e36"}, + {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8dd6dd0e9c1992613ccda2bcb74fc9d49159dbe0f0ca4753f37527749885c25"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d7ae472f74afcc47320238b5dbfd363aba111a525943c8a34a1b657c6be934c3"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5592401cdf3dc682194727c1ddaa8aa0f3ddc57ca64fd03226a430b955eab6f6"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58ffd35bd5425c3c3b9692d078bf7ab851441434531a7e517c4984d5634cd65b"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f720a14aa102a38907c6d5030e3d66b3b680c3e6f6bc95473931ea3c00c59967"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a5e8d207311a0170aca0eb6b160af91adc29ec121832e4ac151a57743a1e1e"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2dd1cc3ea7e60bfb31ff32cafe07e24839df573a5e7c2d33304082a5019bcd58"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfcf84f1defed7e5798ef4f88aa25fcc52d279be731ce904789aa7ccfb7e8d2"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a52a4704811e2623b0324a18d41ad4b9fabf43ce5ff99b14e40a520e2190c851"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c16304bba98f48a28ae10e32a8e75c349dd742c45156f297e16eeb1ba9287a1f"}, + {file = "lxml-6.0.0-cp311-cp311-win32.whl", hash = "sha256:f8d19565ae3eb956d84da3ef367aa7def14a2735d05bd275cd54c0301f0d0d6c"}, + {file = "lxml-6.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2d71cdefda9424adff9a3607ba5bbfc60ee972d73c21c7e3c19e71037574816"}, + {file = "lxml-6.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:8a2e76efbf8772add72d002d67a4c3d0958638696f541734304c7f28217a9cab"}, + {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108"}, + {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0"}, + {file = "lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a"}, + {file = "lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3"}, + {file = "lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb"}, + {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da"}, + {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef"}, + {file = "lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181"}, + {file = "lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e"}, + {file = "lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03"}, + {file = "lxml-6.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4eb114a0754fd00075c12648d991ec7a4357f9cb873042cc9a77bf3a7e30c9db"}, + {file = "lxml-6.0.0-cp38-cp38-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:7da298e1659e45d151b4028ad5c7974917e108afb48731f4ed785d02b6818994"}, + {file = "lxml-6.0.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bf61bc4345c1895221357af8f3e89f8c103d93156ef326532d35c707e2fb19d"}, + {file = "lxml-6.0.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63b634facdfbad421d4b61c90735688465d4ab3a8853ac22c76ccac2baf98d97"}, + {file = "lxml-6.0.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e380e85b93f148ad28ac15f8117e2fd8e5437aa7732d65e260134f83ce67911b"}, + {file = "lxml-6.0.0-cp38-cp38-win32.whl", hash = "sha256:185efc2fed89cdd97552585c624d3c908f0464090f4b91f7d92f8ed2f3b18f54"}, + {file = "lxml-6.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:f97487996a39cb18278ca33f7be98198f278d0bc3c5d0fd4d7b3d63646ca3c8a"}, + {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85b14a4689d5cff426c12eefe750738648706ea2753b20c2f973b2a000d3d261"}, + {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f64ccf593916e93b8d36ed55401bb7fe9c7d5de3180ce2e10b08f82a8f397316"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:b372d10d17a701b0945f67be58fae4664fd056b85e0ff0fbc1e6c951cdbc0512"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a674c0948789e9136d69065cc28009c1b1874c6ea340253db58be7622ce6398f"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:edf6e4c8fe14dfe316939711e3ece3f9a20760aabf686051b537a7562f4da91a"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:048a930eb4572829604982e39a0c7289ab5dc8abc7fc9f5aabd6fbc08c154e93"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0b5fa5eda84057a4f1bbb4bb77a8c28ff20ae7ce211588d698ae453e13c6281"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:c352fc8f36f7e9727db17adbf93f82499457b3d7e5511368569b4c5bd155a922"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8db5dc617cb937ae17ff3403c3a70a7de9df4852a046f93e71edaec678f721d0"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:2181e4b1d07dde53986023482673c0f1fba5178ef800f9ab95ad791e8bdded6a"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3c98d5b24c6095e89e03d65d5c574705be3d49c0d8ca10c17a8a4b5201b72f5"}, + {file = "lxml-6.0.0-cp39-cp39-win32.whl", hash = "sha256:04d67ceee6db4bcb92987ccb16e53bef6b42ced872509f333c04fb58a3315256"}, + {file = "lxml-6.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:e0b1520ef900e9ef62e392dd3d7ae4f5fa224d1dd62897a792cf353eb20b6cae"}, + {file = "lxml-6.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:e35e8aaaf3981489f42884b59726693de32dabfc438ac10ef4eb3409961fd402"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:dbdd7679a6f4f08152818043dbb39491d1af3332128b3752c3ec5cebc0011a72"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40442e2a4456e9910875ac12951476d36c0870dcb38a68719f8c4686609897c4"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db0efd6bae1c4730b9c863fc4f5f3c0fa3e8f05cae2c44ae141cb9dfc7d091dc"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ab542c91f5a47aaa58abdd8ea84b498e8e49fe4b883d67800017757a3eb78e8"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:013090383863b72c62a702d07678b658fa2567aa58d373d963cca245b017e065"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c86df1c9af35d903d2b52d22ea3e66db8058d21dc0f59842ca5deb0595921141"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4337e4aec93b7c011f7ee2e357b0d30562edd1955620fdd4aeab6aacd90d43c5"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ae74f7c762270196d2dda56f8dd7309411f08a4084ff2dfcc0b095a218df2e06"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:059c4cbf3973a621b62ea3132934ae737da2c132a788e6cfb9b08d63a0ef73f9"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f090a9bc0ce8da51a5632092f98a7e7f84bca26f33d161a98b57f7fb0004ca"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9da022c14baeec36edfcc8daf0e281e2f55b950249a455776f0d1adeeada4734"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a55da151d0b0c6ab176b4e761670ac0e2667817a1e0dadd04a01d0561a219349"}, + {file = "lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] + [[package]] name = "mako" version = "1.3.10" @@ -1541,6 +1663,25 @@ tomlkit = ">=0.10.1" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pyquery" +version = "2.0.1" +description = "A jquery-like library for python" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pyquery-2.0.1-py3-none-any.whl", hash = "sha256:aedfa0bd0eb9afc94b3ddbec8f375a6362b32bc9662f46e3e0d866483f4771b0"}, + {file = "pyquery-2.0.1.tar.gz", hash = "sha256:0194bb2706b12d037db12c51928fe9ebb36b72d9e719565daba5a6c595322faf"}, +] + +[package.dependencies] +cssselect = ">=1.2.0" +lxml = ">=2.1" + +[package.extras] +test = ["pytest", "pytest-cov", "requests", "webob", "webtest"] + [[package]] name = "pytest" version = "8.4.1" @@ -2199,4 +2340,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "cef627b2b9206e1d69757fcf9fc3a8330dc54708822b938e50c4e9cab84d9004" +content-hash = "14823118d1ae37d1400d0d3b8fa72f5cf17567607476fa0b6ca6a75725273c0e" diff --git a/server/pyproject.toml b/server/pyproject.toml index fa1c9968..aba45c60 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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" diff --git a/server/test/conftest.py b/server/test/conftest.py index bd1454e1..f6c7aa70 100644 --- a/server/test/conftest.py +++ b/server/test/conftest.py @@ -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 sessionmaker, Session 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 @@ -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 @@ -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=[], ) @@ -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() diff --git a/server/test/test_ui.py b/server/test/test_ui.py new file mode 100644 index 00000000..8dffb93f --- /dev/null +++ b/server/test/test_ui.py @@ -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() diff --git a/server/ttfd/context.py b/server/ttfd/context.py index 490e0a51..342222b2 100644 --- a/server/ttfd/context.py +++ b/server/ttfd/context.py @@ -6,8 +6,6 @@ from itertools import islice from typing import TYPE_CHECKING, Self, TypedDict, Unpack -from pydantic.tools import parse_obj_as - from ttfd import molecule, schemas if TYPE_CHECKING: @@ -297,7 +295,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, diff --git a/server/ttfd/crud.py b/server/ttfd/crud.py index 54fadf17..0cbe1c03 100644 --- a/server/ttfd/crud.py +++ b/server/ttfd/crud.py @@ -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 @@ -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 ] diff --git a/server/ttfd/html_controllers.py b/server/ttfd/html_controllers.py index 65d4f30b..877cb08a 100644 --- a/server/ttfd/html_controllers.py +++ b/server/ttfd/html_controllers.py @@ -4,14 +4,11 @@ import base64 import json -from itertools import chain, zip_longest -from typing import Annotated, Any, TypedDict +from itertools import chain +from typing import Annotated, TypedDict -import jinja2 -from fastapi import APIRouter, Cookie, Depends, Form, Query, Request, Response +from fastapi import APIRouter, Cookie, Depends, Form, Query, Request, Response, status from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from pydantic.tools import parse_obj_as # These imports are needed at _runtime_ by pydantic/fastapi from sqlalchemy.orm import Session # noqa: TC002 @@ -20,13 +17,12 @@ from ttfd import crud, deps, labelling, molecule, schemas from ttfd import reactions as ttfd_reactions from ttfd.auth import current_user -from ttfd.config import settings from ttfd.context import history, metabolite_image, url_for, user_context -from ttfd.format_time import format_time_ago # This import is needed at _runtime_ by pydantic/fastapi from ttfd.models import User # noqa: TC001 from ttfd.results import build_summary +from ttfd.templates import templates class Filter(TypedDict): @@ -95,25 +91,7 @@ def decode_filters(raw: str | None) -> TTFDFilters: } -def ttfd_application_context(_request: Request) -> dict[str, Any]: - """Add TTFD application data to the template context.""" - session = next(deps.get_db()) - return { - "ttfd": { - "version": settings.server_version, - "maintenance_mode": crud.in_maintenance_mode(session), - } - } - - router = APIRouter() -env = jinja2.Environment(loader=jinja2.FileSystemLoader("templates"), autoescape=True) -env.filters["zip_longest"] = zip_longest -env.filters["format_time_ago"] = format_time_ago -templates = Jinja2Templates( - env=env, - context_processors=[ttfd_application_context], -) def decode_labeling(labeling: str) -> list[int]: @@ -181,6 +159,7 @@ def tracer_atoms( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -216,7 +195,7 @@ def atom_attrs(i: int) -> str: 'style="pointer-events:visible;cursor:crosshair"' ) - mol = parse_obj_as(schemas.Molecule, metabolite.mol) + mol = schemas.Molecule.model_validate(metabolite.mol) return templates.TemplateResponse( request=request, @@ -264,6 +243,7 @@ def gauge( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -332,6 +312,7 @@ def countlables( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -341,6 +322,7 @@ def countlables( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -420,6 +402,7 @@ def gaugelables( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -429,6 +412,7 @@ def gaugelables( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -539,6 +523,7 @@ def countpathways( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -548,6 +533,7 @@ def countpathways( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -664,6 +650,7 @@ def metabolites( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -673,6 +660,7 @@ def metabolites( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -820,6 +808,7 @@ def reactions( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -829,6 +818,7 @@ def reactions( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -839,6 +829,7 @@ def reactions( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no pathway with identifier={path}", }, @@ -952,6 +943,7 @@ def results( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", }, @@ -961,6 +953,7 @@ def results( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", }, @@ -995,7 +988,7 @@ def results( "gauge": gauge_metabolite.common_name, "gauge_labelling": [ molecule.render( - parse_obj_as(schemas.Molecule, gauge_metabolite.mol), + schemas.Molecule.model_validate(gauge_metabolite.mol), selected=[], labels=alternative, width=100, @@ -1158,6 +1151,7 @@ def search_endpoint( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"No search endpoint for {element}.", }, @@ -1169,6 +1163,7 @@ def not_found(request: Request, element: str, identifier: str) -> _TemplateRespo return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no {element} with identifier: {identifier}.", }, @@ -1226,6 +1221,7 @@ def filter_include( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"No include endpoint for {element}.", }, @@ -1288,6 +1284,7 @@ def filter_exclude( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"No exclude endpoint for {element}.", }, @@ -1376,6 +1373,7 @@ def filter_remove_reaction( return templates.TemplateResponse( request=request, name="404.html", + status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"No exclude endpoint for {element}.", }, diff --git a/server/ttfd/image.py b/server/ttfd/image.py index 9c2400c8..436d56d4 100644 --- a/server/ttfd/image.py +++ b/server/ttfd/image.py @@ -3,7 +3,6 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic.tools import parse_obj_as from sqlalchemy.orm import Session from ttfd import crud, deps, molecule, schemas @@ -31,7 +30,7 @@ def metabolite_image( ) image = molecule.render( - parse_obj_as(schemas.Molecule, m.mol), + schemas.Molecule.model_validate(m.mol), selected=selected or [], labels=None, width=width, diff --git a/server/ttfd/main.py b/server/ttfd/main.py index df7c3931..f096c2e2 100644 --- a/server/ttfd/main.py +++ b/server/ttfd/main.py @@ -8,10 +8,12 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from asgi_csrf import asgi_csrf -from fastapi import FastAPI +from fastapi import FastAPI, status +from fastapi.exception_handlers import http_exception_handler from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from hypercorn.middleware import ProxyFixMiddleware +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware import ttfd.auth_html_controllers as auth_user_interface @@ -19,6 +21,7 @@ import ttfd.html_controllers as user_interface from ttfd.auth import clear_expired_sessions from ttfd.config import settings +from ttfd.templates import templates logging.basicConfig(level=logging.WARNING) scheduler = BackgroundScheduler() @@ -38,6 +41,20 @@ def setup_app() -> Any: """Run setup for fastapi.""" app = FastAPI(lifespan=lifespan) + @app.exception_handler(StarletteHTTPException) + async def custom_http_exception_handler(request, exc): + if ( + exc.status_code == status.HTTP_404_NOT_FOUND + and not request.url.path.startswith("/api") + ): + return templates.TemplateResponse( + request=request, + name="404.html", + status_code=status.HTTP_404_NOT_FOUND, + ) + + return await http_exception_handler(request, exc) + app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:8000", "https://ttfd.vib.be"], @@ -54,6 +71,11 @@ def setup_app() -> Any: path="/", https_only=True, ) + # app.add_middleware( + # Middleware(ProxyFixMiddleware), + # mode="modern", + # trusted_hops=1, + # ) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/server/ttfd/metabolites.py b/server/ttfd/metabolites.py index 3b8a11eb..382d4882 100644 --- a/server/ttfd/metabolites.py +++ b/server/ttfd/metabolites.py @@ -5,7 +5,6 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic.tools import parse_obj_as # This import is needed at _runtime_ by pydantic/fastapi from sqlalchemy.orm import Session # noqa: TC002 @@ -27,7 +26,7 @@ def make_metabolite( tracer=metabolite.tracer, gauge=gauge, human=metabolite.human, - mol=parse_obj_as(schemas.Molecule, metabolite.mol), + mol=schemas.Molecule.model_validate(metabolite.mol), ) diff --git a/server/ttfd/templates.py b/server/ttfd/templates.py new file mode 100644 index 00000000..363cb5f2 --- /dev/null +++ b/server/ttfd/templates.py @@ -0,0 +1,38 @@ +"""Templates.""" + +from itertools import zip_longest +from typing import Any + +import jinja2 +from fastapi import Request +from fastapi.templating import Jinja2Templates + +from ttfd import crud, deps +from ttfd.config import settings +from ttfd.format_time import format_time_ago + + +def mk_env() -> jinja2.Environment: + """Create and configure a jinja2 environment.""" + env = jinja2.Environment( + loader=jinja2.FileSystemLoader("templates"), autoescape=True + ) + env.filters["zip_longest"] = zip_longest + env.filters["format_time_ago"] = format_time_ago + return env + +def ttfd_application_context(_request: Request) -> dict[str, Any]: + """Add TTFD application data to the template context.""" + session = next(deps.get_db()) + return { + "ttfd": { + "version": settings.server_version, + "maintenance_mode": crud.in_maintenance_mode(session), + } + } + + +templates = Jinja2Templates( + env=mk_env(), + context_processors=[ttfd_application_context], +) From 823dae2df3c269f214333adcf1607f12aea7e1df Mon Sep 17 00:00:00 2001 From: James Collier Date: Mon, 11 Aug 2025 11:31:49 +0200 Subject: [PATCH 2/3] Make template context robust to real and test environments --- .github/workflows/server-pr.yaml | 2 +- server/test/conftest.py | 2 +- server/ttfd/auth.py | 6 +- server/ttfd/auth_html_controllers.py | 22 +++--- server/ttfd/context.py | 51 +++++++++++-- server/ttfd/crud.py | 6 ++ server/ttfd/html_controllers.py | 104 +++++++++++++++------------ server/ttfd/main.py | 11 +-- server/ttfd/templates.py | 15 ---- 9 files changed, 131 insertions(+), 88 deletions(-) diff --git a/.github/workflows/server-pr.yaml b/.github/workflows/server-pr.yaml index 0d33a0f4..341e9b6e 100644 --- a/.github/workflows/server-pr.yaml +++ b/.github/workflows/server-pr.yaml @@ -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 diff --git a/server/test/conftest.py b/server/test/conftest.py index f6c7aa70..befc7ae2 100644 --- a/server/test/conftest.py +++ b/server/test/conftest.py @@ -7,7 +7,7 @@ import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.orm import Session, sessionmaker from testcontainers.postgres import PostgresContainer from ttfd import abomination, crud diff --git a/server/ttfd/auth.py b/server/ttfd/auth.py index 3fa2ef45..73bdd959 100644 --- a/server/ttfd/auth.py +++ b/server/ttfd/auth.py @@ -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 diff --git a/server/ttfd/auth_html_controllers.py b/server/ttfd/auth_html_controllers.py index dcf3ece8..c52812b8 100644 --- a/server/ttfd/auth_html_controllers.py +++ b/server/ttfd/auth_html_controllers.py @@ -9,7 +9,7 @@ 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 @@ -20,47 +20,47 @@ @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=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=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" not in context: return RedirectResponse("/") - api_key = crud.create_api_key(session, name=keyname, user=user) + api_key = crud.create_api_key(session, name=keyname, user=context.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, ) diff --git a/server/ttfd/context.py b/server/ttfd/context.py index 342222b2..9ff1d68b 100644 --- a/server/ttfd/context.py +++ b/server/ttfd/context.py @@ -4,9 +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 ttfd import molecule, schemas +from fastapi import Depends, Request + +# 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 @@ -27,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.""" @@ -326,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} diff --git a/server/ttfd/crud.py b/server/ttfd/crud.py index 0cbe1c03..505e2a74 100644 --- a/server/ttfd/crud.py +++ b/server/ttfd/crud.py @@ -1005,6 +1005,12 @@ 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.""" diff --git a/server/ttfd/html_controllers.py b/server/ttfd/html_controllers.py index 877cb08a..95316e20 100644 --- a/server/ttfd/html_controllers.py +++ b/server/ttfd/html_controllers.py @@ -16,11 +16,7 @@ from ttfd import crud, deps, labelling, molecule, schemas from ttfd import reactions as ttfd_reactions -from ttfd.auth import current_user -from ttfd.context import history, metabolite_image, url_for, user_context - -# This import is needed at _runtime_ by pydantic/fastapi -from ttfd.models import User # noqa: TC001 +from ttfd.context import Context, history, metabolite_image, url_for, user_context from ttfd.results import build_summary from ttfd.templates import templates @@ -108,7 +104,7 @@ def encode_labeling(labeling: list[int]) -> str: def index( request: Request, session: Annotated[Session, Depends(deps.get_db)], - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], selected: Annotated[str | None, Query()] = None, ttfd_filters: Annotated[str | None, Cookie()] = None, ) -> _TemplateResponse: @@ -131,7 +127,8 @@ def index( context={ "message": "There are no tracers available. Try relaxing your filters.", "step": history(), - }, + } + | context, ) return templates.TemplateResponse( @@ -141,7 +138,7 @@ def index( "tracers": tracers, "step": history(), } - | user_context(user), + | context, ) @@ -150,7 +147,7 @@ def tracer_atoms( request: Request, session: Annotated[Session, Depends(deps.get_db)], tracer: str, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], selected: Annotated[list[int] | None, Query()] = None, clicked: Annotated[int | None, Query()] = None, ) -> _TemplateResponse: @@ -162,7 +159,8 @@ def tracer_atoms( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if clicked is not None and clicked in (selected or []): @@ -224,7 +222,7 @@ def atom_attrs(i: int) -> str: extra_atom_attrs=atom_attrs, ), } - | user_context(user), + | context, ) @@ -234,7 +232,7 @@ def gauge( session: Annotated[Session, Depends(deps.get_db)], tracer: str, tracer_labeled_atoms: str, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], selected: Annotated[str | None, Query()] = None, ttfd_filters: Annotated[str | None, Cookie()] = None, ) -> _TemplateResponse: @@ -246,7 +244,8 @@ def gauge( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -276,7 +275,8 @@ def gauge( "filter": { "tracer": metabolite_image(tracer_metabolite, selected=labeling), }, - }, + } + | context, ) return templates.TemplateResponse( @@ -290,7 +290,7 @@ def gauge( "tracer": metabolite_image(tracer_metabolite, selected=labeling), }, } - | user_context(user), + | context, ) @@ -304,7 +304,7 @@ def countlables( tracer: str, tracer_labeled_atoms: str, gauge: str, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], selected: Annotated[str | None, Query()] = None, ) -> _TemplateResponse: """Display a page to select the number of labeled atoms in the gauge metabolite.""" @@ -315,7 +315,8 @@ def countlables( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -325,7 +326,8 @@ def countlables( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -346,7 +348,8 @@ def countlables( "tracer": metabolite_image(tracer_metabolite, selected=labeling), "gauge": metabolite_image(gauge_metabolite, selected=[]), }, - }, + } + | context, ) return templates.TemplateResponse( @@ -379,7 +382,7 @@ def countlables( ], }, } - | user_context(user), + | context, ) @@ -394,7 +397,7 @@ def gaugelables( tracer_labeled_atoms: str, gauge: str, countlabels: int, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], selected: Annotated[list[int] | None, Query()] = None, ) -> _TemplateResponse: """Page for user to select which atoms are labeled in the gauge metabolite.""" @@ -405,7 +408,8 @@ def gaugelables( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -415,7 +419,8 @@ def gaugelables( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -451,7 +456,7 @@ def to_class_list(ids: list[int]) -> str: ), }, } - | user_context(user), + | context, ) return templates.TemplateResponse( @@ -499,7 +504,7 @@ def to_class_list(ids: list[int]) -> str: }, }, } - | user_context(user), + | context, ) @@ -514,7 +519,7 @@ def countpathways( tracer_labeled_atoms: str, gauge: str, countlabels: int, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], gauge_labeled_atoms: Annotated[str | None, Query()] = None, selected: Annotated[str | None, Query()] = None, ) -> _TemplateResponse: @@ -526,7 +531,8 @@ def countpathways( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -536,7 +542,8 @@ def countpathways( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) tracer_labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -579,7 +586,7 @@ def countpathways( ), }, } - | user_context(user), + | context, ) return templates.TemplateResponse( @@ -614,7 +621,7 @@ def countpathways( ], }, } - | user_context(user), + | context, ) @@ -640,7 +647,7 @@ def metabolites( gauge: str, countlabels: int, countpathways: int, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], gauge_labeled_atoms: Annotated[str | None, Query()] = None, ttfd_filters: Annotated[str | None, Cookie()] = None, selected: Annotated[str | None, Query()] = None, @@ -653,7 +660,8 @@ def metabolites( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -663,7 +671,8 @@ def metabolites( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) tracer_labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -716,7 +725,7 @@ def metabolites( ), }, } - | user_context(user), + | context, ) return templates.TemplateResponse( @@ -749,7 +758,7 @@ def metabolites( ], }, } - | user_context(user), + | context, ) @@ -798,7 +807,7 @@ def reactions( countlabels: int, countpathways: int, path: int, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], gauge_labeled_atoms: Annotated[str | None, Query()] = None, ttfd_filters: Annotated[str | None, Cookie()] = None, selected: Annotated[str | None, Query()] = None, @@ -811,7 +820,8 @@ def reactions( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -821,7 +831,8 @@ def reactions( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) tracer_labeling: list[int] = decode_labeling(tracer_labeled_atoms) @@ -832,7 +843,8 @@ def reactions( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no pathway with identifier={path}", - }, + } + | context, ) names = { @@ -880,7 +892,7 @@ def reactions( ), }, } - | user_context(user), + | context, ) return templates.TemplateResponse( @@ -917,7 +929,7 @@ def reactions( }, }, } - | user_context(user), + | context, ) @@ -935,7 +947,7 @@ def results( countpathways: int, path: int, reaction_index: int, - user: Annotated[User | None, Depends(current_user)] = None, + context: Annotated[Context, Depends(user_context)], gauge_labeled_atoms: Annotated[str | None, Query()] = None, ) -> _TemplateResponse: """Page for user to select the number of pathways.""" @@ -946,7 +958,8 @@ def results( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={tracer}", - }, + } + | context, ) if (gauge_metabolite := crud.metabolite_by_name(session, gauge)) is None: @@ -956,7 +969,8 @@ def results( status_code=status.HTTP_404_NOT_FOUND, context={ "message": f"There is no metabolite with identifier={gauge}", - }, + } + | context, ) tracer_labeling = decode_labeling(tracer_labeled_atoms) gauge_labeling = decode_labeling(gauge_labeled_atoms) if gauge_labeled_atoms else [] @@ -997,7 +1011,7 @@ def results( for alternative in gauge_labelling ], } - | user_context(user), + | context, ) diff --git a/server/ttfd/main.py b/server/ttfd/main.py index f096c2e2..005c643b 100644 --- a/server/ttfd/main.py +++ b/server/ttfd/main.py @@ -51,6 +51,12 @@ async def custom_http_exception_handler(request, exc): request=request, name="404.html", status_code=status.HTTP_404_NOT_FOUND, + context={ + "ttfd": { + "version": settings.server_version, + "maintenance_mode": False, + } + }, ) return await http_exception_handler(request, exc) @@ -71,11 +77,6 @@ async def custom_http_exception_handler(request, exc): path="/", https_only=True, ) - # app.add_middleware( - # Middleware(ProxyFixMiddleware), - # mode="modern", - # trusted_hops=1, - # ) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/server/ttfd/templates.py b/server/ttfd/templates.py index 363cb5f2..879ffe37 100644 --- a/server/ttfd/templates.py +++ b/server/ttfd/templates.py @@ -1,14 +1,10 @@ """Templates.""" from itertools import zip_longest -from typing import Any import jinja2 -from fastapi import Request from fastapi.templating import Jinja2Templates -from ttfd import crud, deps -from ttfd.config import settings from ttfd.format_time import format_time_ago @@ -21,18 +17,7 @@ def mk_env() -> jinja2.Environment: env.filters["format_time_ago"] = format_time_ago return env -def ttfd_application_context(_request: Request) -> dict[str, Any]: - """Add TTFD application data to the template context.""" - session = next(deps.get_db()) - return { - "ttfd": { - "version": settings.server_version, - "maintenance_mode": crud.in_maintenance_mode(session), - } - } - templates = Jinja2Templates( env=mk_env(), - context_processors=[ttfd_application_context], ) From 471d67cbf8da964b2cff74701eb8ec3dd2064fab Mon Sep 17 00:00:00 2001 From: James Collier Date: Mon, 11 Aug 2025 11:46:36 +0200 Subject: [PATCH 3/3] Linting, typing, and formatting --- server/ttfd/auth_html_controllers.py | 12 +++++------- server/ttfd/crud.py | 1 + server/ttfd/main.py | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/ttfd/auth_html_controllers.py b/server/ttfd/auth_html_controllers.py index c52812b8..44241a9b 100644 --- a/server/ttfd/auth_html_controllers.py +++ b/server/ttfd/auth_html_controllers.py @@ -8,11 +8,9 @@ from starlette.templating import _TemplateResponse from ttfd import crud -from ttfd.auth import current_user 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() @@ -26,7 +24,7 @@ def me( if "user" not in context: return RedirectResponse("/") return templates.TemplateResponse( - request=request, name="me.html", context=context + request=request, name="me.html", context=dict(context) ) @@ -42,7 +40,7 @@ def make_api_key_arguments( return templates.TemplateResponse( request=request, name="create_api_key.html", - context=context, + context=dict(context), ) @@ -54,10 +52,10 @@ def make_api_key( keyname: Annotated[str, Form()], ) -> _TemplateResponse | RedirectResponse: """Create an API key.""" - if "user" not in context: + if (user := context.get("user")) is None: return RedirectResponse("/") - api_key = crud.create_api_key(session, name=keyname, user=context.user) + api_key = crud.create_api_key(session, name=keyname, user=user) return templates.TemplateResponse( request=request, diff --git a/server/ttfd/crud.py b/server/ttfd/crud.py index 505e2a74..ed14d165 100644 --- a/server/ttfd/crud.py +++ b/server/ttfd/crud.py @@ -1005,6 +1005,7 @@ 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) diff --git a/server/ttfd/main.py b/server/ttfd/main.py index 005c643b..f107ee7a 100644 --- a/server/ttfd/main.py +++ b/server/ttfd/main.py @@ -42,7 +42,7 @@ def setup_app() -> Any: app = FastAPI(lifespan=lifespan) @app.exception_handler(StarletteHTTPException) - async def custom_http_exception_handler(request, exc): + async def custom_http_exception_handler(request, exc): # type: ignore[no-untyped-def] # noqa: ANN202, ANN001 if ( exc.status_code == status.HTTP_404_NOT_FOUND and not request.url.path.startswith("/api")