From 7857f36552f8699d2cfd06b7aefbea0899d18c3c Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Mon, 8 Jul 2024 21:34:26 +0100
Subject: [PATCH 1/7] Add SQLAlchemy and alembic dependencies

---
 poetry.lock    | 473 +++++++++++++++++++++++++++++++++++++++++++------
 pyproject.toml |  11 +-
 2 files changed, 424 insertions(+), 60 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 7582cd4..09553fc 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,24 @@
 # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
 
+[[package]]
+name = "alembic"
+version = "1.13.2"
+description = "A database migration tool for SQLAlchemy."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"},
+    {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"},
+]
+
+[package.dependencies]
+Mako = "*"
+SQLAlchemy = ">=1.3.0"
+typing-extensions = ">=4"
+
+[package.extras]
+tz = ["backports.zoneinfo"]
+
 [[package]]
 name = "anyio"
 version = "4.4.0"
@@ -147,43 +166,38 @@ pytest-mock = ">=3.6.1,<4.0.0"
 
 [[package]]
 name = "cryptography"
-version = "42.0.8"
+version = "43.0.0"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"},
-    {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"},
-    {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"},
-    {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"},
-    {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"},
-    {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"},
-    {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"},
-    {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"},
-    {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"},
-    {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"},
-    {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"},
-    {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"},
-    {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"},
-    {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"},
-    {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"},
-    {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"},
-    {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"},
-    {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"},
-    {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"},
-    {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"},
-    {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"},
-    {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"},
-    {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"},
-    {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"},
-    {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"},
-    {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"},
-    {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"},
-    {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"},
-    {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"},
-    {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"},
-    {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"},
-    {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"},
+    {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
+    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
+    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
+    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
+    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
+    {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
+    {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
+    {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
+    {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
+    {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
+    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
+    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
+    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
+    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
+    {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
+    {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
+    {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
+    {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
+    {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
+    {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
+    {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
+    {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
+    {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
+    {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
+    {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
+    {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
+    {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
 ]
 
 [package.dependencies]
@@ -196,7 +210,7 @@ nox = ["nox"]
 pep8test = ["check-sdist", "click", "mypy", "ruff"]
 sdist = ["build"]
 ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
 test-randomorder = ["pytest-randomly"]
 
 [[package]]
@@ -257,6 +271,77 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1
 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
 typing = ["typing-extensions (>=4.8)"]
 
+[[package]]
+name = "greenlet"
+version = "3.0.3"
+description = "Lightweight in-process concurrent programming"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"},
+    {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"},
+    {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"},
+    {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"},
+    {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"},
+    {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"},
+    {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"},
+    {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"},
+    {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"},
+    {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"},
+    {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"},
+    {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"},
+    {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"},
+    {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"},
+    {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"},
+    {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"},
+    {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"},
+    {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"},
+    {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"},
+    {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"},
+    {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"},
+    {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"},
+    {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"},
+    {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"},
+    {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"},
+    {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"},
+    {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"},
+    {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"},
+    {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"},
+    {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"},
+    {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"},
+    {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"},
+    {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"},
+    {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
+]
+
+[package.extras]
+docs = ["Sphinx", "furo"]
+test = ["objgraph", "psutil"]
+
 [[package]]
 name = "h11"
 version = "0.14.0"
@@ -397,6 +482,94 @@ files = [
     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
 ]
 
+[[package]]
+name = "mako"
+version = "1.3.5"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"},
+    {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=0.9.2"
+
+[package.extras]
+babel = ["Babel"]
+lingua = ["lingua"]
+testing = ["pytest"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.5"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+    {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
+
 [[package]]
 name = "motor"
 version = "3.5.1"
@@ -502,6 +675,92 @@ nodeenv = ">=0.11.1"
 pyyaml = ">=5.1"
 virtualenv = ">=20.10.0"
 
+[[package]]
+name = "psycopg"
+version = "3.2.1"
+description = "PostgreSQL database adapter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"},
+    {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"},
+]
+
+[package.dependencies]
+psycopg-binary = {version = "3.2.1", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""}
+typing-extensions = ">=4.4"
+tzdata = {version = "*", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+binary = ["psycopg-binary (==3.2.1)"]
+c = ["psycopg-c (==3.2.1)"]
+dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
+docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
+pool = ["psycopg-pool"]
+test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
+
+[[package]]
+name = "psycopg-binary"
+version = "3.2.1"
+description = "PostgreSQL database adapter for Python -- C optimisation distribution"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "psycopg_binary-3.2.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:cad2de17804c4cfee8640ae2b279d616bb9e4734ac3c17c13db5e40982bd710d"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:592b27d6c46a40f9eeaaeea7c1fef6f3c60b02c634365eb649b2d880669f149f"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a997efbaadb5e1a294fb5760e2f5643d7b8e4e3fe6cb6f09e6d605fd28e0291"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1d2b6438fb83376f43ebb798bf0ad5e57bc56c03c9c29c85bc15405c8c0ac5a"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1f087bd84bdcac78bf9f024ebdbfacd07fc0a23ec8191448a50679e2ac4a19e"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:415c3b72ea32119163255c6504085f374e47ae7345f14bc3f0ef1f6e0976a879"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f092114f10f81fb6bae544a0ec027eb720e2d9c74a4fcdaa9dd3899873136935"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06a7aae34edfe179ddc04da005e083ff6c6b0020000399a2cbf0a7121a8a22ea"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b018631e5c80ce9bc210b71ea885932f9cca6db131e4df505653d7e3873a938"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8a509aeaac364fa965454e80cd110fe6d48ba2c80f56c9b8563423f0b5c3cfd"},
+    {file = "psycopg_binary-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:413977d18412ff83486eeb5875eb00b185a9391c57febac45b8993bf9c0ff489"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:62b1b7b07e00ee490afb39c0a47d8282a9c2822c7cfed9553a04b0058adf7e7f"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f8afb07114ea9b924a4a0305ceb15354ccf0ef3c0e14d54b8dbeb03e50182dd7"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40bb515d042f6a345714ec0403df68ccf13f73b05e567837d80c886c7c9d3805"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6418712ba63cebb0c88c050b3997185b0ef54173b36568522d5634ac06153040"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:101472468d59c74bb8565fab603e032803fd533d16be4b2d13da1bab8deb32a3"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa3931f308ab4a479d0ee22dc04bea867a6365cac0172e5ddcba359da043854b"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc314a47d44fe1a8069b075a64abffad347a3a1d8652fed1bab5d3baea37acb2"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc304a46be1e291031148d9d95c12451ffe783ff0cc72f18e2cc7ec43cdb8c68"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f9e13600647087df5928875559f0eb8f496f53e6278b7da9511b4b3d0aff960"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b140182830c76c74d17eba27df3755a46442ce8d4fb299e7f1cf2f74a87c877b"},
+    {file = "psycopg_binary-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:3c838806eeb99af39f934b7999e35f947a8e577997cc892c12b5053a97a9057f"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7066d3dca196ed0dc6172f9777b2d62e4f138705886be656cccff2d555234d60"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:28ada5f610468c57d8a4a055a8ea915d0085a43d794266c4f3b9d02f4288f4db"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e8213bf50af073b1aa8dc3cff123bfeedac86332a16c1b7274910bc88a847c7"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74d623261655a169bc84a9669890975c229f2fa6e19a7f2d10a77675dcf1a707"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42781ba94e8842ee98bca5a7d0c44cc9d067500fedca2d6a90fa3609b6d16b42"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e6669091d09f8ba36e10ce678a6d9916e110446236a9b92346464a3565635e"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b09e8a576a2ac69d695032ee76f31e03b30781828b5dd6d18c6a009e5a3d1c35"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8f28ff0cb9f1defdc4a6f8c958bf6787274247e7dfeca811f6e2f56602695fb1"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c84fcac8a3a3479ac14673095cc4e1fdba2935499f72c436785ac679bec0d1a"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:950fd666ec9e9fe6a8eeb2b5a8f17301790e518953730ad44d715b59ffdbc67f"},
+    {file = "psycopg_binary-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:334046a937bb086c36e2c6889fe327f9f29bfc085d678f70fac0b0618949f674"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:1d6833f607f3fc7b22226a9e121235d3b84c0eda1d3caab174673ef698f63788"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d353e028b8f848b9784450fc2abf149d53a738d451eab3ee4c85703438128b9"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f34e369891f77d0738e5d25727c307d06d5344948771e5379ea29c76c6d84555"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ab58213cc976a1666f66bc1cb2e602315cd753b7981a8e17237ac2a185bd4a1"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0104a72a17aa84b3b7dcab6c84826c595355bf54bb6ea6d284dcb06d99c6801"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:059cbd4e6da2337e17707178fe49464ed01de867dc86c677b30751755ec1dc51"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:73f9c9b984be9c322b5ec1515b12df1ee5896029f5e72d46160eb6517438659c"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:af0469c00f24c4bec18c3d2ede124bf62688d88d1b8a5f3c3edc2f61046fe0d7"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:463d55345f73ff391df8177a185ad57b552915ad33f5cc2b31b930500c068b22"},
+    {file = "psycopg_binary-3.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:302b86f92c0d76e99fe1b5c22c492ae519ce8b98b88d37ef74fda4c9e24c6b46"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:0879b5d76b7d48678d31278242aaf951bc2d69ca4e4d7cef117e4bbf7bfefda9"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f99e59f8a5f4dcd9cbdec445f3d8ac950a492fc0e211032384d6992ed3c17eb7"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84837e99353d16c6980603b362d0f03302d4b06c71672a6651f38df8a482923d"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ce965caf618061817f66c0906f0452aef966c293ae0933d4fa5a16ea6eaf5bb"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78c2007caf3c90f08685c5378e3ceb142bafd5636be7495f7d86ec8a977eaeef"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7a84b5eb194a258116154b2a4ff2962ea60ea52de089508db23a51d3d6b1c7d1"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a42b8f9ab39affcd5249b45cac763ac3cf12df962b67e23fd15a2ee2932afe5"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:788ffc43d7517c13e624c83e0e553b7b8823c9655e18296566d36a829bfb373f"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:21927f41c4d722ae8eb30d62a6ce732c398eac230509af5ba1749a337f8a63e2"},
+    {file = "psycopg_binary-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:921f0c7f39590763d64a619de84d1b142587acc70fd11cbb5ba8fa39786f3073"},
+]
+
 [[package]]
 name = "pycparser"
 version = "2.22"
@@ -663,20 +922,20 @@ zstd = ["zstandard"]
 
 [[package]]
 name = "pytest"
-version = "8.2.2"
+version = "8.3.1"
 description = "pytest: simple powerful testing with Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
-    {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
+    {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"},
+    {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"},
 ]
 
 [package.dependencies]
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
 iniconfig = "*"
 packaging = "*"
-pluggy = ">=1.5,<2.0"
+pluggy = ">=1.5,<2"
 
 [package.extras]
 dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
@@ -789,29 +1048,29 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
 
 [[package]]
 name = "ruff"
-version = "0.5.3"
+version = "0.5.4"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.5.3-py3-none-linux_armv6l.whl", hash = "sha256:b12424d9db7347fa63c5ed9af010003338c63c629fb9c9c6adb2aa4f5699729b"},
-    {file = "ruff-0.5.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8d72c5684bbd4ed304a9a955ee2e67f57b35f6193222ade910cca8a805490e3"},
-    {file = "ruff-0.5.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d2fc2cdb85ccac1e816cc9d5d8cedefd93661bd957756d902543af32a6b04a71"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf4bc751240b2fab5d19254571bcacb315c7b0b00bf3c912d52226a82bbec073"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc697ec874fdd7c7ba0a85ec76ab38f8595224868d67f097c5ffc21136e72fcd"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e791d34d3557a3819b3704bc1f087293c821083fa206812842fa363f6018a192"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76bb5a87fd397520b91a83eae8a2f7985236d42dd9459f09eef58e7f5c1d8316"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8cfc7a26422c78e94f1ec78ec02501bbad2df5834907e75afe474cc6b83a8c1"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96066c4328a49fce2dd40e80f7117987369feec30ab771516cf95f1cc2db923c"},
-    {file = "ruff-0.5.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03bfe9ab5bdc0b08470c3b261643ad54ea86edc32b64d1e080892d7953add3ad"},
-    {file = "ruff-0.5.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7704582a026fa02cca83efd76671a98ee6eb412c4230209efe5e2a006c06db62"},
-    {file = "ruff-0.5.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:08058d077e21b856d32ebf483443390e29dc44d927608dc8f092ff6776519da9"},
-    {file = "ruff-0.5.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77d49484429ed7c7e6e2e75a753f153b7b58f875bdb4158ad85af166a1ec1822"},
-    {file = "ruff-0.5.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:642cbff6cbfa38d2566d8db086508d6f472edb136cbfcc4ea65997745368c29e"},
-    {file = "ruff-0.5.3-py3-none-win32.whl", hash = "sha256:eafc45dd8bdc37a00b28e68cc038daf3ca8c233d73fea276dcd09defb1352841"},
-    {file = "ruff-0.5.3-py3-none-win_amd64.whl", hash = "sha256:cbaec2ddf4f78e5e9ecf5456ea0f496991358a1d883862ed0b9e947e2b6aea93"},
-    {file = "ruff-0.5.3-py3-none-win_arm64.whl", hash = "sha256:05fbd2cb404775d6cd7f2ff49504e2d20e13ef95fa203bd1ab22413af70d420b"},
-    {file = "ruff-0.5.3.tar.gz", hash = "sha256:2a3eb4f1841771fa5b67a56be9c2d16fd3cc88e378bd86aaeaec2f7e6bcdd0a2"},
+    {file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"},
+    {file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"},
+    {file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"},
+    {file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"},
+    {file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"},
+    {file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"},
+    {file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"},
+    {file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"},
+    {file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"},
+    {file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"},
+    {file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"},
+    {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"},
 ]
 
 [[package]]
@@ -898,6 +1157,93 @@ flask = ["flask"]
 quart = ["quart"]
 starlette = ["starlette[full]"]
 
+[[package]]
+name = "sqlalchemy"
+version = "2.0.31"
+description = "Database Abstraction Library"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f2a213c1b699d3f5768a7272de720387ae0122f1becf0901ed6eaa1abd1baf6c"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9fea3d0884e82d1e33226935dac990b967bef21315cbcc894605db3441347443"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ad7f221d8a69d32d197e5968d798217a4feebe30144986af71ada8c548e9fa"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2bee229715b6366f86a95d497c347c22ddffa2c7c96143b59a2aa5cc9eebbc"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cd5b94d4819c0c89280b7c6109c7b788a576084bf0a480ae17c227b0bc41e109"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:750900a471d39a7eeba57580b11983030517a1f512c2cb287d5ad0fcf3aebd58"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-win32.whl", hash = "sha256:7bd112be780928c7f493c1a192cd8c5fc2a2a7b52b790bc5a84203fb4381c6be"},
+    {file = "SQLAlchemy-2.0.31-cp310-cp310-win_amd64.whl", hash = "sha256:5a48ac4d359f058474fadc2115f78a5cdac9988d4f99eae44917f36aa1476327"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f68470edd70c3ac3b6cd5c2a22a8daf18415203ca1b036aaeb9b0fb6f54e8298"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e2c38c2a4c5c634fe6c3c58a789712719fa1bf9b9d6ff5ebfce9a9e5b89c1ca"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd15026f77420eb2b324dcb93551ad9c5f22fab2c150c286ef1dc1160f110203"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2196208432deebdfe3b22185d46b08f00ac9d7b01284e168c212919891289396"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:352b2770097f41bff6029b280c0e03b217c2dcaddc40726f8f53ed58d8a85da4"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56d51ae825d20d604583f82c9527d285e9e6d14f9a5516463d9705dab20c3740"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-win32.whl", hash = "sha256:6e2622844551945db81c26a02f27d94145b561f9d4b0c39ce7bfd2fda5776dac"},
+    {file = "SQLAlchemy-2.0.31-cp311-cp311-win_amd64.whl", hash = "sha256:ccaf1b0c90435b6e430f5dd30a5aede4764942a695552eb3a4ab74ed63c5b8d3"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"},
+    {file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f43e93057cf52a227eda401251c72b6fbe4756f35fa6bfebb5d73b86881e59b0"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d337bf94052856d1b330d5fcad44582a30c532a2463776e1651bd3294ee7e58b"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c06fb43a51ccdff3b4006aafee9fcf15f63f23c580675f7734245ceb6b6a9e05"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:b6e22630e89f0e8c12332b2b4c282cb01cf4da0d26795b7eae16702a608e7ca1"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:79a40771363c5e9f3a77f0e28b3302801db08040928146e6808b5b7a40749c88"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-win32.whl", hash = "sha256:501ff052229cb79dd4c49c402f6cb03b5a40ae4771efc8bb2bfac9f6c3d3508f"},
+    {file = "SQLAlchemy-2.0.31-cp37-cp37m-win_amd64.whl", hash = "sha256:597fec37c382a5442ffd471f66ce12d07d91b281fd474289356b1a0041bdf31d"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dc6d69f8829712a4fd799d2ac8d79bdeff651c2301b081fd5d3fe697bd5b4ab9"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23b9fbb2f5dd9e630db70fbe47d963c7779e9c81830869bd7d137c2dc1ad05fb"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21c97efcbb9f255d5c12a96ae14da873233597dfd00a3a0c4ce5b3e5e79704"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a6a9837589c42b16693cf7bf836f5d42218f44d198f9343dd71d3164ceeeac"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc251477eae03c20fae8db9c1c23ea2ebc47331bcd73927cdcaecd02af98d3c3"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2fd17e3bb8058359fa61248c52c7b09a97cf3c820e54207a50af529876451808"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-win32.whl", hash = "sha256:c76c81c52e1e08f12f4b6a07af2b96b9b15ea67ccdd40ae17019f1c373faa227"},
+    {file = "SQLAlchemy-2.0.31-cp38-cp38-win_amd64.whl", hash = "sha256:4b600e9a212ed59355813becbcf282cfda5c93678e15c25a0ef896b354423238"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b6cf796d9fcc9b37011d3f9936189b3c8074a02a4ed0c0fbbc126772c31a6d4"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:78fe11dbe37d92667c2c6e74379f75746dc947ee505555a0197cfba9a6d4f1a4"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc47dc6185a83c8100b37acda27658fe4dbd33b7d5e7324111f6521008ab4fe"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a41514c1a779e2aa9a19f67aaadeb5cbddf0b2b508843fcd7bafdf4c6864005"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:afb6dde6c11ea4525318e279cd93c8734b795ac8bb5dda0eedd9ebaca7fa23f1"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f9faef422cfbb8fd53716cd14ba95e2ef655400235c3dfad1b5f467ba179c8c"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-win32.whl", hash = "sha256:fc6b14e8602f59c6ba893980bea96571dd0ed83d8ebb9c4479d9ed5425d562e9"},
+    {file = "SQLAlchemy-2.0.31-cp39-cp39-win_amd64.whl", hash = "sha256:3cb8a66b167b033ec72c3812ffc8441d4e9f5f78f5e31e54dcd4c90a4ca5bebc"},
+    {file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"},
+    {file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"},
+]
+
+[package.dependencies]
+greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""}
+typing-extensions = ">=4.6.0"
+
+[package.extras]
+aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
+aioodbc = ["aioodbc", "greenlet (!=0.4.17)"]
+aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
+asyncio = ["greenlet (!=0.4.17)"]
+asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
+mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
+mssql = ["pyodbc"]
+mssql-pymssql = ["pymssql"]
+mssql-pyodbc = ["pyodbc"]
+mypy = ["mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0)"]
+mysql-connector = ["mysql-connector-python"]
+oracle = ["cx_oracle (>=8)"]
+oracle-oracledb = ["oracledb (>=1.0.1)"]
+postgresql = ["psycopg2 (>=2.7)"]
+postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
+postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
+postgresql-psycopg = ["psycopg (>=3.0.7)"]
+postgresql-psycopg2binary = ["psycopg2-binary"]
+postgresql-psycopg2cffi = ["psycopg2cffi"]
+postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
+pymysql = ["pymysql"]
+sqlcipher = ["sqlcipher3_binary"]
+
 [[package]]
 name = "starlette"
 version = "0.38.0"
@@ -981,6 +1327,17 @@ files = [
     {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
 ]
 
+[[package]]
+name = "tzdata"
+version = "2024.1"
+description = "Provider of IANA time zone data"
+optional = false
+python-versions = ">=2"
+files = [
+    {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
+    {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
+]
+
 [[package]]
 name = "urllib3"
 version = "2.2.2"
@@ -1258,4 +1615,4 @@ files = [
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.12"
-content-hash = "87d3616ee3731f4a180dfb8f8acc630e94b25d9c486311c90f35e9d8622d1c5e"
+content-hash = "2aa841718abdad1aad43b31843f4cb3cc292c75ac7e23db80b84d41407361cf0"
diff --git a/pyproject.toml b/pyproject.toml
index 1bf7240..422d1e9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,22 +4,29 @@ version = "0.1.0"
 description = "Backend for Python Discord Forms"
 authors = ["Joe Banks <joseph@josephbanks.me>"]
 license = "MIT"
+package-mode = false
 
 [tool.poetry.dependencies]
 python = "^3.12"
+
 starlette = "^0.38.0"
 nested_dict = "^1.61"
 uvicorn = { extras = ["standard"], version = "^0.30.1" }
-motor = "3.5.1"
+httpx = "^0.27.0"
+
 python-dotenv = "^1.0.1"
 pyjwt = "^2.8.0"
-httpx = "^0.27.0"
 pydantic = "^1.10.17"
 spectree = "^1.2.10"
 deepmerge = "^1.1.1"
 sentry-sdk = "^2.7.1"
 condorcet = "^0.1.1"
+
 redis = "^5.0.7"
+motor = "3.5.1"
+alembic = {version = "^1.13.2", extras = ["tz"]}
+sqlalchemy = {version = "^2.0.31", extras = ["asyncio"]}
+psycopg = {version = "3.2.1", extras = ["binary"]}
 
 [tool.poetry.group.typing.dependencies]
 types-redis = "^4.6.0.20240425"

From 3b5d3e4424c23c8ab27e2c469e3d10af860bbf2e Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Mon, 8 Jul 2024 21:35:06 +0100
Subject: [PATCH 2/7] Update middleware to use SQLA to create db sessions

---
 backend/__init__.py   |  7 +++++++
 backend/constants.py  | 11 +++++++++--
 backend/middleware.py |  8 +++++---
 3 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/backend/__init__.py b/backend/__init__.py
index c2e1335..eb276c0 100644
--- a/backend/__init__.py
+++ b/backend/__init__.py
@@ -1,3 +1,6 @@
+import asyncio
+import os
+
 import sentry_sdk
 from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
 from starlette.applications import Starlette
@@ -14,6 +17,10 @@
 from backend.route_manager import create_route_map
 from backend.validation import api
 
+# On Windows, the selector event loop is required for psycopg.
+if os.name == "nt":
+    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
 ORIGINS = [
     r"(https://[^.?#]*--pydis-forms\.netlify\.app)",  # Netlify Previews
     r"(https?://[^.?#]*.forms-frontend.pages.dev)",  # Cloudflare Previews
diff --git a/backend/constants.py b/backend/constants.py
index 1e55cd2..eb0c68f 100644
--- a/backend/constants.py
+++ b/backend/constants.py
@@ -4,17 +4,24 @@
 
 from dotenv import load_dotenv
 from redis.asyncio import Redis as _Redis
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
 
 load_dotenv()
 
 
 FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com")
-DATABASE_URL = os.getenv("DATABASE_URL")
-MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms")
 SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval")
 
 REDIS_CLIENT = _Redis.from_url(os.getenv("REDIS_URL"), encoding="utf-8")
 
+MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms")
+MONGO_DATABASE_URL = os.getenv("MONGO_DATABASE_URL")
+
+PSQL_DATABASE_URL = os.getenv("PSQL_DATABASE_URL")
+DATABASE_ECHO = os.getenv("DATABASE_ECHO", "false").lower() == "true"
+_DB_ENGINE = create_async_engine(PSQL_DATABASE_URL, echo=DATABASE_ECHO)
+DB_SESSION_MAKER = async_sessionmaker(_DB_ENGINE)
+
 PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false"
 PRODUCTION_URL = "https://forms.pythondiscord.com"
 
diff --git a/backend/middleware.py b/backend/middleware.py
index 0b08859..5b36473 100644
--- a/backend/middleware.py
+++ b/backend/middleware.py
@@ -3,7 +3,7 @@
 from starlette.responses import JSONResponse
 from starlette.types import ASGIApp, Receive, Scope, Send
 
-from backend.constants import DATABASE_URL, DOCS_PASSWORD, MONGO_DATABASE
+from backend.constants import DB_SESSION_MAKER, DOCS_PASSWORD, MONGO_DATABASE, MONGO_DATABASE_URL
 
 
 class DatabaseMiddleware:
@@ -12,12 +12,14 @@ def __init__(self, app: ASGIApp) -> None:
 
     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
         client: AsyncIOMotorClient = AsyncIOMotorClient(
-            DATABASE_URL,
+            MONGO_DATABASE_URL,
             tlsAllowInvalidCertificates=True,
         )
         db = client[MONGO_DATABASE]
         Request(scope).state.db = db
-        await self._app(scope, receive, send)
+        async with DB_SESSION_MAKER() as session, session.begin():
+            Request(scope).state.psql_db = session
+            await self._app(scope, receive, send)
 
 
 class ProtectedDocsMiddleware:

From 41169d155f025c78a68889d36b3cc4ebb07a99cf Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Mon, 8 Jul 2024 21:35:32 +0100
Subject: [PATCH 3/7] Move existing models to schemas namespace

This is to make room for a new ORM namespace for SQLAlchemy models
---
 backend/authentication/user.py             |  5 ++--
 backend/discord.py                         | 23 +++++++++---------
 backend/models/__init__.py                 | 19 ---------------
 backend/models/dtos/__init__.py            | 19 +++++++++++++++
 backend/models/{ => dtos}/antispam.py      |  0
 backend/models/{ => dtos}/discord_role.py  |  0
 backend/models/{ => dtos}/discord_user.py  |  0
 backend/models/{ => dtos}/form.py          |  0
 backend/models/{ => dtos}/form_response.py |  0
 backend/models/{ => dtos}/question.py      |  0
 backend/models/orm/__init__.py             |  0
 backend/routes/discord.py                  |  9 +++----
 backend/routes/forms/condorcet.py          |  2 +-
 backend/routes/forms/discover.py           |  2 +-
 backend/routes/forms/form.py               |  2 +-
 backend/routes/forms/index.py              |  4 ++--
 backend/routes/forms/response.py           |  2 +-
 backend/routes/forms/responses.py          |  2 +-
 backend/routes/forms/submit.py             |  2 +-
 backend/routes/forms/unittesting.py        |  2 +-
 docker-compose.yml                         | 28 ++++++++++++++++++----
 21 files changed, 72 insertions(+), 49 deletions(-)
 create mode 100644 backend/models/dtos/__init__.py
 rename backend/models/{ => dtos}/antispam.py (100%)
 rename backend/models/{ => dtos}/discord_role.py (100%)
 rename backend/models/{ => dtos}/discord_user.py (100%)
 rename backend/models/{ => dtos}/form.py (100%)
 rename backend/models/{ => dtos}/form_response.py (100%)
 rename backend/models/{ => dtos}/question.py (100%)
 create mode 100644 backend/models/orm/__init__.py

diff --git a/backend/authentication/user.py b/backend/authentication/user.py
index 5e99546..6ad4c63 100644
--- a/backend/authentication/user.py
+++ b/backend/authentication/user.py
@@ -4,8 +4,9 @@
 from pymongo.database import Database
 from starlette.authentication import BaseUser
 
-from backend import discord, models
+from backend import discord
 from backend.constants import SECRET_KEY
+from backend.models import dtos
 
 
 class User(BaseUser):
@@ -15,7 +16,7 @@ def __init__(
         self,
         token: str,
         payload: dict[str, t.Any],
-        member: models.DiscordMember | None,
+        member: dtos.DiscordMember | None,
     ) -> None:
         self.token = token
         self.payload = payload
diff --git a/backend/discord.py b/backend/discord.py
index 4a1ecf5..22826e2 100644
--- a/backend/discord.py
+++ b/backend/discord.py
@@ -6,7 +6,8 @@
 import starlette.requests
 from starlette import exceptions
 
-from backend import constants, models
+from backend import constants
+from backend.models import dtos
 
 
 async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict:
@@ -51,7 +52,7 @@ async def fetch_user_details(bearer_token: str) -> dict:
         return r.json()
 
 
-async def _get_role_info() -> list[models.DiscordRole]:
+async def _get_role_info() -> list[dtos.DiscordRole]:
     """Get information about the roles in the configured guild."""
     async with httpx.AsyncClient() as client:
         r = await client.get(
@@ -60,13 +61,13 @@ async def _get_role_info() -> list[models.DiscordRole]:
         )
 
         r.raise_for_status()
-        return [models.DiscordRole(**role) for role in r.json()]
+        return [dtos.DiscordRole(**role) for role in r.json()]
 
 
 async def get_roles(
     *,
     force_refresh: bool = False,
-) -> list[models.DiscordRole]:
+) -> list[dtos.DiscordRole]:
     """
     Get a list of all roles from the cache, or discord API if not available.
 
@@ -77,7 +78,7 @@ async def get_roles(
         roles = await constants.REDIS_CLIENT.hgetall(role_cache_key)
         if roles:
             return [
-                models.DiscordRole(**json.loads(role_data)) for role_id, role_data in roles.items()
+                dtos.DiscordRole(**json.loads(role_data)) for role_id, role_data in roles.items()
             ]
 
     roles = await _get_role_info()
@@ -86,7 +87,7 @@ async def get_roles(
     return roles
 
 
-async def _fetch_member_api(member_id: str) -> models.DiscordMember | None:
+async def _fetch_member_api(member_id: str) -> dtos.DiscordMember | None:
     """Get a member by ID from the configured guild using the discord API."""
     async with httpx.AsyncClient() as client:
         r = await client.get(
@@ -99,14 +100,14 @@ async def _fetch_member_api(member_id: str) -> models.DiscordMember | None:
             return None
 
         r.raise_for_status()
-        return models.DiscordMember(**r.json())
+        return dtos.DiscordMember(**r.json())
 
 
 async def get_member(
     user_id: str,
     *,
     force_refresh: bool = False,
-) -> models.DiscordMember | None:
+) -> dtos.DiscordMember | None:
     """
     Get a member from the cache, or from the discord API.
 
@@ -118,7 +119,7 @@ async def get_member(
     if not force_refresh:
         result = await constants.REDIS_CLIENT.get(member_key)
         if result:
-            return models.DiscordMember(**json.loads(result))
+            return dtos.DiscordMember(**json.loads(result))
 
     member = await _fetch_member_api(user_id)
     if member:
@@ -150,14 +151,14 @@ async def _verify_access_helper(
     if "admin" in request.auth.scopes:
         return
 
-    form = models.Form(**form)
+    form = dtos.Form(**form)
 
     for role_id in getattr(form, attribute, None) or []:
         role = await request.state.db.roles.find_one({"id": role_id})
         if not role:
             continue
 
-        role = models.DiscordRole(**json.loads(role["data"]))
+        role = dtos.DiscordRole(**json.loads(role["data"]))
 
         if role.name in request.auth.scopes:
             return
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index 336e28b..e69de29 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -1,19 +0,0 @@
-from .antispam import AntiSpam
-from .discord_role import DiscordRole
-from .discord_user import DiscordMember, DiscordUser
-from .form import Form, FormList
-from .form_response import FormResponse, ResponseList
-from .question import CodeQuestion, Question
-
-__all__ = [
-    "AntiSpam",
-    "CodeQuestion",
-    "DiscordMember",
-    "DiscordRole",
-    "DiscordUser",
-    "Form",
-    "FormList",
-    "FormResponse",
-    "Question",
-    "ResponseList",
-]
diff --git a/backend/models/dtos/__init__.py b/backend/models/dtos/__init__.py
new file mode 100644
index 0000000..336e28b
--- /dev/null
+++ b/backend/models/dtos/__init__.py
@@ -0,0 +1,19 @@
+from .antispam import AntiSpam
+from .discord_role import DiscordRole
+from .discord_user import DiscordMember, DiscordUser
+from .form import Form, FormList
+from .form_response import FormResponse, ResponseList
+from .question import CodeQuestion, Question
+
+__all__ = [
+    "AntiSpam",
+    "CodeQuestion",
+    "DiscordMember",
+    "DiscordRole",
+    "DiscordUser",
+    "Form",
+    "FormList",
+    "FormResponse",
+    "Question",
+    "ResponseList",
+]
diff --git a/backend/models/antispam.py b/backend/models/dtos/antispam.py
similarity index 100%
rename from backend/models/antispam.py
rename to backend/models/dtos/antispam.py
diff --git a/backend/models/discord_role.py b/backend/models/dtos/discord_role.py
similarity index 100%
rename from backend/models/discord_role.py
rename to backend/models/dtos/discord_role.py
diff --git a/backend/models/discord_user.py b/backend/models/dtos/discord_user.py
similarity index 100%
rename from backend/models/discord_user.py
rename to backend/models/dtos/discord_user.py
diff --git a/backend/models/form.py b/backend/models/dtos/form.py
similarity index 100%
rename from backend/models/form.py
rename to backend/models/dtos/form.py
diff --git a/backend/models/form_response.py b/backend/models/dtos/form_response.py
similarity index 100%
rename from backend/models/form_response.py
rename to backend/models/dtos/form_response.py
diff --git a/backend/models/question.py b/backend/models/dtos/question.py
similarity index 100%
rename from backend/models/question.py
rename to backend/models/dtos/question.py
diff --git a/backend/models/orm/__init__.py b/backend/models/orm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/routes/discord.py b/backend/routes/discord.py
index 5cd6b47..1a56aac 100644
--- a/backend/routes/discord.py
+++ b/backend/routes/discord.py
@@ -6,7 +6,8 @@
 from starlette.responses import JSONResponse
 from starlette.routing import Request
 
-from backend import discord, models, route
+from backend import discord, route
+from backend.models import dtos
 from backend.validation import ErrorMessage, api
 
 NOT_FOUND_EXCEPTION = JSONResponse(
@@ -24,7 +25,7 @@ class RolesRoute(route.Route):
     class RolesResponse(pydantic.BaseModel):
         """A list of all roles on the configured server."""
 
-        roles: list[models.DiscordRole]
+        roles: list[dtos.DiscordRole]
 
     @requires(["authenticated", "admin"])
     @api.validate(
@@ -53,7 +54,7 @@ class MemberRequest(pydantic.BaseModel):
 
     @requires(["authenticated", "admin"])
     @api.validate(
-        resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage),
+        resp=Response(HTTP_200=dtos.DiscordMember, HTTP_400=ErrorMessage),
         json=MemberRequest,
         tags=["auth"],
     )
@@ -68,7 +69,7 @@ async def delete(self, request: Request) -> JSONResponse:
 
     @requires(["authenticated", "admin"])
     @api.validate(
-        resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage),
+        resp=Response(HTTP_200=dtos.DiscordMember, HTTP_400=ErrorMessage),
         json=MemberRequest,
         tags=["auth"],
     )
diff --git a/backend/routes/forms/condorcet.py b/backend/routes/forms/condorcet.py
index 902770b..ac7e52e 100644
--- a/backend/routes/forms/condorcet.py
+++ b/backend/routes/forms/condorcet.py
@@ -9,7 +9,7 @@
 from starlette.responses import JSONResponse
 
 from backend import discord
-from backend.models import Form, FormResponse, Question
+from backend.models.dtos import Form, FormResponse, Question
 from backend.route import Route
 from backend.validation import api
 
diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py
index 0fe10b5..43e6cf3 100644
--- a/backend/routes/forms/discover.py
+++ b/backend/routes/forms/discover.py
@@ -5,7 +5,7 @@
 from starlette.responses import JSONResponse
 
 from backend import constants
-from backend.models import Form, FormList, Question
+from backend.models.dtos import Form, FormList, Question
 from backend.route import Route
 from backend.validation import api
 
diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py
index 86bbf49..c96d0d6 100644
--- a/backend/routes/forms/form.py
+++ b/backend/routes/forms/form.py
@@ -10,7 +10,7 @@
 from starlette.responses import JSONResponse
 
 from backend import constants, discord
-from backend.models import Form
+from backend.models.dtos import Form
 from backend.route import Route
 from backend.routes.forms.discover import AUTH_FORM
 from backend.validation import ErrorMessage, OkayResponse, api
diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py
index 1fdfc48..4b55af2 100644
--- a/backend/routes/forms/index.py
+++ b/backend/routes/forms/index.py
@@ -6,8 +6,8 @@
 from starlette.responses import JSONResponse
 
 from backend.constants import WebHook
-from backend.models import Form, FormList
-from backend.models.form import validate_hook_url
+from backend.models.dtos import Form, FormList
+from backend.models.dtos.form import validate_hook_url
 from backend.route import Route
 from backend.validation import ErrorMessage, OkayResponse, api
 
diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py
index b4f7f04..ac80b74 100644
--- a/backend/routes/forms/response.py
+++ b/backend/routes/forms/response.py
@@ -6,7 +6,7 @@
 from starlette.responses import JSONResponse
 
 from backend import discord
-from backend.models import FormResponse
+from backend.models.dtos import FormResponse
 from backend.route import Route
 from backend.validation import ErrorMessage, OkayResponse, api
 
diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py
index 85e5af2..4228af8 100644
--- a/backend/routes/forms/responses.py
+++ b/backend/routes/forms/responses.py
@@ -7,7 +7,7 @@
 from starlette.responses import JSONResponse
 
 from backend import discord
-from backend.models import FormResponse, ResponseList
+from backend.models.dtos import FormResponse, ResponseList
 from backend.route import Route
 from backend.validation import ErrorMessage, OkayResponse, api
 
diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py
index 01c32cc..45636b7 100644
--- a/backend/routes/forms/submit.py
+++ b/backend/routes/forms/submit.py
@@ -19,7 +19,7 @@
 
 from backend import constants
 from backend.authentication.user import User
-from backend.models import Form, FormResponse
+from backend.models.dtos import Form, FormResponse
 from backend.route import Route
 from backend.routes.auth.authorize import set_response_token
 from backend.routes.forms.discover import AUTH_FORM
diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py
index 57c3a86..469243c 100644
--- a/backend/routes/forms/unittesting.py
+++ b/backend/routes/forms/unittesting.py
@@ -8,7 +8,7 @@
 from httpx import HTTPStatusError
 
 from backend.constants import SNEKBOX_URL
-from backend.models import Form, FormResponse
+from backend.models.dtos import Form, FormResponse
 
 with Path("resources/unittest_template.py").open(encoding="utf8") as file:
     TEST_TEMPLATE = file.read()
diff --git a/docker-compose.yml b/docker-compose.yml
index a9363f8..b8d58da 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,22 +20,42 @@ services:
     ports:
       - "127.0.0.1:6379:6379"
 
+  postgres:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_DB: backend
+      POSTGRES_PASSWORD: backend
+      POSTGRES_USER: backend
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U backend"]
+      interval: 2s
+      timeout: 1s
+      retries: 5
+    ports:
+      - 5000:5432
+
   backend:
     build: .
     command: ["uvicorn", "--reload", "--host", "0.0.0.0", "backend:app"]
     ports:
       - "127.0.0.1:8000:8000"
     depends_on:
-      - mongo
-      - snekbox
-      - redis
+      mongo:
+        condition: service_started
+      snekbox:
+        condition: service_started
+      redis:
+        condition: service_started
+      postgres:
+        condition: service_healthy
     tty: true
     env_file:
       - .env
     volumes:
       - .:/app:ro
     environment:
-      - DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017
+      - MONGO_DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017
+      - PSQL_DATABASE_URL=postgresql+psycopg_async://backend:backend@postgres:5432/backend
       - SNEKBOX_URL=http://snekbox:8060/eval
       - OAUTH2_CLIENT_ID
       - OAUTH2_CLIENT_SECRET

From 6de1e262a478973ff3cec0ca896682c3ecdde090 Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Tue, 9 Jul 2024 19:44:24 +0100
Subject: [PATCH 4/7] Add alembic boiler plate for migrations

---
 Dockerfile                      |  4 +-
 alembic.ini                     | 53 ++++++++++++++++++++
 backend/models/orm/__init__.py  |  7 +++
 backend/models/orm/base.py      | 25 ++++++++++
 docker-compose.yml              |  2 +-
 migrations/__init__.py          |  0
 migrations/env.py               | 86 +++++++++++++++++++++++++++++++++
 migrations/script.py.mako       | 27 +++++++++++
 migrations/versions/__init__.py |  0
 pyproject.toml                  |  4 ++
 10 files changed, 205 insertions(+), 3 deletions(-)
 create mode 100644 alembic.ini
 create mode 100644 backend/models/orm/base.py
 create mode 100644 migrations/__init__.py
 create mode 100644 migrations/env.py
 create mode 100644 migrations/script.py.mako
 create mode 100644 migrations/versions/__init__.py

diff --git a/Dockerfile b/Dockerfile
index 1229b56..af798d6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,5 +18,5 @@ ARG git_sha="development"
 ENV GIT_SHA=$git_sha
 
 # Start the server with uvicorn
-ENTRYPOINT ["poetry", "run"]
-CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "8000"]
+ENTRYPOINT ["/bin/bash", "-c"]
+CMD ["poetry run alembic upgrade head && poetry run uvicorn backend:app --host 0.0.0.0 --port 8000"]
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000..89c7735
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,53 @@
+# A generic, single database configuration.
+
+[alembic]
+script_location = migrations
+file_template = %%(epoch)s-%%(rev)s_%%(slug)s
+prepend_sys_path = .
+timezone = utc
+version_path_separator = os
+output_encoding = utf-8
+
+[post_write_hooks]
+hooks = ruff-lint, ruff-format
+ruff-lint.type = exec
+ruff-lint.executable = ruff
+ruff-lint.options = check --fix-only REVISION_SCRIPT_FILENAME
+
+ruff-format.type = exec
+ruff-format.executable = ruff
+ruff-format.options = format REVISION_SCRIPT_FILENAME
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/models/orm/__init__.py b/backend/models/orm/__init__.py
index e69de29..4c8a6b4 100644
--- a/backend/models/orm/__init__.py
+++ b/backend/models/orm/__init__.py
@@ -0,0 +1,7 @@
+"""Database models."""
+
+from .base import Base
+
+__all__ = (
+    "Base",
+)
diff --git a/backend/models/orm/base.py b/backend/models/orm/base.py
new file mode 100644
index 0000000..adf9270
--- /dev/null
+++ b/backend/models/orm/base.py
@@ -0,0 +1,25 @@
+"""The base classes for ORM models."""
+
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncAttrs
+from sqlalchemy.orm import DeclarativeBase
+from sqlalchemy.schema import MetaData
+
+NAMING_CONVENTIONS = {
+    "ix": "%(column_0_label)s_ix",
+    "uq": "%(table_name)s_%(column_0_name)s_uq",
+    "ck": "%(table_name)s_%(constraint_name)s_ck",
+    "fk": "%(table_name)s_%(column_0_name)s_%(referred_table_name)s_fk",
+    "pk": "%(table_name)s_pk",
+}
+
+
+class Base(AsyncAttrs, DeclarativeBase):
+    """Classes that inherit this class will be automatically mapped using declarative mapping."""
+
+    metadata = MetaData(naming_convention=NAMING_CONVENTIONS)
+
+    def patch_from_pydantic(self, pydantic_model: BaseModel) -> None:
+        """Patch this model using the given pydantic model, unspecified attributes remain the same."""
+        for key, value in pydantic_model.dict(exclude_unset=True).items():
+            setattr(self, key, value)
diff --git a/docker-compose.yml b/docker-compose.yml
index b8d58da..5be7843 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -36,7 +36,7 @@ services:
 
   backend:
     build: .
-    command: ["uvicorn", "--reload", "--host", "0.0.0.0", "backend:app"]
+    command: ["poetry run alembic upgrade head && poetry run uvicorn backend:app --reload --host 0.0.0.0 --port 8000"]
     ports:
       - "127.0.0.1:8000:8000"
     depends_on:
diff --git a/migrations/__init__.py b/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..36ddea1
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,86 @@
+import asyncio
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from sqlalchemy.ext.asyncio import AsyncEngine
+from sqlalchemy.ext.asyncio.engine import AsyncConnection
+
+# This is a required step by Alembic to properly generate migrations
+import backend.models.orm
+from backend.constants import PSQL_DATABASE_URL
+
+target_metadata = backend.models.orm.base.Base.metadata
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+config.set_main_option("sqlalchemy.url", PSQL_DATABASE_URL)
+
+
+def run_migrations_offline() -> None:
+    """
+    Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def do_run_migrations(connection: AsyncConnection) -> None:
+    """Run all migrations on the given connection."""
+    context.configure(
+        connection=connection,
+        target_metadata=target_metadata,
+        compare_type=True,
+        compare_server_default=True,
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_migrations_online() -> None:
+    """
+    Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+    """
+    connectable = AsyncEngine(
+        engine_from_config(
+            config.get_section(config.config_ini_section),
+            prefix="sqlalchemy.",
+            poolclass=pool.NullPool,
+            future=True,
+        ),
+    )
+
+    async with connectable.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    asyncio.run(run_migrations_online())
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..1d3e1aa
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,27 @@
+"""
+${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+import sqlalchemy as sa
+from alembic import op
+
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    """Apply this migration."""
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    """Revert this migration."""
+    ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pyproject.toml b/pyproject.toml
index 422d1e9..c5a18b9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,6 +78,10 @@ ignore = [
 ]
 
 [tool.ruff.lint.isort]
+known-first-party = ["migrations", "backend"]
 order-by-type = false
 case-sensitive = true
 combine-as-imports = true
+
+[tool.ruff.lint.per-file-ignores]
+"migrations/*" = ["N999"]

From a439ef0fefe9f171ca1568869820ee159036bdfc Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Tue, 9 Jul 2024 20:08:44 +0100
Subject: [PATCH 5/7] Add models and migration files for admins & forms

---
 backend/models/orm/__init__.py                | 30 ++++++
 backend/models/orm/admins.py                  | 14 +++
 backend/models/orm/forms.py                   | 61 ++++++++++++
 ...721486482-a9ea4b71d23a_add_admins_forms.py | 93 +++++++++++++++++++
 4 files changed, 198 insertions(+)
 create mode 100644 backend/models/orm/admins.py
 create mode 100644 backend/models/orm/forms.py
 create mode 100644 migrations/versions/1721486482-a9ea4b71d23a_add_admins_forms.py

diff --git a/backend/models/orm/__init__.py b/backend/models/orm/__init__.py
index 4c8a6b4..4db39ae 100644
--- a/backend/models/orm/__init__.py
+++ b/backend/models/orm/__init__.py
@@ -1,7 +1,37 @@
 """Database models."""
 
+from .admins import Admin
 from .base import Base
+from .forms import Form, FormEditor, FormFeatures
+from .questions import (
+    FormCheckboxQuestion,
+    FormCodeQuestion,
+    FormCodeQuestionTest,
+    FormQuestion,
+    FormRadioQuestion,
+    FormRangeQuestion,
+    FormSectionQuestion,
+    FormSelectQuestion,
+    FormTextQuestion,
+    FormTimezoneQuestion,
+    FormVoteQuestion,
+)
 
 __all__ = (
+    "Admin",
     "Base",
+    "Form",
+    "FormCheckboxQuestion",
+    "FormCodeQuestion",
+    "FormCodeQuestionTest",
+    "FormEditor",
+    "FormFeatures",
+    "FormQuestion",
+    "FormRadioQuestion",
+    "FormRangeQuestion",
+    "FormSectionQuestion",
+    "FormSelectQuestion",
+    "FormTextQuestion",
+    "FormTimezoneQuestion",
+    "FormVoteQuestion",
 )
diff --git a/backend/models/orm/admins.py b/backend/models/orm/admins.py
new file mode 100644
index 0000000..7eee008
--- /dev/null
+++ b/backend/models/orm/admins.py
@@ -0,0 +1,14 @@
+"""Discord members who have admin access."""
+
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import BigInteger
+
+from .base import Base
+
+
+class Admin(Base):
+    """A discord user_id that has admin level access to forms."""
+
+    __tablename__ = "admins"
+
+    user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
diff --git a/backend/models/orm/forms.py b/backend/models/orm/forms.py
new file mode 100644
index 0000000..489b71b
--- /dev/null
+++ b/backend/models/orm/forms.py
@@ -0,0 +1,61 @@
+"""All forms that can have submissions."""
+
+import sqlalchemy.dialects.postgresql as pg
+from sqlalchemy import ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.types import BigInteger, Enum, Text
+
+from backend.constants import FormFeatures
+
+from .base import Base
+
+
+class Form(Base):
+    """A form that users can submit responses to."""
+
+    __tablename__ = "forms"
+
+    form_id: Mapped[int] = mapped_column(primary_key=True)
+
+    short_name: Mapped[str] = mapped_column(Text, nullable=False, index=True)
+    name: Mapped[str] = mapped_column(Text, nullable=False)
+    description: Mapped[str] = mapped_column(Text, nullable=False)
+    submission_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
+    webhook_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    discord_role: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+    features: Mapped[list[FormFeatures]] = mapped_column(pg.ARRAY(Enum(FormFeatures), dimensions=1))
+
+    form_response_readers: Mapped[list["FormResponseReader"]] = relationship(
+        cascade="all, delete",
+        passive_deletes=True,
+    )
+    form_editors: Mapped[list["FormEditor"]] = relationship(
+        cascade="all, delete",
+        passive_deletes=True,
+    )
+
+
+class FormResponseReader(Base):
+    """A Discord user that can read a given form."""
+
+    __tablename__ = "form_response_readers"
+
+    form_id: Mapped[int] = mapped_column(
+        ForeignKey("forms.form_id", ondelete="CASCADE"), primary_key=True
+    )
+    user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+
+
+class FormEditor(Base):
+    """A Discord user that can edit a given form."""
+
+    __tablename__ = "form_editors"
+
+    form_id: Mapped[int] = mapped_column(
+        ForeignKey("forms.form_id", ondelete="CASCADE"), primary_key=True
+    )
+    user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
diff --git a/migrations/versions/1721486482-a9ea4b71d23a_add_admins_forms.py b/migrations/versions/1721486482-a9ea4b71d23a_add_admins_forms.py
new file mode 100644
index 0000000..c61944b
--- /dev/null
+++ b/migrations/versions/1721486482-a9ea4b71d23a_add_admins_forms.py
@@ -0,0 +1,93 @@
+"""
+Add admins & forms.
+
+Revision ID: a9ea4b71d23a
+Revises:
+Create Date: 2024-07-20 14:41:22.166383+00:00
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "a9ea4b71d23a"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    """Apply this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "admins",
+        sa.Column("user_id", sa.BigInteger(), nullable=False),
+        sa.PrimaryKeyConstraint("user_id", name=op.f("admins_pk")),
+    )
+    op.create_table(
+        "forms",
+        sa.Column("form_id", sa.Integer(), nullable=False),
+        sa.Column("short_name", sa.Text(), nullable=False),
+        sa.Column("name", sa.Text(), nullable=False),
+        sa.Column("description", sa.Text(), nullable=False),
+        sa.Column("submission_text", sa.Text(), nullable=True),
+        sa.Column("webhook_url", sa.Text(), nullable=True),
+        sa.Column("webhook_message", sa.Text(), nullable=True),
+        sa.Column("discord_role", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "features",
+            postgresql.ARRAY(
+                sa.Enum(
+                    "DISCOVERABLE",
+                    "REQUIRES_LOGIN",
+                    "OPEN",
+                    "COLLECT_EMAIL",
+                    "DISABLE_ANTISPAM",
+                    "WEBHOOK_ENABLED",
+                    "ASSIGN_ROLE",
+                    name="formfeatures",
+                ),
+                dimensions=1,
+            ),
+            nullable=False,
+        ),
+        sa.PrimaryKeyConstraint("form_id", name=op.f("forms_pk")),
+    )
+    op.create_index(op.f("forms_short_name_ix"), "forms", ["short_name"], unique=False)
+    op.create_table(
+        "form_editors",
+        sa.Column("form_id", sa.Integer(), nullable=False),
+        sa.Column("user_id", sa.BigInteger(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["form_id"],
+            ["forms.form_id"],
+            name=op.f("form_editors_form_id_forms_fk"),
+            ondelete="CASCADE",
+        ),
+        sa.PrimaryKeyConstraint("form_id", "user_id", name=op.f("form_editors_pk")),
+    )
+    op.create_table(
+        "form_response_readers",
+        sa.Column("form_id", sa.Integer(), nullable=False),
+        sa.Column("user_id", sa.BigInteger(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["form_id"],
+            ["forms.form_id"],
+            name=op.f("form_response_readers_form_id_forms_fk"),
+            ondelete="CASCADE",
+        ),
+        sa.PrimaryKeyConstraint("form_id", "user_id", name=op.f("form_response_readers_pk")),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Revert this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table("form_response_readers")
+    op.drop_table("form_editors")
+    op.drop_index(op.f("forms_short_name_ix"), table_name="forms")
+    op.drop_table("forms")
+    op.drop_table("admins")
+    # ### end Alembic commands ###

From 228cacbd8eda41cdc4179e0e66d7a0bdc04f30fe Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Sat, 20 Jul 2024 17:48:49 +0100
Subject: [PATCH 6/7] Add models & migrations for form questions

---
 backend/constants.py                          |   6 +
 backend/models/orm/form_questions.py          | 198 ++++++++++++++++++
 backend/models/orm/forms.py                   |  10 +
 ...7-787116206e6d_add_form_question_models.py | 160 ++++++++++++++
 ...f2f206168e_link_form_questions_to_forms.py |  36 ++++
 5 files changed, 410 insertions(+)
 create mode 100644 backend/models/orm/form_questions.py
 create mode 100644 migrations/versions/1721493967-787116206e6d_add_form_question_models.py
 create mode 100644 migrations/versions/1721564095-bef2f206168e_link_form_questions_to_forms.py

diff --git a/backend/constants.py b/backend/constants.py
index eb0c68f..4bb051c 100644
--- a/backend/constants.py
+++ b/backend/constants.py
@@ -56,6 +56,12 @@
     "vote",
 ]
 
+
+class TextType(Enum):
+    SHORT_TEXT = "short_text"
+    TEXT_AREA = "text_area"
+
+
 REQUIRED_QUESTION_TYPE_DATA = {
     "radio": {
         "options": list,
diff --git a/backend/models/orm/form_questions.py b/backend/models/orm/form_questions.py
new file mode 100644
index 0000000..cde1d0b
--- /dev/null
+++ b/backend/models/orm/form_questions.py
@@ -0,0 +1,198 @@
+"""Discord members who have admin access."""
+
+from typing import ClassVar
+
+from sqlalchemy import Enum, ForeignKey, Text
+from sqlalchemy.dialects.postgresql import ARRAY
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.constants import TextType
+
+from .base import Base
+
+
+class FormQuestion(Base):
+    __tablename__ = "form_questions"
+
+    question_id: Mapped[int] = mapped_column(primary_key=True)
+    form_id: Mapped[int] = mapped_column(ForeignKey("forms.form_id"))
+    name: Mapped[str]
+    type: Mapped[str]
+    required: Mapped[bool]
+
+    __mapper_args__: ClassVar = {"polymorphic_identity": "form_questions", "polymorphic_on": "type"}
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.name!r})"
+
+
+class QuestionWithOptions:
+    options: Mapped[list[str]] = mapped_column(
+        ARRAY(Text),
+        nullable=False,
+        use_existing_column=True,
+    )
+
+
+class FormRadioQuestion(QuestionWithOptions, FormQuestion):
+    """A radio question type."""
+
+    __tablename__ = "form_radio_questions"
+
+    radio_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_radio_questions",
+    }
+
+
+class FormCheckboxQuestion(QuestionWithOptions, FormQuestion):
+    """A radio question type."""
+
+    __tablename__ = "form_checkbox_questions"
+
+    checkbox_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_checkbox_questions",
+    }
+
+
+class FormRangeQuestion(QuestionWithOptions, FormQuestion):
+    """A range question type."""
+
+    __tablename__ = "form_range_questions"
+
+    range_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_range_questions",
+    }
+
+
+class FormVoteQuestion(QuestionWithOptions, FormQuestion):
+    """A vote question type."""
+
+    __tablename__ = "form_vote_questions"
+
+    range_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_vote_questions",
+    }
+
+
+class FormSelectQuestion(QuestionWithOptions, FormQuestion):
+    """A select question type."""
+
+    __tablename__ = "form_select_questions"
+
+    select_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_select_questions",
+    }
+
+
+class FormTextQuestion(FormQuestion):
+    """A text question type."""
+
+    __tablename__ = "form_text_questions"
+
+    text_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+    text_type: Mapped[TextType] = mapped_column(Enum(TextType))
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_text_questions",
+    }
+
+
+class FormCodeQuestion(FormQuestion):
+    """A code question type."""
+
+    __tablename__ = "form_code_questions"
+
+    code_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+    language: Mapped[str]
+    allow_failure: Mapped[bool]
+    unit_tests: Mapped[list["FormCodeQuestionTest"]] = relationship(
+        cascade="all, delete",
+        passive_deletes=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_code_questions",
+    }
+
+
+class FormCodeQuestionTest(Base):
+    """Unit tests for a given code question."""
+
+    __tablename__ = "form_code_question_tests"
+    test_id: Mapped[int] = mapped_column(primary_key=True)
+    code_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_code_questions.code_question_id")
+    )
+    name: Mapped[str]
+    code: Mapped[str]
+
+
+class FormSectionQuestion(FormQuestion):
+    """A section question type."""
+
+    __tablename__ = "form_section_questions"
+
+    section_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+    text: Mapped[str]
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_section_questions",
+    }
+
+
+class FormTimezoneQuestion(FormQuestion):
+    """A timezone question type."""
+
+    __tablename__ = "form_timezone_questions"
+
+    timezone_question_id: Mapped[int] = mapped_column(
+        ForeignKey("form_questions.question_id"),
+        primary_key=True,
+    )
+
+    __mapper_args__: ClassVar = {
+        "polymorphic_load": "selectin",
+        "polymorphic_identity": "form_timezone_questions",
+    }
diff --git a/backend/models/orm/forms.py b/backend/models/orm/forms.py
index 489b71b..71119c7 100644
--- a/backend/models/orm/forms.py
+++ b/backend/models/orm/forms.py
@@ -1,5 +1,7 @@
 """All forms that can have submissions."""
 
+from typing import TYPE_CHECKING
+
 import sqlalchemy.dialects.postgresql as pg
 from sqlalchemy import ForeignKey
 from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -9,6 +11,9 @@
 
 from .base import Base
 
+if TYPE_CHECKING:
+    from . import FormQuestion
+
 
 class Form(Base):
     """A form that users can submit responses to."""
@@ -22,6 +27,11 @@ class Form(Base):
     description: Mapped[str] = mapped_column(Text, nullable=False)
     submission_text: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    form_questions: Mapped[list["FormQuestion"]] = relationship(
+        cascade="all, delete",
+        passive_deletes=True,
+    )
+
     webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
     webhook_message: Mapped[str | None] = mapped_column(Text, nullable=True)
 
diff --git a/migrations/versions/1721493967-787116206e6d_add_form_question_models.py b/migrations/versions/1721493967-787116206e6d_add_form_question_models.py
new file mode 100644
index 0000000..17acd61
--- /dev/null
+++ b/migrations/versions/1721493967-787116206e6d_add_form_question_models.py
@@ -0,0 +1,160 @@
+"""
+Add form question models.
+
+Revision ID: 787116206e6d
+Revises: a9ea4b71d23a
+Create Date: 2024-07-20 16:46:07.590445+00:00
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "787116206e6d"
+down_revision = "a9ea4b71d23a"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    """Apply this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "form_questions",
+        sa.Column("question_id", sa.Integer(), nullable=False),
+        sa.Column("name", sa.String(), nullable=False),
+        sa.Column("type", sa.String(), nullable=False),
+        sa.Column("required", sa.Boolean(), nullable=False),
+        sa.PrimaryKeyConstraint("question_id", name=op.f("form_questions_pk")),
+    )
+    op.create_table(
+        "form_checkbox_questions",
+        sa.Column("checkbox_question_id", sa.Integer(), nullable=False),
+        sa.Column("options", postgresql.ARRAY(sa.Text()), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["checkbox_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_checkbox_questions_checkbox_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("checkbox_question_id", name=op.f("form_checkbox_questions_pk")),
+    )
+    op.create_table(
+        "form_code_questions",
+        sa.Column("code_question_id", sa.Integer(), nullable=False),
+        sa.Column("language", sa.String(), nullable=False),
+        sa.Column("allow_failure", sa.Boolean(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["code_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_code_questions_code_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("code_question_id", name=op.f("form_code_questions_pk")),
+    )
+    op.create_table(
+        "form_radio_questions",
+        sa.Column("radio_question_id", sa.Integer(), nullable=False),
+        sa.Column("options", postgresql.ARRAY(sa.Text()), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["radio_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_radio_questions_radio_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("radio_question_id", name=op.f("form_radio_questions_pk")),
+    )
+    op.create_table(
+        "form_range_questions",
+        sa.Column("range_question_id", sa.Integer(), nullable=False),
+        sa.Column("options", postgresql.ARRAY(sa.Text()), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["range_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_range_questions_range_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("range_question_id", name=op.f("form_range_questions_pk")),
+    )
+    op.create_table(
+        "form_section_questions",
+        sa.Column("section_question_id", sa.Integer(), nullable=False),
+        sa.Column("text", sa.String(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["section_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_section_questions_section_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("section_question_id", name=op.f("form_section_questions_pk")),
+    )
+    op.create_table(
+        "form_select_questions",
+        sa.Column("select_question_id", sa.Integer(), nullable=False),
+        sa.Column("options", postgresql.ARRAY(sa.Text()), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["select_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_select_questions_select_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("select_question_id", name=op.f("form_select_questions_pk")),
+    )
+    op.create_table(
+        "form_text_questions",
+        sa.Column("text_question_id", sa.Integer(), nullable=False),
+        sa.Column("text_type", sa.Enum("SHORT_TEXT", "TEXT_AREA", name="texttype"), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["text_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_text_questions_text_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("text_question_id", name=op.f("form_text_questions_pk")),
+    )
+    op.create_table(
+        "form_timezone_questions",
+        sa.Column("timezone_question_id", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["timezone_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_timezone_questions_timezone_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("timezone_question_id", name=op.f("form_timezone_questions_pk")),
+    )
+    op.create_table(
+        "form_vote_questions",
+        sa.Column("range_question_id", sa.Integer(), nullable=False),
+        sa.Column("options", postgresql.ARRAY(sa.Text()), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["range_question_id"],
+            ["form_questions.question_id"],
+            name=op.f("form_vote_questions_range_question_id_form_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("range_question_id", name=op.f("form_vote_questions_pk")),
+    )
+    op.create_table(
+        "form_code_question_tests",
+        sa.Column("test_id", sa.Integer(), nullable=False),
+        sa.Column("code_question_id", sa.Integer(), nullable=False),
+        sa.Column("name", sa.String(), nullable=False),
+        sa.Column("code", sa.String(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["code_question_id"],
+            ["form_code_questions.code_question_id"],
+            name=op.f("form_code_question_tests_code_question_id_form_code_questions_fk"),
+        ),
+        sa.PrimaryKeyConstraint("test_id", name=op.f("form_code_question_tests_pk")),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Revert this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table("form_code_question_tests")
+    op.drop_table("form_vote_questions")
+    op.drop_table("form_timezone_questions")
+    op.drop_table("form_text_questions")
+    op.drop_table("form_select_questions")
+    op.drop_table("form_section_questions")
+    op.drop_table("form_range_questions")
+    op.drop_table("form_radio_questions")
+    op.drop_table("form_code_questions")
+    op.drop_table("form_checkbox_questions")
+    op.drop_table("form_questions")
+    # ### end Alembic commands ###
diff --git a/migrations/versions/1721564095-bef2f206168e_link_form_questions_to_forms.py b/migrations/versions/1721564095-bef2f206168e_link_form_questions_to_forms.py
new file mode 100644
index 0000000..e84ecf4
--- /dev/null
+++ b/migrations/versions/1721564095-bef2f206168e_link_form_questions_to_forms.py
@@ -0,0 +1,36 @@
+"""
+Link form questions to forms.
+
+Revision ID: bef2f206168e
+Revises: 787116206e6d
+Create Date: 2024-07-21 12:14:55.545648+00:00
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "bef2f206168e"
+down_revision = "787116206e6d"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    """Apply this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column("form_questions", sa.Column("form_id", sa.Integer(), nullable=False))
+    op.create_foreign_key(
+        op.f("form_questions_form_id_forms_fk"), "form_questions", "forms", ["form_id"], ["form_id"]
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Revert this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint(
+        op.f("form_questions_form_id_forms_fk"), "form_questions", type_="foreignkey"
+    )
+    op.drop_column("form_questions", "form_id")
+    # ### end Alembic commands ###

From 0c79a18a5661623f13e83c7636953fc49bc95c51 Mon Sep 17 00:00:00 2001
From: Chris Lovering <chris.lovering.95@gmail.com>
Date: Sun, 21 Jul 2024 13:44:36 +0100
Subject: [PATCH 7/7] Add form response model & migrations

---
 backend/models/orm/__init__.py                |  6 ++-
 backend/models/orm/form_responses.py          | 28 +++++++++++
 backend/models/orm/forms.py                   |  6 ++-
 ...1565620-9ee599d0f323_add_form_responses.py | 46 +++++++++++++++++++
 4 files changed, 83 insertions(+), 3 deletions(-)
 create mode 100644 backend/models/orm/form_responses.py
 create mode 100644 migrations/versions/1721565620-9ee599d0f323_add_form_responses.py

diff --git a/backend/models/orm/__init__.py b/backend/models/orm/__init__.py
index 4db39ae..4b4e99b 100644
--- a/backend/models/orm/__init__.py
+++ b/backend/models/orm/__init__.py
@@ -2,8 +2,7 @@
 
 from .admins import Admin
 from .base import Base
-from .forms import Form, FormEditor, FormFeatures
-from .questions import (
+from .form_questions import (
     FormCheckboxQuestion,
     FormCodeQuestion,
     FormCodeQuestionTest,
@@ -16,6 +15,8 @@
     FormTimezoneQuestion,
     FormVoteQuestion,
 )
+from .form_responses import FormResponse
+from .forms import Form, FormEditor, FormFeatures
 
 __all__ = (
     "Admin",
@@ -29,6 +30,7 @@
     "FormQuestion",
     "FormRadioQuestion",
     "FormRangeQuestion",
+    "FormResponse",
     "FormSectionQuestion",
     "FormSelectQuestion",
     "FormTextQuestion",
diff --git a/backend/models/orm/form_responses.py b/backend/models/orm/form_responses.py
new file mode 100644
index 0000000..d9876b9
--- /dev/null
+++ b/backend/models/orm/form_responses.py
@@ -0,0 +1,28 @@
+"""A submitted response to a form."""
+
+from datetime import datetime
+
+from sqlalchemy import ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import BigInteger, DateTime, Text
+
+from .base import Base
+
+
+class FormResponse(Base):
+    """A submitted response to a form."""
+
+    __tablename__ = "form_responses"
+
+    response_id: Mapped[int] = mapped_column(primary_key=True)
+    form_id: Mapped[int] = mapped_column(ForeignKey("forms.form_id"))
+    submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+
+    user_id: Mapped[int] = mapped_column(BigInteger)
+    username: Mapped[str] = mapped_column(Text)
+    user_email: Mapped[str] = mapped_column(Text, nullable=True)
+    user_is_admin: Mapped[bool]
+
+    antispam_ip_hash: Mapped[str] = mapped_column(Text, nullable=True)
+    antispam_user_agent_hash: Mapped[str] = mapped_column(Text, nullable=True)
+    antispam_captcha_pass: Mapped[bool] = mapped_column(nullable=True)
diff --git a/backend/models/orm/forms.py b/backend/models/orm/forms.py
index 71119c7..0c1a4e3 100644
--- a/backend/models/orm/forms.py
+++ b/backend/models/orm/forms.py
@@ -12,7 +12,7 @@
 from .base import Base
 
 if TYPE_CHECKING:
-    from . import FormQuestion
+    from . import FormQuestion, FormResponse
 
 
 class Form(Base):
@@ -31,6 +31,10 @@ class Form(Base):
         cascade="all, delete",
         passive_deletes=True,
     )
+    form_responses: Mapped[list["FormResponse"]] = relationship(
+        cascade="all, delete",
+        passive_deletes=True,
+    )
 
     webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
     webhook_message: Mapped[str | None] = mapped_column(Text, nullable=True)
diff --git a/migrations/versions/1721565620-9ee599d0f323_add_form_responses.py b/migrations/versions/1721565620-9ee599d0f323_add_form_responses.py
new file mode 100644
index 0000000..1927f16
--- /dev/null
+++ b/migrations/versions/1721565620-9ee599d0f323_add_form_responses.py
@@ -0,0 +1,46 @@
+"""
+Add form responses.
+
+Revision ID: 9ee599d0f323
+Revises: bef2f206168e
+Create Date: 2024-07-21 12:40:20.326205+00:00
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "9ee599d0f323"
+down_revision = "bef2f206168e"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    """Apply this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "form_responses",
+        sa.Column("response_id", sa.Integer(), nullable=False),
+        sa.Column("form_id", sa.Integer(), nullable=False),
+        sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=False),
+        sa.Column("user_id", sa.BigInteger(), nullable=False),
+        sa.Column("username", sa.Text(), nullable=False),
+        sa.Column("user_email", sa.Text(), nullable=True),
+        sa.Column("user_is_admin", sa.Boolean(), nullable=False),
+        sa.Column("antispam_ip_hash", sa.Text(), nullable=True),
+        sa.Column("antispam_user_agent_hash", sa.Text(), nullable=True),
+        sa.Column("antispam_captcha_pass", sa.Boolean(), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["form_id"], ["forms.form_id"], name=op.f("form_responses_form_id_forms_fk")
+        ),
+        sa.PrimaryKeyConstraint("response_id", name=op.f("form_responses_pk")),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Revert this migration."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table("form_responses")
+    # ### end Alembic commands ###