feature/db #2

Merged
tliu93 merged 16 commits from feature/db into main 2025-09-18 14:28:18 +02:00
15 changed files with 1828 additions and 37 deletions

29
.github/workflows/backend-ci.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Backend CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install deps
run: pip install -r dev-requirements.txt
- name: Run tests
run: |
pytest -q

4
backend/.gitignore vendored
View File

@@ -11,3 +11,7 @@ venv.bak/
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.db
*.db-shm
*.db-wal

View File

@@ -1,2 +1,2 @@
-r requirements.txt -r requirements.in
pytest pytest

View File

@@ -7,55 +7,101 @@
annotated-types==0.7.0 \ annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
# via # via pydantic
# -r requirements.txt
# pydantic
anyio==4.10.0 \ anyio==4.10.0 \
--hash=sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6 \ --hash=sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6 \
--hash=sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1 --hash=sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1
# via # via
# -r requirements.txt
# httpx # httpx
# starlette # starlette
certifi==2025.8.3 \ certifi==2025.8.3 \
--hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \ --hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \
--hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5 --hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5
# via # via
# -r requirements.txt
# httpcore # httpcore
# httpx # httpx
click==8.2.1 \ click==8.2.1 \
--hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \
--hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b
# via # via uvicorn
# -r requirements.txt
# uvicorn
fastapi==0.116.1 \ fastapi==0.116.1 \
--hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \ --hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \
--hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143 --hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143
# via -r requirements.txt # via -r requirements.in
greenlet==3.2.4 \
--hash=sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b \
--hash=sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735 \
--hash=sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079 \
--hash=sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d \
--hash=sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433 \
--hash=sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58 \
--hash=sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52 \
--hash=sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31 \
--hash=sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246 \
--hash=sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f \
--hash=sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671 \
--hash=sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8 \
--hash=sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d \
--hash=sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f \
--hash=sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0 \
--hash=sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd \
--hash=sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337 \
--hash=sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0 \
--hash=sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633 \
--hash=sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b \
--hash=sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa \
--hash=sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31 \
--hash=sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9 \
--hash=sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b \
--hash=sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4 \
--hash=sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc \
--hash=sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c \
--hash=sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98 \
--hash=sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f \
--hash=sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c \
--hash=sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590 \
--hash=sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3 \
--hash=sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2 \
--hash=sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9 \
--hash=sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5 \
--hash=sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02 \
--hash=sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0 \
--hash=sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1 \
--hash=sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c \
--hash=sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594 \
--hash=sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5 \
--hash=sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d \
--hash=sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a \
--hash=sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6 \
--hash=sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b \
--hash=sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df \
--hash=sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945 \
--hash=sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae \
--hash=sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb \
--hash=sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504 \
--hash=sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb \
--hash=sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01 \
--hash=sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c \
--hash=sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968
# via sqlalchemy
h11==0.16.0 \ h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via # via
# -r requirements.txt
# httpcore # httpcore
# uvicorn # uvicorn
httpcore==1.0.9 \ httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via # via httpx
# -r requirements.txt
# httpx
httpx==0.28.1 \ httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r requirements.txt # via -r requirements.in
idna==3.10 \ idna==3.10 \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
# via # via
# -r requirements.txt
# anyio # anyio
# httpx # httpx
iniconfig==2.1.0 \ iniconfig==2.1.0 \
@@ -74,9 +120,9 @@ pydantic==2.11.7 \
--hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \ --hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \
--hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b --hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b
# via # via
# -r requirements.txt
# fastapi # fastapi
# pydantic-settings # pydantic-settings
# sqlmodel
pydantic-core==2.33.2 \ pydantic-core==2.33.2 \
--hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \
--hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \
@@ -177,13 +223,11 @@ pydantic-core==2.33.2 \
--hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \
--hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \
--hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d
# via # via pydantic
# -r requirements.txt
# pydantic
pydantic-settings==2.10.1 \ pydantic-settings==2.10.1 \
--hash=sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee \ --hash=sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee \
--hash=sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796 --hash=sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796
# via -r requirements.txt # via -r requirements.in
pygments==2.19.2 \ pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
@@ -195,9 +239,7 @@ pytest==8.4.2 \
python-dotenv==1.1.1 \ python-dotenv==1.1.1 \
--hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \ --hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \
--hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab --hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab
# via # via pydantic-settings
# -r requirements.txt
# pydantic-settings
pyyaml==6.0.2 \ pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
@@ -252,38 +294,96 @@ pyyaml==6.0.2 \
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via -r requirements.txt # via -r requirements.in
sniffio==1.3.1 \ sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via # via anyio
# -r requirements.txt sqlalchemy==2.0.43 \
# anyio --hash=sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019 \
--hash=sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7 \
--hash=sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c \
--hash=sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227 \
--hash=sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf \
--hash=sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed \
--hash=sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a \
--hash=sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa \
--hash=sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc \
--hash=sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48 \
--hash=sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a \
--hash=sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24 \
--hash=sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9 \
--hash=sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed \
--hash=sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b \
--hash=sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83 \
--hash=sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e \
--hash=sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad \
--hash=sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687 \
--hash=sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782 \
--hash=sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f \
--hash=sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b \
--hash=sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3 \
--hash=sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a \
--hash=sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685 \
--hash=sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe \
--hash=sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29 \
--hash=sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921 \
--hash=sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738 \
--hash=sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185 \
--hash=sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9 \
--hash=sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547 \
--hash=sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069 \
--hash=sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417 \
--hash=sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d \
--hash=sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154 \
--hash=sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b \
--hash=sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197 \
--hash=sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18 \
--hash=sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f \
--hash=sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164 \
--hash=sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414 \
--hash=sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d \
--hash=sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c \
--hash=sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612 \
--hash=sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34 \
--hash=sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8 \
--hash=sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20 \
--hash=sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32 \
--hash=sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443 \
--hash=sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7 \
--hash=sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512 \
--hash=sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca \
--hash=sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00 \
--hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \
--hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \
--hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d
# via sqlmodel
sqlmodel==0.0.24 \
--hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \
--hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423
# via -r requirements.in
starlette==0.47.3 \ starlette==0.47.3 \
--hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \ --hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \
--hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 --hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51
# via # via fastapi
# -r requirements.txt
# fastapi
typing-extensions==4.15.0 \ typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via # via
# -r requirements.txt
# anyio # anyio
# fastapi # fastapi
# pydantic # pydantic
# pydantic-core # pydantic-core
# sqlalchemy
# starlette # starlette
# typing-inspection # typing-inspection
typing-inspection==0.4.1 \ typing-inspection==0.4.1 \
--hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \
--hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28
# via # via
# -r requirements.txt
# pydantic # pydantic
# pydantic-settings # pydantic-settings
uvicorn==0.35.0 \ uvicorn==0.35.0 \
--hash=sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a \ --hash=sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a \
--hash=sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01 --hash=sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01
# via -r requirements.txt # via -r requirements.in

