diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..c00d4cd --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -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 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 51f9037..6321b92 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -11,3 +11,7 @@ venv.bak/ __pycache__/ .pytest_cache/ + +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/backend/dev-requirements.in b/backend/dev-requirements.in index f2578c6..d9cb060 100644 --- a/backend/dev-requirements.in +++ b/backend/dev-requirements.in @@ -1,2 +1,2 @@ --r requirements.txt +-r requirements.in pytest \ No newline at end of file diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index 14da341..0cab2f5 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -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 diff --git a/backend/requirements.in b/backend/requirements.in index 8f1de34..76cec58 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -2,4 +2,5 @@ fastapi uvicorn httpx pyyaml -pydantic-settings \ No newline at end of file +pydantic-settings +sqlmodel \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index d9bde87..18a2164 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 \ diff --git a/backend/ruff.toml b/backend/ruff.toml index a4b5c62..c5a0394 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -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"] \ No newline at end of file +"test*.py" = ["S101"] diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py new file mode 100644 index 0000000..3ee2fce --- /dev/null +++ b/backend/tests/test_crud.py @@ -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) + ) diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..4275b1c --- /dev/null +++ b/backend/tests/test_db.py @@ -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 == [] diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py new file mode 100644 index 0000000..e1c8850 --- /dev/null +++ b/backend/tests/test_db_migration.py @@ -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() diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py new file mode 100644 index 0000000..386c1f4 --- /dev/null +++ b/backend/trading_journal/crud.py @@ -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 diff --git a/backend/trading_journal/db.py b/backend/trading_journal/db.py new file mode 100644 index 0000000..d09a53d --- /dev/null +++ b/backend/trading_journal/db.py @@ -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) diff --git a/backend/trading_journal/db_migration.py b/backend/trading_journal/db_migration.py new file mode 100644 index 0000000..c59e3b0 --- /dev/null +++ b/backend/trading_journal/db_migration.py @@ -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 diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py new file mode 100644 index 0000000..0e5857f --- /dev/null +++ b/backend/trading_journal/models.py @@ -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) diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py new file mode 100644 index 0000000..0e5857f --- /dev/null +++ b/backend/trading_journal/models_v1.py @@ -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)