diff --git a/Makefile b/Makefile index 8c03010..17d17b7 100644 --- a/Makefile +++ b/Makefile @@ -23,3 +23,6 @@ migrate: stop-test-db: docker compose down + +lint-backend: + cd backend && poetry run pre-commit run -c ../.pre-commit-config.yaml diff --git a/backend/poetry.lock b/backend/poetry.lock index 3d0a2ef..a29afaf 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -276,6 +276,90 @@ files = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] +[[package]] +name = "charset-normalizer" +version = "3.2.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, +] + [[package]] name = "click" version = "8.1.5" @@ -1245,6 +1329,27 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "setuptools" version = "68.0.0" @@ -1378,6 +1483,23 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "urllib3" +version = "2.0.4" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, + {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "uvicorn" version = "0.21.1" @@ -1419,4 +1541,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7260371ae99d69f8278f45376de3620c0ca0557d90b2134660bcf112dd80fb11" +content-hash = "cac54ac7026c864df262cec8ec02b09054f55596c412ae5daebf3bbc4615e3a3" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9406438..e787123 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,7 @@ black = "^23.1.0" pre-commit = "^3.1.1" pytest = "^7.4.0" pytest-asyncio = "^0.21.1" +requests = "^2.31.0" [tool.pytest.ini_options] asyncio_mode="auto" diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 1c98f15..4292444 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -1,7 +1,7 @@ from typing import Annotated from api.auth.users import current_active_user -from api.routers import auth +from api.routers import auth, projects from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from models.user import User @@ -18,6 +18,7 @@ ) app.include_router(router=auth.router) +app.include_router(router=projects.router, prefix="/projects", tags=["projects"]) @app.get("/authenticated-route") diff --git a/backend/src/api/routers/projects.py b/backend/src/api/routers/projects.py new file mode 100644 index 0000000..f9598d8 --- /dev/null +++ b/backend/src/api/routers/projects.py @@ -0,0 +1,145 @@ +import uuid +from typing import Annotated + +from api.auth.users import current_active_user +from database.session import transactional_session +from fastapi import APIRouter, Depends, HTTPException, status +from models.project import Project +from pydantic import BaseModel, ConfigDict, TypeAdapter +from sqlalchemy import select +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() + + +class ReadProject(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + title: str + summary: str | None = None + status: str + + +class CreateProject(BaseModel): + title: str + summary: str | None = None + status: str + + +class UpdateProject(BaseModel): + title: str | None = None + summary: str | None = None + status: str | None = None + + +class ProjectStore: + def __init__( + self, session: Annotated[AsyncSession, Depends(transactional_session)] + ): + self.session = session + + async def _get_orm_project(self, project_id: uuid.UUID) -> Project: + try: + project_entry = ( + await self.session.execute(select(Project).filter_by(id=project_id)) + ).scalar_one() + except NoResultFound: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return project_entry + + async def get(self, project_id: uuid.UUID) -> ReadProject: + project_entry = await self._get_orm_project(project_id) + return TypeAdapter(ReadProject).validate_python(project_entry) + + async def get_all(self) -> list[ReadProject]: + stmt = select(Project) + projects = (await self.session.scalars(stmt)).all() + return TypeAdapter(list[ReadProject]).validate_python(projects) + + async def create(self, project: CreateProject) -> ReadProject: + new_project = Project(id=uuid.uuid4(), **project.model_dump()) + self.session.add(new_project) + return TypeAdapter(ReadProject).validate_python(new_project) + + async def delete(self, project_id: uuid.UUID) -> None: + project_entry = await self._get_orm_project(project_id) + await self.session.delete(project_entry) + return None + + async def update( + self, project_id: uuid.UUID, project: UpdateProject + ) -> ReadProject: + project_entry = await self._get_orm_project(project_id) + for key, value in project.model_dump(exclude_defaults=True).items(): + if getattr(project_entry, key) != value: + setattr(project_entry, key, value) + return TypeAdapter(ReadProject).validate_python(project_entry) + + +@router.get( + "/", + response_description="List all projects", + dependencies=[Depends(current_active_user)], +) +async def list_projects( + project_store: Annotated[ProjectStore, Depends()], +) -> list[ReadProject]: + projects = await project_store.get_all() + return projects + + +@router.post( + "/", + response_description="Create a new project", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(current_active_user)], +) +async def create_project( + project: CreateProject, + project_store: Annotated[ProjectStore, Depends()], +) -> ReadProject: + new_project = await project_store.create(project) + return new_project + + +@router.get( + "/{project_id}", + response_description="Get a single project", + dependencies=[Depends(current_active_user)], +) +async def read_project( + project_id: uuid.UUID, + project_store: Annotated[ProjectStore, Depends()], +) -> ReadProject: + project_entry = await project_store.get(project_id) + return project_entry + + +@router.delete( + "/{project_id}", + response_description="Delete a single project", + dependencies=[Depends(current_active_user)], + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_project( + project_id: uuid.UUID, + project_store: Annotated[ProjectStore, Depends()], +) -> None: + await project_store.delete(project_id) + return None + + +@router.patch( + "/{project_id}", + response_description="Update a single project", + dependencies=[Depends(current_active_user)], +) +async def update_project( + project_id: uuid.UUID, + project: UpdateProject, + project_store: Annotated[ProjectStore, Depends()], +) -> ReadProject: + project_entry = await project_store.update(project_id, project) + return project_entry diff --git a/backend/src/database/migrations/versions/0002_add_project_table.py b/backend/src/database/migrations/versions/0002_add_project_table.py new file mode 100644 index 0000000..9feee00 --- /dev/null +++ b/backend/src/database/migrations/versions/0002_add_project_table.py @@ -0,0 +1,29 @@ +"""Add project table + +Revision ID: 0002 +Revises: 0001 +Create Date: 2023-07-24 13:00:25.140928 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("summary", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("project") diff --git a/backend/src/database/session.py b/backend/src/database/session.py index 040b61e..2c250ef 100644 --- a/backend/src/database/session.py +++ b/backend/src/database/session.py @@ -1,4 +1,4 @@ -from typing import AsyncGenerator +from typing import AsyncGenerator, AsyncIterator from fastapi import Depends from fastapi_users.db import SQLAlchemyUserDatabase @@ -8,6 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine engine = create_async_engine(url=settings.database.dsn) +# TODO(KW): Consider renaming this in accordance with the SQLAlchemy +# conventions. In their docs they use Pascal Case for session maker instances and don't +# include "maker" in the name to reflect that these instances are used similarly +# to SQLAlchemy's Session classes. async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) @@ -22,3 +26,8 @@ async def get_user_service(session: AsyncSession = Depends(get_async_session)): async def get_access_token_service(session: AsyncSession = Depends(get_async_session)): yield SQLAlchemyAccessTokenDatabase(session, AccessToken) + + +async def transactional_session() -> AsyncIterator[AsyncSession]: + async with async_session_maker.begin() as session: + yield session diff --git a/backend/src/models/project.py b/backend/src/models/project.py new file mode 100644 index 0000000..628a91b --- /dev/null +++ b/backend/src/models/project.py @@ -0,0 +1,14 @@ +from uuid import UUID + +from models.base import Base +from sqlalchemy import String, Text, Uuid +from sqlalchemy.orm import Mapped, mapped_column + + +class Project(Base): + __tablename__ = "project" + + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) + title: Mapped[str] = mapped_column(String(length=255), nullable=False) + summary: Mapped[str] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(length=20), nullable=False) diff --git a/backend/src/settings.py b/backend/src/settings.py index d97fc3d..b9ff52f 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -29,6 +29,7 @@ def dsn(self) -> str: class TestConfiguration(BaseModel): test_database: bool = False + use_latest_migration: bool = True def is_src_root_dir(directory: Path): diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py index 88697a3..7f141c2 100644 --- a/backend/tests/fixtures/__init__.py +++ b/backend/tests/fixtures/__init__.py @@ -1,2 +1,3 @@ from .client import * # noqa: F403, F401 from .database import * # noqa: F403, F401 +from .overrides import * # noqa: F403, F401 diff --git a/backend/tests/fixtures/database.py b/backend/tests/fixtures/database.py index 47e5dba..2787212 100644 --- a/backend/tests/fixtures/database.py +++ b/backend/tests/fixtures/database.py @@ -1,10 +1,16 @@ import contextlib +import uuid +from pathlib import Path import pytest +from alembic import command +from alembic.config import Config from api.auth.users import get_user_manager from api.routers.auth import UserCreate from database.session import get_async_session, get_user_service +from models.project import Project from models.user import User +from settings import settings from sqlalchemy import delete get_async_session_context = contextlib.asynccontextmanager(get_async_session) @@ -12,6 +18,18 @@ get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) +@pytest.fixture(scope="session") +def alembic_upgrade(): + if settings.tests.test_database and settings.tests.use_latest_migration: + backend_root = Path(__file__).parent.parent.parent + alembic_cfg = Config(backend_root / "src" / "alembic.ini") + migration_directory = alembic_cfg.get_main_option("script_location") + alembic_cfg.set_main_option( + "script_location", str(backend_root / "src" / migration_directory) + ) + command.upgrade(alembic_cfg, "head") + + @pytest.fixture(scope="session") def admin_details(): return { @@ -23,7 +41,7 @@ def admin_details(): @pytest.fixture(scope="session", autouse=True) -async def admin_user(admin_details): +async def admin_user(admin_details, alembic_upgrade): """Creates a user with admin privileges for testing purposes. The test does "tear down" up front because asyncio errors in tests @@ -32,12 +50,43 @@ async def admin_user(admin_details): in the database are idempotent and also succeed if the user does not exist. """ - async with get_async_session_context() as session: - delete_stmt = delete(User).where(User.email == admin_details.get("email")) - await session.execute(delete_stmt) - await session.commit() - - async with get_user_service_context(session) as user_db: - async with get_user_manager_context(user_db) as user_manager: - await user_manager.create(UserCreate(**admin_details)) + if settings.tests.test_database: + async with get_async_session_context() as session: + delete_stmt = delete(User).where(User.email == admin_details.get("email")) + await session.execute(delete_stmt) + await session.commit() + + async with get_user_service_context(session) as user_db: + async with get_user_manager_context(user_db) as user_manager: + await user_manager.create(UserCreate(**admin_details)) + yield + else: + yield + + +@pytest.fixture(scope="session") +async def project_details(): + return { + "title": "Test Project", + "summary": "Test Summary", + "status": "Test Status", + } + + +@pytest.fixture(scope="session", autouse=True) +async def example_project(project_details, alembic_upgrade): + if settings.tests.test_database: + async with get_async_session_context() as session: + delete_stmt = delete(Project).where( + Project.title == project_details.get("title") + ) + await session.execute(delete_stmt) + await session.commit() + + project = Project(**project_details, id=uuid.uuid4()) + session.add(project) + await session.commit() + + yield + else: yield diff --git a/backend/tests/fixtures/overrides.py b/backend/tests/fixtures/overrides.py new file mode 100644 index 0000000..12e0545 --- /dev/null +++ b/backend/tests/fixtures/overrides.py @@ -0,0 +1,52 @@ +import uuid + +import pytest +from api.app import app +from api.auth.users import current_active_user +from api.routers.projects import ProjectStore, ReadProject +from fastapi import HTTPException, status + + +class ProjectDictStore: + projects: dict[uuid.UUID, ReadProject] = {} + + async def get(self, project_id): + try: + return self.projects[project_id] + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + async def get_all(self): + return self.projects.values() + + async def create(self, project): + project_id = uuid.uuid4() + project_entry = ReadProject(**project.model_dump(), id=project_id) + self.projects[project_id] = project_entry + return project_entry + + async def update(self, project_id, project): + project_entry = await self.get(project_id) + project_entry.title = project.title + project_entry.summary = project.summary + project_entry.status = project.status + return project_entry + + async def delete(self, project_id): + await self.get(project_id) + del self.projects[project_id] + return None + + +@pytest.fixture +def override_project_store(): + app.dependency_overrides[ProjectStore] = ProjectDictStore + yield + del app.dependency_overrides[ProjectStore] + + +@pytest.fixture +def override_active_user(): + app.dependency_overrides[current_active_user] = lambda: {} + yield + del app.dependency_overrides[current_active_user] diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py new file mode 100644 index 0000000..92696af --- /dev/null +++ b/backend/tests/test_projects.py @@ -0,0 +1,101 @@ +from .base import database_test + + +async def get_auth_cookie_header(connected_async_client, admin_details): + login_response = await connected_async_client.post( + "/auth/login", + data={ + "username": admin_details["email"], + "password": admin_details["password"], + }, + ) + assert login_response.status_code == 204 + return {"Cookie": login_response.headers["Set-Cookie"]} + + +@database_test +async def test_get_projects(async_client, admin_details): + async with async_client as ac: + auth_cookie_header = await get_auth_cookie_header(ac, admin_details) + response = await ac.get("/projects/", headers=auth_cookie_header) + assert response.status_code == 200 + assert len(response.json()) >= 1 + + +@database_test +async def test_project_crud(async_client, admin_details): + project = { + "title": "Inserted Project", + "status": "running", + } + + async with async_client as ac: + auth_cookie_header = await get_auth_cookie_header(ac, admin_details) + + response = await ac.post("/projects/", headers=auth_cookie_header, json=project) + assert response.status_code == 201 + + project_id = response.json()["id"] + + response = await ac.get(f"/projects/{project_id}", headers=auth_cookie_header) + assert response.status_code == 200 + assert response.json()["title"] == project["title"] + assert response.json()["status"] == project["status"] + assert response.json()["summary"] is None + + project["title"] = "Updated Project" + project["summary"] = "Updated Summary" + + response = await ac.patch( + f"/projects/{project_id}", headers=auth_cookie_header, json=project + ) + response = await ac.get(f"/projects/{project_id}", headers=auth_cookie_header) + assert response.status_code == 200 + assert response.json()["title"] == project["title"] + assert response.json()["status"] == project["status"] + assert response.json()["summary"] == project["summary"] + + response = await ac.delete( + f"/projects/{project_id}", headers=auth_cookie_header + ) + assert response.status_code == 204 + + response = await ac.get(f"/projects/{project_id}", headers=auth_cookie_header) + assert response.status_code == 404 + + +async def test_project_crud_no_db( + async_client, admin_details, override_project_store, override_active_user +): + project = { + "title": "Inserted Project", + "status": "running", + } + + async with async_client as ac: + response = await ac.post("/projects/", json=project) + assert response.status_code == 201 + + project_id = response.json()["id"] + + response = await ac.get(f"/projects/{project_id}") + assert response.status_code == 200 + assert response.json()["title"] == project["title"] + assert response.json()["status"] == project["status"] + assert response.json()["summary"] is None + + project["title"] = "Updated Project" + project["summary"] = "Updated Summary" + + response = await ac.patch(f"/projects/{project_id}", json=project) + response = await ac.get(f"/projects/{project_id}") + assert response.status_code == 200 + assert response.json()["title"] == project["title"] + assert response.json()["status"] == project["status"] + assert response.json()["summary"] == project["summary"] + + response = await ac.delete(f"/projects/{project_id}") + assert response.status_code == 204 + + response = await ac.get(f"/projects/{project_id}") + assert response.status_code == 404