View File

@@ -3,3 +3,4 @@ uvicorn
httpx httpx
pyyaml pyyaml
pydantic-settings pydantic-settings
sqlmodel

View File

@@ -28,6 +28,62 @@ fastapi==0.116.1 \
--hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \ --hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \
--hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143 --hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143
# via -r requirements.in # via -r requirements.in
greenlet==3.2.4 \
--hash=sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b \
--hash=sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735 \
--hash=sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079 \
--hash=sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d \
--hash=sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433 \
--hash=sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58 \
--hash=sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52 \
--hash=sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31 \
--hash=sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246 \
--hash=sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f \
--hash=sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671 \
--hash=sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8 \
--hash=sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d \
--hash=sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f \
--hash=sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0 \
--hash=sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd \
--hash=sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337 \
--hash=sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0 \
--hash=sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633 \
--hash=sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b \
--hash=sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa \
--hash=sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31 \
--hash=sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9 \
--hash=sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b \
--hash=sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4 \
--hash=sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc \
--hash=sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c \
--hash=sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98 \
--hash=sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f \
--hash=sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c \
--hash=sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590 \
--hash=sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3 \
--hash=sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2 \
--hash=sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9 \
--hash=sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5 \
--hash=sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02 \
--hash=sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0 \
--hash=sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1 \
--hash=sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c \
--hash=sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594 \
--hash=sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5 \
--hash=sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d \
--hash=sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a \
--hash=sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6 \
--hash=sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b \
--hash=sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df \
--hash=sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945 \
--hash=sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae \
--hash=sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb \
--hash=sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504 \
--hash=sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb \
--hash=sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01 \
--hash=sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c \
--hash=sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968
# via sqlalchemy
h11==0.16.0 \ h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -54,6 +110,7 @@ pydantic==2.11.7 \
# via # via
# fastapi # fastapi
# pydantic-settings # pydantic-settings
# sqlmodel
pydantic-core==2.33.2 \ pydantic-core==2.33.2 \
--hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \
--hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \
@@ -222,6 +279,69 @@ sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via anyio # via anyio
sqlalchemy==2.0.43 \
--hash=sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019 \
--hash=sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7 \
--hash=sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c \
--hash=sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227 \
--hash=sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf \
--hash=sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed \
--hash=sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a \
--hash=sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa \
--hash=sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc \
--hash=sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48 \
--hash=sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a \
--hash=sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24 \
--hash=sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9 \
--hash=sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed \
--hash=sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b \
--hash=sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83 \
--hash=sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e \
--hash=sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad \
--hash=sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687 \
--hash=sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782 \
--hash=sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f \
--hash=sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b \
--hash=sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3 \
--hash=sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a \
--hash=sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685 \
--hash=sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe \
--hash=sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29 \
--hash=sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921 \
--hash=sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738 \
--hash=sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185 \
--hash=sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9 \
--hash=sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547 \
--hash=sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069 \
--hash=sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417 \
--hash=sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d \
--hash=sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154 \
--hash=sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b \
--hash=sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197 \
--hash=sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18 \
--hash=sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f \
--hash=sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164 \
--hash=sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414 \
--hash=sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d \
--hash=sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c \
--hash=sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612 \
--hash=sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34 \
--hash=sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8 \
--hash=sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20 \
--hash=sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32 \
--hash=sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443 \
--hash=sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7 \
--hash=sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512 \
--hash=sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca \
--hash=sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00 \
--hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \
--hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \
--hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d
# via sqlmodel
sqlmodel==0.0.24 \
--hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \
--hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423
# via -r requirements.in
starlette==0.47.3 \ starlette==0.47.3 \
--hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \ --hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \
--hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 --hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51
@@ -234,6 +354,7 @@ typing-extensions==4.15.0 \
# fastapi # fastapi
# pydantic # pydantic
# pydantic-core # pydantic-core
# sqlalchemy
# starlette # starlette
# typing-inspection # typing-inspection
typing-inspection==0.4.1 \ typing-inspection==0.4.1 \

View File

@@ -4,7 +4,17 @@ line-length = 144
[lint] [lint]
select = ["ALL"] select = ["ALL"]
fixable = ["UP034", "I001"] fixable = ["UP034", "I001"]
ignore = ["T201", "D", "ANN101", "TD002", "TD003"] ignore = [
"T201",
"D",
"ANN101",
"TD002",
"TD003",
"TRY003",
"EM101",
"EM102",
"PLC0405",
]
[lint.extend-per-file-ignores] [lint.extend-per-file-ignores]
"test*.py" = ["S101"] "test*.py" = ["S101"]

542
backend/tests/test_crud.py Normal file
View File

