feature/db #2
29
.github/workflows/backend-ci.yml
vendored
Normal file
29
.github/workflows/backend-ci.yml
vendored
Normal 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
4
backend/.gitignore
vendored
@@ -11,3 +11,7 @@ venv.bak/
|
||||
__pycache__/
|
||||
|
||||
.pytest_cache/
|
||||
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -1,2 +1,2 @@
|
||||
-r requirements.txt
|
||||
-r requirements.in
|
||||
pytest
|
||||
@@ -7,55 +7,101 @@
|
||||
annotated-types==0.7.0 \
|
||||
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
|
||||
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pydantic
|
||||
# via pydantic
|
||||
anyio==4.10.0 \
|
||||
--hash=sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6 \
|
||||
--hash=sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# httpx
|
||||
# starlette
|
||||
certifi==2025.8.3 \
|
||||
--hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \
|
||||
--hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# httpcore
|
||||
# httpx
|
||||
click==8.2.1 \
|
||||
--hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \
|
||||
--hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# uvicorn
|
||||
# via uvicorn
|
||||
fastapi==0.116.1 \
|
||||
--hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \
|
||||
--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 \
|
||||
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# httpcore
|
||||
# uvicorn
|
||||
httpcore==1.0.9 \
|
||||
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
|
||||
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# httpx
|
||||
# via httpx
|
||||
httpx==0.28.1 \
|
||||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via -r requirements.txt
|
||||
# via -r requirements.in
|
||||
idna==3.10 \
|
||||
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
|
||||
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# anyio
|
||||
# httpx
|
||||
iniconfig==2.1.0 \
|
||||
@@ -74,9 +120,9 @@ pydantic==2.11.7 \
|
||||
--hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \
|
||||
--hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# fastapi
|
||||
# pydantic-settings
|
||||
# sqlmodel
|
||||
pydantic-core==2.33.2 \
|
||||
--hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \
|
||||
--hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \
|
||||
@@ -177,13 +223,11 @@ pydantic-core==2.33.2 \
|
||||
--hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \
|
||||
--hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \
|
||||
--hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pydantic
|
||||
# via pydantic
|
||||
pydantic-settings==2.10.1 \
|
||||
--hash=sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee \
|
||||
--hash=sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796
|
||||
# via -r requirements.txt
|
||||
# via -r requirements.in
|
||||
pygments==2.19.2 \
|
||||
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
|
||||
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
|
||||
@@ -195,9 +239,7 @@ pytest==8.4.2 \
|
||||
python-dotenv==1.1.1 \
|
||||
--hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \
|
||||
--hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pydantic-settings
|
||||
# via pydantic-settings
|
||||
pyyaml==6.0.2 \
|
||||
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
|
||||
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
|
||||
@@ -252,38 +294,96 @@ pyyaml==6.0.2 \
|
||||
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
|
||||
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
|
||||
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
|
||||
# via -r requirements.txt
|
||||
# via -r requirements.in
|
||||
sniffio==1.3.1 \
|
||||
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
|
||||
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# 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 \
|
||||
--hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \
|
||||
--hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# fastapi
|
||||
# via fastapi
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# anyio
|
||||
# fastapi
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.1 \
|
||||
--hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \
|
||||
--hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
uvicorn==0.35.0 \
|
||||
--hash=sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a \
|
||||
--hash=sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01
|
||||
# via -r requirements.txt
|
||||
# via -r requirements.in
|
||||
|
||||
@@ -3,3 +3,4 @@ uvicorn
|
||||
httpx
|
||||
pyyaml
|
||||
pydantic-settings
|
||||
sqlmodel
|
||||
@@ -28,6 +28,62 @@ fastapi==0.116.1 \
|
||||
--hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \
|
||||
--hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143
|
||||
# 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 \
|
||||
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
@@ -54,6 +110,7 @@ pydantic==2.11.7 \
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic-settings
|
||||
# sqlmodel
|
||||
pydantic-core==2.33.2 \
|
||||
--hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \
|
||||
--hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \
|
||||
@@ -222,6 +279,69 @@ sniffio==1.3.1 \
|
||||
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
|
||||
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
|
||||
# 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 \
|
||||
--hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \
|
||||
--hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51
|
||||
@@ -234,6 +354,7 @@ typing-extensions==4.15.0 \
|
||||
# fastapi
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.1 \
|
||||
|
||||
@@ -4,7 +4,17 @@ line-length = 144
|
||||
[lint]
|
||||
select = ["ALL"]
|
||||
fixable = ["UP034", "I001"]
|
||||
ignore = ["T201", "D", "ANN101", "TD002", "TD003"]
|
||||
ignore = [
|
||||
"T201",
|
||||
"D",
|
||||
"ANN101",
|
||||
"TD002",
|
||||
"TD003",
|
||||
"TRY003",
|
||||
"EM101",
|
||||
"EM102",
|
||||
"PLC0405",
|
||||
]
|
||||
|
||||
[lint.extend-per-file-ignores]
|
||||
"test*.py" = ["S101"]
|
||||
542
backend/tests/test_crud.py
Normal file
542
backend/tests/test_crud.py
Normal 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
101
backend/tests/test_db.py
Normal 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 == []
|
||||
132
backend/tests/test_db_migration.py
Normal file
132
backend/tests/test_db_migration.py
Normal 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()
|
||||
302
backend/trading_journal/crud.py
Normal file
302
backend/trading_journal/crud.py
Normal 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
|
||||
92
backend/trading_journal/db.py
Normal file
92
backend/trading_journal/db.py
Normal 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)
|
||||
69
backend/trading_journal/db_migration.py
Normal file
69
backend/trading_journal/db_migration.py
Normal 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
|
||||
144
backend/trading_journal/models.py
Normal file
144
backend/trading_journal/models.py
Normal 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)
|
||||
144
backend/trading_journal/models_v1.py
Normal file
144
backend/trading_journal/models_v1.py
Normal 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)
|
||||
Reference in New Issue
Block a user