@@ -0,0 +1,542 @@
from collections.abc import Generator
from datetime import datetime
import pytest
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel
from trading_journal import crud, models
# TODO: If needed, add failing flow tests, but now only add happy flow.
@pytest.fixture
def engine() -> Generator[Engine, None, None]:
e = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(e)
try:
yield e
finally:
SQLModel.metadata.drop_all(e)
e.dispose()
@pytest.fixture
def session(engine: Engine) -> Generator[Session, None, None]:
with Session(engine) as s:
yield s
def make_user(session: Session, username: str = "testuser") -> int:
user = models.Users(username=username, password_hash="hashedpassword")
session.add(user)
session.commit()
session.refresh(user)
return user.id
def make_cycle(session, user_id: int, friendly_name: str = "Test Cycle") -> int:
cycle = models.Cycles(
user_id=user_id,
friendly_name=friendly_name,
symbol="AAPL",
underlying_currency=models.UnderlyingCurrency.USD,
status=models.CycleStatus.OPEN,
start_date=datetime.now().date(),
)
session.add(cycle)
session.commit()
session.refresh(cycle)
return cycle.id
def make_trade(
session, user_id: int, cycle_id: int, friendly_name: str = "Test Trade"
) -> int:
trade = models.Trades(
user_id=user_id,
friendly_name=friendly_name,
symbol="AAPL",
underlying_currency=models.UnderlyingCurrency.USD,
trade_type=models.TradeType.LONG_SPOT,
trade_strategy=models.TradeStrategy.SPOT,
trade_date=datetime.now().date(),
trade_time_utc=datetime.now(),
quantity=10,
price_cents=15000,
gross_cash_flow_cents=-150000,
commission_cents=500,
net_cash_flow_cents=-150500,
cycle_id=cycle_id,
notes="Initial test trade",
)
session.add(trade)
session.commit()
session.refresh(trade)
return trade.id
def make_trade_by_trade_data(session, trade_data: dict) -> int:
trade = models.Trades(**trade_data)
session.add(trade)
session.commit()
session.refresh(trade)
return trade.id
def test_create_trade_success_with_cycle(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
trade_data = {
"user_id": user_id,
"friendly_name": "Test Trade",
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
"commission_cents": 500,
"net_cash_flow_cents": -150500,
"cycle_id": cycle_id,
}
trade = crud.create_trade(session, trade_data)
assert trade.id is not None
assert trade.user_id == user_id
assert trade.cycle_id == cycle_id
session.refresh(trade)
actual_trade = session.get(models.Trades, trade.id)
assert actual_trade is not None
assert actual_trade.friendly_name == trade_data["friendly_name"]
assert actual_trade.symbol == trade_data["symbol"]
assert actual_trade.underlying_currency == trade_data["underlying_currency"]
assert actual_trade.trade_type == trade_data["trade_type"]
assert actual_trade.trade_strategy == trade_data["trade_strategy"]
assert actual_trade.quantity == trade_data["quantity"]
assert actual_trade.price_cents == trade_data["price_cents"]
assert actual_trade.gross_cash_flow_cents == trade_data["gross_cash_flow_cents"]
assert actual_trade.commission_cents == trade_data["commission_cents"]
assert actual_trade.net_cash_flow_cents == trade_data["net_cash_flow_cents"]
assert actual_trade.cycle_id == trade_data["cycle_id"]
def test_create_trade_with_auto_created_cycle(session: Session):
user_id = make_user(session)
trade_data = {
"user_id": user_id,
"friendly_name": "Test Trade with Auto Cycle",
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"quantity": 5,
"price_cents": 15500,
}
trade = crud.create_trade(session, trade_data)
assert trade.id is not None
assert trade.user_id == user_id
assert trade.cycle_id is not None
session.refresh(trade)
actual_trade = session.get(models.Trades, trade.id)
assert actual_trade is not None
assert actual_trade.friendly_name == trade_data["friendly_name"]
assert actual_trade.symbol == trade_data["symbol"]
assert actual_trade.underlying_currency == trade_data["underlying_currency"]
assert actual_trade.trade_type == trade_data["trade_type"]
assert actual_trade.trade_strategy == trade_data["trade_strategy"]
assert actual_trade.quantity == trade_data["quantity"]
assert actual_trade.price_cents == trade_data["price_cents"]
assert actual_trade.cycle_id == trade.cycle_id
# Verify the auto-created cycle
auto_cycle = session.get(models.Cycles, trade.cycle_id)
assert auto_cycle is not None
assert auto_cycle.user_id == user_id
assert auto_cycle.symbol == trade_data["symbol"]
assert auto_cycle.underlying_currency == trade_data["underlying_currency"]
assert auto_cycle.status == models.CycleStatus.OPEN
assert auto_cycle.friendly_name.startswith("Auto-created Cycle by trade")
def test_create_trade_missing_required_fields(session: Session):
user_id = make_user(session)
base_trade_data = {
"user_id": user_id,
"friendly_name": "Incomplete Trade",
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"quantity": 10,
"price_cents": 15000,
}
# Missing symbol
trade_data = base_trade_data.copy()
trade_data.pop("symbol", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "symbol is required" in str(excinfo.value)
# Missing underlying_currency
trade_data = base_trade_data.copy()
trade_data.pop("underlying_currency", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "underlying_currency is required" in str(excinfo.value)
# Missing trade_type
trade_data = base_trade_data.copy()
trade_data.pop("trade_type", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "trade_type is required" in str(excinfo.value)
# Missing trade_strategy
trade_data = base_trade_data.copy()
trade_data.pop("trade_strategy", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "trade_strategy is required" in str(excinfo.value)
# Missing quantity
trade_data = base_trade_data.copy()
trade_data.pop("quantity", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "quantity is required" in str(excinfo.value)
# Missing price_cents
trade_data = base_trade_data.copy()
trade_data.pop("price_cents", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "price_cents is required" in str(excinfo.value)
def test_get_trade_by_id(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
trade_data = {
"user_id": user_id,
"friendly_name": "Test Trade for Get",
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_date": datetime.now().date(),
"trade_time_utc": datetime.now(),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
"commission_cents": 500,
"net_cash_flow_cents": -150500,
"cycle_id": cycle_id,
}
trade_id = make_trade_by_trade_data(session, trade_data)
trade = crud.get_trade_by_id(session, trade_id)
assert trade is not None
assert trade.id == trade_id
assert trade.friendly_name == trade_data["friendly_name"]
assert trade.symbol == trade_data["symbol"]
assert trade.underlying_currency == trade_data["underlying_currency"]
assert trade.trade_type == trade_data["trade_type"]
assert trade.trade_strategy == trade_data["trade_strategy"]
assert trade.quantity == trade_data["quantity"]
assert trade.price_cents == trade_data["price_cents"]
assert trade.gross_cash_flow_cents == trade_data["gross_cash_flow_cents"]
assert trade.commission_cents == trade_data["commission_cents"]
assert trade.net_cash_flow_cents == trade_data["net_cash_flow_cents"]
assert trade.cycle_id == trade_data["cycle_id"]
assert trade.trade_date == trade_data["trade_date"]
def test_get_trade_by_user_id_and_friendly_name(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
friendly_name = "Unique Trade Name"
trade_data = {
"user_id": user_id,
"friendly_name": friendly_name,
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_date": datetime.now().date(),
"trade_time_utc": datetime.now(),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
"commission_cents": 500,
"net_cash_flow_cents": -150500,
"cycle_id": cycle_id,
}
make_trade_by_trade_data(session, trade_data)
trade = crud.get_trade_by_user_id_and_friendly_name(session, user_id, friendly_name)
assert trade is not None
assert trade.friendly_name == friendly_name
assert trade.user_id == user_id
def test_get_trades_by_user_id(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
trade_data_1 = {
"user_id": user_id,
"friendly_name": "Trade One",
"symbol": "AAPL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_date": datetime.now().date(),
"trade_time_utc": datetime.now(),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
"commission_cents": 500,
"net_cash_flow_cents": -150500,
"cycle_id": cycle_id,
}
trade_data_2 = {
"user_id": user_id,
"friendly_name": "Trade Two",
"symbol": "GOOGL",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.SHORT_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_date": datetime.now().date(),
"trade_time_utc": datetime.now(),
"quantity": 5,
"price_cents": 280000,
"gross_cash_flow_cents": 1400000,
"commission_cents": 700,
"net_cash_flow_cents": 1399300,
"cycle_id": cycle_id,
}
make_trade_by_trade_data(session, trade_data_1)
make_trade_by_trade_data(session, trade_data_2)
trades = crud.get_trades_by_user_id(session, user_id)
assert len(trades) == 2
friendly_names = {trade.friendly_name for trade in trades}
assert friendly_names == {"Trade One", "Trade Two"}
def test_update_trade_note(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
trade_id = make_trade(session, user_id, cycle_id)
new_note = "This is an updated note."
updated_trade = crud.update_trade_note(session, trade_id, new_note)
assert updated_trade is not None
assert updated_trade.id == trade_id
assert updated_trade.notes == new_note
session.refresh(updated_trade)
actual_trade = session.get(models.Trades, trade_id)
assert actual_trade is not None
assert actual_trade.notes == new_note
def test_invalidate_trade(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
trade_id = make_trade(session, user_id, cycle_id)
invalidated_trade = crud.invalidate_trade(session, trade_id)
assert invalidated_trade is not None
assert invalidated_trade.id == trade_id
assert invalidated_trade.is_invalidated is True
session.refresh(invalidated_trade)
actual_trade = session.get(models.Trades, trade_id)
assert actual_trade is not None
assert actual_trade.is_invalidated is True
def test_replace_trade(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
old_trade_id = make_trade(session, user_id, cycle_id)
new_trade_data = {
"user_id": user_id,
"friendly_name": "Replaced Trade",
"symbol": "MSFT",
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"quantity": 20,
"price_cents": 25000,
}
new_trade = crud.replace_trade(session, old_trade_id, new_trade_data)
assert new_trade.id is not None
assert new_trade.id != old_trade_id
assert new_trade.user_id == user_id
assert new_trade.symbol == new_trade_data["symbol"]
assert new_trade.quantity == new_trade_data["quantity"]
# Verify the old trade is invalidated
old_trade = session.get(models.Trades, old_trade_id)
assert old_trade is not None
assert old_trade.is_invalidated is True
# Verify the new trade exists
session.refresh(new_trade)
actual_new_trade = session.get(models.Trades, new_trade.id)
assert actual_new_trade is not None
assert actual_new_trade.friendly_name == new_trade_data["friendly_name"]
assert actual_new_trade.symbol == new_trade_data["symbol"]
assert actual_new_trade.underlying_currency == new_trade_data["underlying_currency"]
assert actual_new_trade.trade_type == new_trade_data["trade_type"]
assert actual_new_trade.trade_strategy == new_trade_data["trade_strategy"]
assert actual_new_trade.quantity == new_trade_data["quantity"]
assert actual_new_trade.price_cents == new_trade_data["price_cents"]
assert actual_new_trade.replaced_by_trade_id == old_trade_id
def test_create_cycle(session: Session):
user_id = make_user(session)
cycle_data = {
"user_id": user_id,
"friendly_name": "My First Cycle",
"symbol": "GOOGL",
"underlying_currency": models.UnderlyingCurrency.USD,
"status": models.CycleStatus.OPEN,
"start_date": datetime.now().date(),
}
cycle = crud.create_cycle(session, cycle_data)
assert cycle.id is not None
assert cycle.user_id == user_id
assert cycle.friendly_name == cycle_data["friendly_name"]
assert cycle.symbol == cycle_data["symbol"]
assert cycle.underlying_currency == cycle_data["underlying_currency"]
assert cycle.status == cycle_data["status"]
assert cycle.start_date == cycle_data["start_date"]
session.refresh(cycle)
actual_cycle = session.get(models.Cycles, cycle.id)
assert actual_cycle is not None
assert actual_cycle.friendly_name == cycle_data["friendly_name"]
assert actual_cycle.symbol == cycle_data["symbol"]
assert actual_cycle.underlying_currency == cycle_data["underlying_currency"]
assert actual_cycle.status == cycle_data["status"]
assert actual_cycle.start_date == cycle_data["start_date"]
def test_update_cycle(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name")
update_data = {
"friendly_name": "Updated Cycle Name",
"status": models.CycleStatus.CLOSED,
}
updated_cycle = crud.update_cycle(session, cycle_id, update_data)
assert updated_cycle is not None
assert updated_cycle.id == cycle_id
assert updated_cycle.friendly_name == update_data["friendly_name"]
assert updated_cycle.status == update_data["status"]
session.refresh(updated_cycle)
actual_cycle = session.get(models.Cycles, cycle_id)
assert actual_cycle is not None
assert actual_cycle.friendly_name == update_data["friendly_name"]
assert actual_cycle.status == update_data["status"]
def test_update_cycle_immutable_fields(session: Session):
user_id = make_user(session)
cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name")
# Attempt to update immutable fields
update_data = {
"id": cycle_id + 1, # Trying to change the ID
"user_id": user_id + 1, # Trying to change the user_id
"start_date": datetime(2020, 1, 1).date(), # Trying to change start_date
"created_at": datetime(2020, 1, 1), # Trying to change created_at
"friendly_name": "Valid Update", # Valid field to update
}
with pytest.raises(ValueError) as excinfo:
crud.update_cycle(session, cycle_id, update_data)
assert (
"field 'id' is immutable" in str(excinfo.value)
or "field 'user_id' is immutable" in str(excinfo.value)
or "field 'start_date' is immutable" in str(excinfo.value)
or "field 'created_at' is immutable" in str(excinfo.value)
)
def test_create_user(session: Session):
user_data = {
"username": "newuser",
"password_hash": "newhashedpassword",
}
user = crud.create_user(session, user_data)
assert user.id is not None
assert user.username == user_data["username"]
assert user.password_hash == user_data["password_hash"]
session.refresh(user)
actual_user = session.get(models.Users, user.id)
assert actual_user is not None
assert actual_user.username == user_data["username"]
assert actual_user.password_hash == user_data["password_hash"]
def test_update_user(session: Session):
user_id = make_user(session, username="updatableuser")
update_data = {
"password_hash": "updatedhashedpassword",
}
updated_user = crud.update_user(session, user_id, update_data)
assert updated_user is not None
assert updated_user.id == user_id
assert updated_user.password_hash == update_data["password_hash"]
session.refresh(updated_user)
actual_user = session.get(models.Users, user_id)
assert actual_user is not None
assert actual_user.password_hash == update_data["password_hash"]
def test_update_user_immutable_fields(session: Session):
user_id = make_user(session, username="immutableuser")
# Attempt to update immutable fields
update_data = {
"id": user_id + 1, # Trying to change the ID
"username": "newusername", # Trying to change the username
"created_at": datetime(2020, 1, 1), # Trying to change created_at
"password_hash": "validupdate", # Valid field to update
}
with pytest.raises(ValueError) as excinfo:
crud.update_user(session, user_id, update_data)
assert (
"field 'id' is immutable" in str(excinfo.value)
or "field 'username' is immutable" in str(excinfo.value)
or "field 'created_at' is immutable" in str(excinfo.value)
)

101
backend/tests/test_db.py Normal file
View File

@@ -0,0 +1,101 @@
from collections.abc import Generator
from contextlib import contextmanager, suppress
import pytest
from sqlalchemy import text
from sqlmodel import Session, SQLModel
from trading_journal.db import Database, create_database
@contextmanager
def session_ctx(db: Database) -> Generator[Session, None, None]:
"""
Drive Database.get_session() generator and correctly propagate exceptions
into the generator so the generator's except/rollback path runs.
"""
gen = db.get_session()
session = next(gen)
try:
yield session
except Exception as exc:
# Propagate the exception into the dependency generator so it can rollback.
with suppress(StopIteration):
gen.throw(exc)
raise
else:
# Normal completion: advance generator to let it commit/close.
with suppress(StopIteration):
next(gen)
finally:
# close the generator but DO NOT dispose the engine here
gen.close()
@contextmanager
def database_ctx(db: Database) -> Generator[Database, None, None]:
"""
Test-scoped context manager to ensure the Database (engine) is disposed at test end.
Use this to wrap test logic that needs the same in-memory engine across multiple sessions.
"""
try:
yield db
finally:
db.dispose()
def test_select_one_executes() -> None:
db = create_database(None) # in-memory by default
with database_ctx(db):
with session_ctx(db) as session:
val = session.exec(text("SELECT 1")).scalar_one()
assert int(val) == 1
def test_in_memory_persists_across_sessions_when_using_staticpool() -> None:
db = create_database(None) # in-memory with StaticPool
with database_ctx(db):
with session_ctx(db) as s1:
s1.exec(
text("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);")
)
s1.exec(text("INSERT INTO t (val) VALUES (:v)").bindparams(v="hello"))
with session_ctx(db) as s2:
got = s2.exec(text("SELECT val FROM t")).scalar_one()
assert got == "hello"
def test_sqlite_pragmas_applied() -> None:
db = create_database(None)
with database_ctx(db):
# PRAGMA returns integer 1 when foreign_keys ON
with session_ctx(db) as session:
fk = session.exec(text("PRAGMA foreign_keys")).scalar_one()
assert int(fk) == 1
def test_rollback_on_exception() -> None:
db = create_database(None)
SQLModel.metadata.clear()
db.init_db()
with database_ctx(db):
# Create table then insert and raise inside the same session to force rollback
with pytest.raises(RuntimeError): # noqa: PT012, SIM117
with session_ctx(db) as s:
s.exec(
text(
"CREATE TABLE IF NOT EXISTS t_rb (id INTEGER PRIMARY KEY, val TEXT);"
)
)
s.exec(
text("INSERT INTO t_rb (val) VALUES (:v)").bindparams(
v="will_rollback"
)
)
# simulate handler error -> should trigger rollback in get_session
raise RuntimeError("simulated failure")
# New session should not see the inserted row
with session_ctx(db) as s2:
rows = list(s2.exec(text("SELECT val FROM t_rb")).scalars())
assert rows == []

View File

@@ -0,0 +1,132 @@
import pytest
from sqlalchemy import text
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine
from trading_journal import db_migration
def _base_type_of(compiled: str) -> str:
"""Return base type name (e.g. VARCHAR from VARCHAR(13)), upper-cased."""
return compiled.split("(")[0].strip().upper()
def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
# in-memory engine that preserves the same connection (StaticPool)
SQLModel.metadata.clear()
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
try:
monkeypatch.setattr(db_migration, "LATEST_VERSION", 1)
final_version = db_migration.run_migrations(engine)
assert final_version == 1
expected_schema = {
"users": {
"id": ("INTEGER", 1, 1),
"username": ("TEXT", 1, 0),
"password_hash": ("TEXT", 1, 0),
"is_active": ("BOOLEAN", 1, 0),
},
"cycles": {
"id": ("INTEGER", 1, 1),
"user_id": ("INTEGER", 1, 0),
"friendly_name": ("TEXT", 0, 0),
"symbol": ("TEXT", 1, 0),
"underlying_currency": ("TEXT", 1, 0),
"status": ("TEXT", 1, 0),
"funding_source": ("TEXT", 0, 0),
"capital_exposure_cents": ("INTEGER", 0, 0),
"loan_amount_cents": ("INTEGER", 0, 0),
"loan_interest_rate_bps": ("INTEGER", 0, 0),
"start_date": ("DATE", 1, 0),
"end_date": ("DATE", 0, 0),
},
"trades": {
"id": ("INTEGER", 1, 1),
"user_id": ("INTEGER", 1, 0),
"friendly_name": ("TEXT", 0, 0),
"symbol": ("TEXT", 1, 0),
"underlying_currency": ("TEXT", 1, 0),
"trade_type": ("TEXT", 1, 0),
"trade_strategy": ("TEXT", 1, 0),
"trade_time_utc": ("DATETIME", 1, 0),
"expiry_date": ("DATE", 0, 0),
"strike_price_cents": ("INTEGER", 0, 0),
"quantity": ("INTEGER", 1, 0),
"price_cents": ("INTEGER", 1, 0),
"gross_cash_flow_cents": ("INTEGER", 1, 0),
"commission_cents": ("INTEGER", 1, 0),
"net_cash_flow_cents": ("INTEGER", 1, 0),
"cycle_id": ("INTEGER", 0, 0),
},
}
expected_fks = {
"trades": [
{"table": "cycles", "from": "cycle_id", "to": "id"},
{"table": "users", "from": "user_id", "to": "id"},
],
"cycles": [
{"table": "users", "from": "user_id", "to": "id"},
],
}
with engine.connect() as conn:
# check tables exist
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
found_tables = {r[0] for r in rows}
assert set(expected_schema.keys()).issubset(found_tables), (
f"missing tables: {set(expected_schema.keys()) - found_tables}"
)
# check user_version
uv = conn.execute(text("PRAGMA user_version")).fetchone()
assert uv is not None
assert int(uv[0]) == 1
# validate each table columns
for tbl_name, cols in expected_schema.items():
info_rows = conn.execute(
text(f"PRAGMA table_info({tbl_name})")
).fetchall()
# map: name -> (type, notnull, pk)
actual = {
r[1]: ((r[2] or "").upper(), int(r[3]), int(r[5]))
for r in info_rows
}
for colname, (exp_type, exp_notnull, exp_pk) in cols.items():
assert colname in actual, f"{tbl_name}: missing column {colname}"
act_type, act_notnull, act_pk = actual[colname]
# compare base type (e.g. VARCHAR(13) -> VARCHAR)
if act_type:
act_base = _base_type_of(act_type)
else:
act_base = ""
assert exp_type in act_base or act_base in exp_type, (
f"type mismatch {tbl_name}.{colname}: expected {exp_type}, got {act_base}"
)
assert act_notnull == exp_notnull, (
f"notnull mismatch {tbl_name}.{colname}: expected {exp_notnull}, got {act_notnull}"
)
assert act_pk == exp_pk, (
f"pk mismatch {tbl_name}.{colname}: expected {exp_pk}, got {act_pk}"
)
for tbl_name, fks in expected_fks.items():
fk_rows = conn.execute(
text(f"PRAGMA foreign_key_list('{tbl_name}')")
).fetchall()
# fk_rows columns: (id, seq, table, from, to, on_update, on_delete, match)
actual_fk_list = [
{"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows
]
for efk in fks:
assert efk in actual_fk_list, f"missing FK on {tbl_name}: {efk}"
finally:
engine.dispose()
SQLModel.metadata.clear()

View File

@@ -0,0 +1,302 @@
from datetime import datetime, timezone
from typing import Mapping
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from trading_journal import models
def _check_enum(enum_cls, value, field_name: str):
if value is None:
raise ValueError(f"{field_name} is required")
# already an enum member
if isinstance(value, enum_cls):
return value
# strict string match: must match exactly enum name or enum value (case-sensitive)
if isinstance(value, str):
for m in enum_cls:
if m.name == value or str(m.value) == value:
return m
allowed = [m.name for m in enum_cls]
raise ValueError(f"Invalid {field_name!s}: {value!r}. Allowed: {allowed}")
# Trades
def create_trade(session: Session, trade_data: Mapping) -> models.Trades:
if hasattr(trade_data, "dict"):
data = trade_data.dict(exclude_unset=True)
else:
data = dict(trade_data)
allowed = {c.name for c in models.Trades.__table__.columns}
payload = {k: v for k, v in data.items() if k in allowed}
if "symbol" not in payload:
raise ValueError("symbol is required")
if "underlying_currency" not in payload:
raise ValueError("underlying_currency is required")
payload["underlying_currency"] = _check_enum(
models.UnderlyingCurrency, payload["underlying_currency"], "underlying_currency"
)
if "trade_type" not in payload:
raise ValueError("trade_type is required")
payload["trade_type"] = _check_enum(
models.TradeType, payload["trade_type"], "trade_type"
)
if "trade_strategy" not in payload:
raise ValueError("trade_strategy is required")
payload["trade_strategy"] = _check_enum(
models.TradeStrategy, payload["trade_strategy"], "trade_strategy"
)
# trade_time_utc is the creation moment: always set to now (caller shouldn't provide)
now = datetime.now(timezone.utc)
payload.pop("trade_time_utc", None)
payload["trade_time_utc"] = now
if "trade_date" not in payload or payload.get("trade_date") is None:
payload["trade_date"] = payload["trade_time_utc"].date()
cycle_id = payload.get("cycle_id")
user_id = payload.get("user_id")
if "quantity" not in payload:
raise ValueError("quantity is required")
if "price_cents" not in payload:
raise ValueError("price_cents is required")
if "commission_cents" not in payload:
payload["commission_cents"] = 0
quantity: int = payload["quantity"]
price_cents: int = payload["price_cents"]
commission_cents: int = payload["commission_cents"]
if "gross_cash_flow_cents" not in payload:
payload["gross_cash_flow_cents"] = -quantity * price_cents
if "net_cash_flow_cents" not in payload:
payload["net_cash_flow_cents"] = (
payload["gross_cash_flow_cents"] - commission_cents
)
# If no cycle_id provided, create Cycle instance but don't call create_cycle()
created_cycle = None
if cycle_id is None:
c_payload = {
"user_id": user_id,
"symbol": payload["symbol"],
"underlying_currency": payload["underlying_currency"],
"friendly_name": "Auto-created Cycle by trade "
+ payload.get("friendly_name", ""),
"status": models.CycleStatus.OPEN,
"start_date": payload["trade_date"],
}
created_cycle = models.Cycles(**c_payload)
session.add(created_cycle)
# do NOT flush here; will flush together with trade below
# If cycle_id provided, validate existence and ownership
if cycle_id is not None:
cycle = session.get(models.Cycles, cycle_id)
if cycle is None:
raise ValueError("cycle_id does not exist")
else:
if cycle.user_id != user_id:
raise ValueError("cycle.user_id does not match trade.user_id")
# Build trade instance; if we created a Cycle instance, link via relationship so a single flush will persist both and populate ids
t_payload = dict(payload)
# remove cycle_id if we're using created_cycle; relationship will set it on flush
if created_cycle is not None:
t_payload.pop("cycle_id", None)
t = models.Trades(**t_payload)
if created_cycle is not None:
t.cycle = created_cycle
session.add(t)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("create_trade integrity error") from e
session.refresh(t)
return t
def get_trade_by_id(session: Session, trade_id: int) -> models.Trades | None:
return session.get(models.Trades, trade_id)
def get_trade_by_user_id_and_friendly_name(
session: Session, user_id: int, friendly_name: str
) -> models.Trades | None:
statement = select(models.Trades).where(
models.Trades.user_id == user_id,
models.Trades.friendly_name == friendly_name,
)
return session.exec(statement).first()
def get_trades_by_user_id(session: Session, user_id: int) -> list[models.Trades]:
statement = select(models.Trades).where(
models.Trades.user_id == user_id,
)
return session.exec(statement).all()
def update_trade_note(session: Session, trade_id: int, note: str) -> models.Trades:
trade: models.Trades | None = session.get(models.Trades, trade_id)
if trade is None:
raise ValueError("trade_id does not exist")
trade.notes = note
session.add(trade)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("update_trade_note integrity error") from e
session.refresh(trade)
return trade
def invalidate_trade(session: Session, trade_id: int) -> models.Trades:
trade: models.Trades | None = session.get(models.Trades, trade_id)
if trade is None:
raise ValueError("trade_id does not exist")
if trade.is_invalidated:
raise ValueError("trade is already invalidated")
trade.is_invalidated = True
trade.invalidated_at = datetime.now(timezone.utc)
session.add(trade)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("invalidate_trade integrity error") from e
session.refresh(trade)
return trade
def replace_trade(
session: Session, old_trade_id: int, new_trade_data: Mapping
) -> models.Trades:
invalidate_trade(session, old_trade_id)
if hasattr(new_trade_data, "dict"):
data = new_trade_data.dict(exclude_unset=True)
else:
data = dict(new_trade_data)
data["replaced_by_trade_id"] = old_trade_id
new_trade = create_trade(session, data)
return new_trade
# Cycles
def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles:
if hasattr(cycle_data, "dict"):
data = cycle_data.dict(exclude_unset=True)
else:
data = dict(cycle_data)
allowed = {c.name for c in models.Cycles.__table__.columns}
payload = {k: v for k, v in data.items() if k in allowed}
if "user_id" not in payload:
raise ValueError("user_id is required")
if "symbol" not in payload:
raise ValueError("symbol is required")
if "underlying_currency" not in payload:
raise ValueError("underlying_currency is required")
payload["underlying_currency"] = _check_enum(
models.UnderlyingCurrency, payload["underlying_currency"], "underlying_currency"
)
if "status" not in payload:
raise ValueError("status is required")
payload["status"] = _check_enum(models.CycleStatus, payload["status"], "status")
if "start_date" not in payload:
raise ValueError("start_date is required")
c = models.Cycles(**payload)
session.add(c)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("create_cycle integrity error") from e
session.refresh(c)
return c
IMMUTABLE_CYCLE_FIELDS = {"id", "user_id", "start_date", "created_at"}
def update_cycle(
session: Session, cycle_id: int, update_data: Mapping
) -> models.Cycles:
cycle: models.Cycles | None = session.get(models.Cycles, cycle_id)
if cycle is None:
raise ValueError("cycle_id does not exist")
if hasattr(update_data, "dict"):
data = update_data.dict(exclude_unset=True)
else:
data = dict(update_data)
allowed = {c.name for c in models.Cycles.__table__.columns}
for k, v in data.items():
if k in IMMUTABLE_CYCLE_FIELDS:
raise ValueError(f"field {k!r} is immutable")
if k not in allowed:
continue
if k == "underlying_currency":
v = _check_enum(models.UnderlyingCurrency, v, "underlying_currency")
if k == "status":
v = _check_enum(models.CycleStatus, v, "status")
setattr(cycle, k, v)
session.add(cycle)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("update_cycle integrity error") from e
session.refresh(cycle)
return cycle
# Users
IMMUTABLE_USER_FIELDS = {"id", "username", "created_at"}
def create_user(session: Session, user_data: Mapping) -> models.Users:
if hasattr(user_data, "dict"):
data = user_data.dict(exclude_unset=True)
else:
data = dict(user_data)
allowed = {c.name for c in models.Users.__table__.columns}
payload = {k: v for k, v in data.items() if k in allowed}
if "username" not in payload:
raise ValueError("username is required")
if "password_hash" not in payload:
raise ValueError("password_hash is required")
u = models.Users(**payload)
session.add(u)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("create_user integrity error") from e
session.refresh(u)
return u
def update_user(session: Session, user_id: int, update_data: Mapping) -> models.Users:
user: models.Users | None = session.get(models.Users, user_id)
if user is None:
raise ValueError("user_id does not exist")
if hasattr(update_data, "dict"):
data = update_data.dict(exclude_unset=True)
else:
data = dict(update_data)
allowed = {c.name for c in models.Users.__table__.columns}
for k, v in data.items():
if k in IMMUTABLE_USER_FIELDS:
raise ValueError(f"field {k!r} is immutable")
if k in allowed:
setattr(user, k, v)
session.add(user)
try:
session.flush()
except IntegrityError as e:
session.rollback()
raise ValueError("update_user integrity error") from e
session.refresh(user)
return user

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from sqlalchemy import event
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, create_engine
from trading_journal import db_migration
if TYPE_CHECKING:
from collections.abc import Generator
from sqlite3 import Connection as DBAPIConnection
class Database:
def __init__(
self,
database_url: str | None = None,
*,
echo: bool = False,
connect_args: dict | None = None,
) -> None:
self._database_url = database_url or "sqlite:///:memory:"
default_connect = (
{"check_same_thread": False, "timeout": 30}
if self._database_url.startswith("sqlite")
else {}
)
merged_connect = {**default_connect, **(connect_args or {})}
if self._database_url == "sqlite:///:memory:":
logger = logging.getLogger(__name__)
logger.warning(
"Using in-memory SQLite database; all data will be lost when the application stops."
)
self._engine = create_engine(
self._database_url,
echo=echo,
connect_args=merged_connect,
poolclass=StaticPool,
)
else:
self._engine = create_engine(
self._database_url, echo=echo, connect_args=merged_connect
)
if self._database_url.startswith("sqlite"):
def _enable_sqlite_pragmas(
dbapi_conn: DBAPIConnection, _connection_record: object
) -> None:
try:
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL;")
cur.execute("PRAGMA synchronous=NORMAL;")
cur.execute("PRAGMA foreign_keys=ON;")
cur.execute("PRAGMA busy_timeout=30000;")
cur.close()
except Exception:
logger = logging.getLogger(__name__)
logger.exception("Failed to set sqlite pragmas on new connection: ")
event.listen(self._engine, "connect", _enable_sqlite_pragmas)
def init_db(self) -> None:
db_migration.run_migrations(self._engine)
def get_session(self) -> Generator[Session, None, None]:
session = Session(self._engine)
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def dispose(self) -> None:
self._engine.dispose()
def create_database(
database_url: str | None = None,
*,
echo: bool = False,
connect_args: dict | None = None,
) -> Database:
return Database(database_url, echo=echo, connect_args=connect_args)

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable
from sqlalchemy import text
from sqlmodel import SQLModel
if TYPE_CHECKING:
from sqlalchemy.engine import Connection, Engine
LATEST_VERSION = 1
def _mig_0_1(engine: Engine) -> None:
"""
Initial schema: create all tables from SQLModel models.
Safe to call on an empty DB; idempotent for missing tables.
"""
# Ensure all models are imported before this is called (import side-effect registers tables)
# e.g. trading_journal.models is imported in the caller / app startup.
from trading_journal import models_v1
SQLModel.metadata.create_all(
bind=engine,
tables=[
models_v1.Trades.__table__,
models_v1.Cycles.__table__,
models_v1.Users.__table__,
],
)
# map current_version -> function that migrates from current_version -> current_version+1
MIGRATIONS: dict[int, Callable[[Engine], None]] = {
0: _mig_0_1,
}
def _get_sqlite_user_version(conn: Connection) -> int:
row = conn.execute(text("PRAGMA user_version")).fetchone()
return int(row[0]) if row and row[0] is not None else 0
def _set_sqlite_user_version(conn: Connection, v: int) -> None:
conn.execute(text(f"PRAGMA user_version = {int(v)}"))
def run_migrations(engine: Engine, target_version: int | None = None) -> int:
"""
Run migrations up to target_version (or LATEST_VERSION).
Returns final applied version.
"""
target = target_version or LATEST_VERSION
with engine.begin() as conn:
driver = conn.engine.name.lower()
if driver == "sqlite":
cur_version = _get_sqlite_user_version(conn)
while cur_version < target:
fn = MIGRATIONS.get(cur_version)
if fn is None:
raise RuntimeError(
f"No migration from {cur_version} -> {cur_version + 1}"
)
# call migration with Engine (fn should use transactions)
fn(engine)
_set_sqlite_user_version(conn, cur_version + 1)
cur_version += 1
return cur_version
return -1 # unknown / unsupported driver; no-op

View File

@@ -0,0 +1,144 @@
from datetime import date, datetime # noqa: TC003
from enum import Enum
from sqlmodel import (
Column,
Date,
DateTime,
Field,
Integer,
Relationship,
SQLModel,
Text,
UniqueConstraint,
)
class TradeType(str, Enum):
SELL_PUT = "SELL_PUT"
ASSIGNMENT = "ASSIGNMENT"
SELL_CALL = "SELL_CALL"
EXERCISE_CALL = "EXERCISE_CALL"
LONG_SPOT = "LONG_SPOT"
CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT"
SHORT_SPOT = "SHORT_SPOT"
CLOSE_SHORT_SPOT = "CLOSE_SHORT_SPOT"
LONG_CFD = "LONG_CFD"
CLOSE_LONG_CFD = "CLOSE_LONG_CFD"
SHORT_CFD = "SHORT_CFD"
CLOSE_SHORT_CFD = "CLOSE_SHORT_CFD"
LONG_OTHER = "LONG_OTHER"
CLOSE_LONG_OTHER = "CLOSE_LONG_OTHER"
SHORT_OTHER = "SHORT_OTHER"
CLOSE_SHORT_OTHER = "CLOSE_SHORT_OTHER"
class TradeStrategy(str, Enum):
WHEEL = "WHEEL"
FX = "FX"
SPOT = "SPOT"
OTHER = "OTHER"
class CycleStatus(str, Enum):
OPEN = "OPEN"
CLOSED = "CLOSED"
class UnderlyingCurrency(str, Enum):
EUR = "EUR"
USD = "USD"
GBP = "GBP"
JPY = "JPY"
AUD = "AUD"
CAD = "CAD"
CHF = "CHF"
NZD = "NZD"
CNY = "CNY"
class FundingSource(str, Enum):
CASH = "CASH"
MARGIN = "MARGIN"
MIXED = "MIXED"
class Trades(SQLModel, table=True):
__tablename__ = "trades"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_trades_user_friendly_name"
),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
# allow null while user may omit friendly_name; uniqueness enforced per-user by constraint
friendly_name: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
trade_type: TradeType = Field(sa_column=Column(Text, nullable=False))
trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False))
trade_date: date = Field(sa_column=Column(Date, nullable=False))
trade_time_utc: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False)
)
expiry_date: date | None = Field(default=None, nullable=True)
strike_price_cents: int | None = Field(default=None, nullable=True)
quantity: int = Field(sa_column=Column(Integer, nullable=False))
price_cents: int = Field(sa_column=Column(Integer, nullable=False))
gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False))
commission_cents: int = Field(sa_column=Column(Integer, nullable=False))
net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False))
is_invalidated: bool = Field(default=False, nullable=False)
invalidated_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
replaced_by_trade_id: int | None = Field(
default=None, foreign_key="trades.id", nullable=True
)
notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
cycle_id: int | None = Field(
default=None, foreign_key="cycles.id", nullable=True, index=True
)
cycle: "Cycles" = Relationship(back_populates="trades")
class Cycles(SQLModel, table=True):
__tablename__ = "cycles"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_cycles_user_friendly_name"
),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
friendly_name: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
status: CycleStatus = Field(sa_column=Column(Text, nullable=False))
funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True))
capital_exposure_cents: int | None = Field(default=None, nullable=True)
loan_amount_cents: int | None = Field(default=None, nullable=True)
loan_interest_rate_bps: int | None = Field(default=None, nullable=True)
start_date: date = Field(sa_column=Column(Date, nullable=False))
end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True))
trades: list["Trades"] = Relationship(back_populates="cycle")
class Users(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
# unique=True already creates an index; no need to also set index=True
username: str = Field(sa_column=Column(Text, nullable=False, unique=True))
password_hash: str = Field(sa_column=Column(Text, nullable=False))
is_active: bool = Field(default=True, nullable=False)

View File

@@ -0,0 +1,144 @@
from datetime import date, datetime # noqa: TC003
from enum import Enum
from sqlmodel import (
Column,
Date,
DateTime,
Field,
Integer,
Relationship,
SQLModel,
Text,
UniqueConstraint,
)
class TradeType(str, Enum):
SELL_PUT = "SELL_PUT"
ASSIGNMENT = "ASSIGNMENT"
SELL_CALL = "SELL_CALL"
EXERCISE_CALL = "EXERCISE_CALL"
LONG_SPOT = "LONG_SPOT"
CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT"
SHORT_SPOT = "SHORT_SPOT"
CLOSE_SHORT_SPOT = "CLOSE_SHORT_SPOT"
LONG_CFD = "LONG_CFD"
CLOSE_LONG_CFD = "CLOSE_LONG_CFD"
SHORT_CFD = "SHORT_CFD"
CLOSE_SHORT_CFD = "CLOSE_SHORT_CFD"
LONG_OTHER = "LONG_OTHER"
CLOSE_LONG_OTHER = "CLOSE_LONG_OTHER"
SHORT_OTHER = "SHORT_OTHER"
CLOSE_SHORT_OTHER = "CLOSE_SHORT_OTHER"
class TradeStrategy(str, Enum):
WHEEL = "WHEEL"
FX = "FX"
SPOT = "SPOT"
OTHER = "OTHER"
class CycleStatus(str, Enum):
OPEN = "OPEN"
CLOSED = "CLOSED"
class UnderlyingCurrency(str, Enum):
EUR = "EUR"
USD = "USD"
GBP = "GBP"
JPY = "JPY"
AUD = "AUD"
CAD = "CAD"
CHF = "CHF"
NZD = "NZD"
CNY = "CNY"
class FundingSource(str, Enum):
CASH = "CASH"
MARGIN = "MARGIN"
MIXED = "MIXED"
class Trades(SQLModel, table=True):
__tablename__ = "trades"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_trades_user_friendly_name"
),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
# allow null while user may omit friendly_name; uniqueness enforced per-user by constraint
friendly_name: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
trade_type: TradeType = Field(sa_column=Column(Text, nullable=False))
trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False))
trade_date: date = Field(sa_column=Column(Date, nullable=False))
trade_time_utc: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False)
)
expiry_date: date | None = Field(default=None, nullable=True)
strike_price_cents: int | None = Field(default=None, nullable=True)
quantity: int = Field(sa_column=Column(Integer, nullable=False))
price_cents: int = Field(sa_column=Column(Integer, nullable=False))
gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False))
commission_cents: int = Field(sa_column=Column(Integer, nullable=False))
net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False))
is_invalidated: bool = Field(default=False, nullable=False)
invalidated_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
replaced_by_trade_id: int | None = Field(
default=None, foreign_key="trades.id", nullable=True
)
notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
cycle_id: int | None = Field(
default=None, foreign_key="cycles.id", nullable=True, index=True
)
cycle: "Cycles" = Relationship(back_populates="trades")
class Cycles(SQLModel, table=True):
__tablename__ = "cycles"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_cycles_user_friendly_name"
),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
friendly_name: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
status: CycleStatus = Field(sa_column=Column(Text, nullable=False))
funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True))
capital_exposure_cents: int | None = Field(default=None, nullable=True)
loan_amount_cents: int | None = Field(default=None, nullable=True)
loan_interest_rate_bps: int | None = Field(default=None, nullable=True)
start_date: date = Field(sa_column=Column(Date, nullable=False))
end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True))
trades: list["Trades"] = Relationship(back_populates="cycle")
class Users(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
# unique=True already creates an index; no need to also set index=True
username: str = Field(sa_column=Column(Text, nullable=False, unique=True))
password_hash: str = Field(sa_column=Column(Text, nullable=False))
is_active: bool = Field(default=True, nullable=False)