From 550e043e1e8f7f271c9b96c2cea79c51b21177c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 19:39:36 +0000 Subject: [PATCH 01/33] Bump actions/cache from 3 to 4 (#1612) --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 43c9494c3..916518335 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: POETRY_VIRTUALENVS_CREATE: false - name: Restore pre-commit cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }} From 9b1a336eec7a35f7adb732cbc95b62aca16be250 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 17 Jan 2024 15:41:16 -0400 Subject: [PATCH 02/33] Update version for webpub manifest parser. (#1614) --- poetry.lock | 75 +++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3e56fb672..7706596d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "alembic" @@ -2552,19 +2552,19 @@ files = [ [[package]] name = "palace-webpub-manifest-parser" -version = "3.1.0" +version = "3.1.1" description = "A parser for the Readium Web Publication Manifest, OPDS 2.0 and ODL formats." optional = false python-versions = ">=3.8,<4" files = [ - {file = "palace_webpub_manifest_parser-3.1.0-py3-none-any.whl", hash = "sha256:2d65fbfddafd70d0e571d8e0cee4ad726baf187180742bbab026ef9066068b5c"}, - {file = "palace_webpub_manifest_parser-3.1.0.tar.gz", hash = "sha256:9ca52be816ade5812e4f2cc1a3bd0892ba10c16f8497896aed43038ad831ee02"}, + {file = "palace_webpub_manifest_parser-3.1.1-py3-none-any.whl", hash = "sha256:ac43d7f16414810cf7aeea26b9825ae8678404887ecf7a0345aa47ad992510d8"}, + {file = "palace_webpub_manifest_parser-3.1.1.tar.gz", hash = "sha256:7025164e2ae997371ed355355d8321685c6eb1228b86d10430e682d7316351b3"}, ] [package.dependencies] jsonschema = ">=4.19,<5.0" multipledispatch = ">=1.0,<2.0" -pyrsistent = ">=0.19,<0.20" +pyrsistent = ">=0.20,<0.21" python-dateutil = ">=2.8,<3.0" pytz = ">=2023.3,<2024.0" requests = ">=2.27,<3.0" @@ -3183,38 +3183,43 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pyrsistent" -version = "0.19.3" +version = "0.20.0" description = "Persistent/Functional/Immutable data structures" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"}, - {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64"}, - {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf"}, - {file = "pyrsistent-0.19.3-cp310-cp310-win32.whl", hash = "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a"}, - {file = "pyrsistent-0.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da"}, - {file = "pyrsistent-0.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9"}, - {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393"}, - {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19"}, - {file = "pyrsistent-0.19.3-cp311-cp311-win32.whl", hash = "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3"}, - {file = "pyrsistent-0.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-win32.whl", hash = "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b"}, - {file = "pyrsistent-0.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8"}, - {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a"}, - {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c"}, - {file = "pyrsistent-0.19.3-cp38-cp38-win32.whl", hash = "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c"}, - {file = "pyrsistent-0.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7"}, - {file = "pyrsistent-0.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc"}, - {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2"}, - {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3"}, - {file = "pyrsistent-0.19.3-cp39-cp39-win32.whl", hash = "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2"}, - {file = "pyrsistent-0.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98"}, - {file = "pyrsistent-0.19.3-py3-none-any.whl", hash = "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64"}, - {file = "pyrsistent-0.19.3.tar.gz", hash = "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440"}, + {file = "pyrsistent-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win32.whl", hash = "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7"}, + {file = "pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee"}, + {file = "pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d"}, + {file = "pyrsistent-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win32.whl", hash = "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d"}, + {file = "pyrsistent-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win32.whl", hash = "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf"}, + {file = "pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b"}, + {file = "pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4"}, ] [[package]] @@ -4494,4 +4499,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "e1b57fcc2f4b7d942892cd619212101d0ba831e39a8ad220db827cb306f336f7" +content-hash = "0fbd61c3f50c72f1b95ca308be52d3aff554b6fc69c0b76b9d93b8e81e9ebee1" diff --git a/pyproject.toml b/pyproject.toml index 798f40d45..330cff277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -214,7 +214,7 @@ nameparser = "^1.1" # nameparser is for author name manipulations nltk = "3.8.1" # nltk is a textblob dependency. opensearch-dsl = "~1.0" opensearch-py = "~1.1" -palace-webpub-manifest-parser = "^3.1" +palace-webpub-manifest-parser = "^3.1.1" pillow = "^10.0" pycryptodome = "^3.18" pydantic = {version = "^1.10.9", extras = ["dotenv", "email"]} From 3c581d9e35b65992d626cbe921acdb8e8a0889ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 19:56:39 +0000 Subject: [PATCH 03/33] Bump tox from 4.12.0 to 4.12.1 (#1613) --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7706596d4..028339962 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alembic" @@ -4019,13 +4019,13 @@ files = [ [[package]] name = "tox" -version = "4.12.0" +version = "4.12.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.12.0-py3-none-any.whl", hash = "sha256:c94bf5852ba41f3d9f1e3470ccf3390e0b7bdc938095be3cd96dce25ab5062a0"}, - {file = "tox-4.12.0.tar.gz", hash = "sha256:76adc53a3baff7bde80d6ad7f63235735cfc5bc42e8cb6fccfbf62cb5ffd4d92"}, + {file = "tox-4.12.1-py3-none-any.whl", hash = "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c"}, + {file = "tox-4.12.1.tar.gz", hash = "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e"}, ] [package.dependencies] From ad2082ae1345b98740eb3029d44a496f1da94eaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:47:40 +0000 Subject: [PATCH 04/33] Bump pyfakefs from 5.3.2 to 5.3.4 (#1619) --- poetry.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 028339962..3a79f0ea2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2979,13 +2979,13 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "pyfakefs" -version = "5.3.2" +version = "5.3.4" description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" files = [ - {file = "pyfakefs-5.3.2-py3-none-any.whl", hash = "sha256:5a62194cfa24542a3c9080b66ce65d78b2e977957edfd3cd6fe98e8349bcca32"}, - {file = "pyfakefs-5.3.2.tar.gz", hash = "sha256:a83776a3c1046d4d103f2f530029aa6cdff5f0386dffd59c15ee16926135493c"}, + {file = "pyfakefs-5.3.4-py3-none-any.whl", hash = "sha256:fc375229f5417f197f0892a7d6dc49a411e67e10eb8142b19d80e60a9d52a13d"}, + {file = "pyfakefs-5.3.4.tar.gz", hash = "sha256:dadac1653195a4bfe4c26e9dfa7cc0c0286b1cd8e18706442c2464cae5542a17"}, ] [[package]] @@ -3406,6 +3406,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, From 6a49bef6a4ee22c1bbbf9616b0f6c0b8d684f68b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:47:54 +0000 Subject: [PATCH 05/33] Bump flask from 3.0.0 to 3.0.1 (#1618) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3a79f0ea2..74c46e6ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1153,13 +1153,13 @@ pyjwt = {version = ">=2.5.0", extras = ["crypto"]} [[package]] name = "flask" -version = "3.0.0" +version = "3.0.1" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.8" files = [ - {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, - {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, + {file = "flask-3.0.1-py3-none-any.whl", hash = "sha256:ca631a507f6dfe6c278ae20112cea3ff54ff2216390bf8880f6b035a5354af13"}, + {file = "flask-3.0.1.tar.gz", hash = "sha256:6489f51bb3666def6f314e15f19d50a1869a19ae0e8c9a3641ffe66c77d42403"}, ] [package.dependencies] From 7d34420ff996aa9e23405091e74713781ff05e58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:48:09 +0000 Subject: [PATCH 06/33] Bump types-jsonschema from 4.20.0.20240105 to 4.21.0.20240118 (#1616) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 74c46e6ae..3ef7e711f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4136,13 +4136,13 @@ Flask = ">=2.0.0" [[package]] name = "types-jsonschema" -version = "4.20.0.20240105" +version = "4.21.0.20240118" description = "Typing stubs for jsonschema" optional = false python-versions = ">=3.8" files = [ - {file = "types-jsonschema-4.20.0.20240105.tar.gz", hash = "sha256:4a71af7e904498e7ad055149f6dc1eee04153b59a99ad7dd17aa3769c9bc5982"}, - {file = "types_jsonschema-4.20.0.20240105-py3-none-any.whl", hash = "sha256:26706cd70a273e59e718074c4e756608a25ba61327a7f9a4493ebd11941e5ad4"}, + {file = "types-jsonschema-4.21.0.20240118.tar.gz", hash = "sha256:31aae1b5adc0176c1155c2d4f58348b22d92ae64315e9cc83bd6902168839232"}, + {file = "types_jsonschema-4.21.0.20240118-py3-none-any.whl", hash = "sha256:77a4ac36b0be4f24274d5b9bf0b66208ee771c05f80e34c4641de7d63e8a872d"}, ] [package.dependencies] From 2b086ff7329fee8e5dd5ca8fc7f40492bf19ddb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:48:22 +0000 Subject: [PATCH 07/33] Bump types-psycopg2 from 2.9.21.20240106 to 2.9.21.20240118 (#1615) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3ef7e711f..fc886a5c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4161,13 +4161,13 @@ files = [ [[package]] name = "types-psycopg2" -version = "2.9.21.20240106" +version = "2.9.21.20240118" description = "Typing stubs for psycopg2" optional = false python-versions = ">=3.8" files = [ - {file = "types-psycopg2-2.9.21.20240106.tar.gz", hash = "sha256:0d0a350449714ba28448c4f10a0a3aec36e9e3efd1450730e227e17b704a4bea"}, - {file = "types_psycopg2-2.9.21.20240106-py3-none-any.whl", hash = "sha256:c20cf8236757f8ca4519068548f0c6c159158c9262cc7264c3f2f67f1f511b61"}, + {file = "types-psycopg2-2.9.21.20240118.tar.gz", hash = "sha256:e4a06316e7c9690255175c3ee5dffa5b47c5057f17181f5e34c6dcdb34066f35"}, + {file = "types_psycopg2-2.9.21.20240118-py3-none-any.whl", hash = "sha256:08c024f7da3a78c2c0404305f96c2b6067185d690cc4e9d14fc6ea595879ff8a"}, ] [[package]] From 24f3f2f47974e4314633e795a8f6ae2b61002533 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 22 Jan 2024 10:05:35 -0400 Subject: [PATCH 08/33] Search integration configured as a service (PP-93) (#1554) * Replace opensearch integration with a service configuration. * Use docker compose for our build tests * Add new environment variable info to readme. * Bump up admin-ui version. --- .github/workflows/test-build.yml | 43 +-- README.md | 20 +- api/admin/config.py | 2 +- api/admin/controller/__init__.py | 12 - .../controller/search_service_self_tests.py | 28 -- api/admin/controller/settings.py | 7 +- api/admin/controller/sitewide_services.py | 127 -------- api/admin/controller/work_editor.py | 2 +- api/admin/routes.py | 26 -- api/circulation_manager.py | 55 +--- bin/search_index_refresh | 2 +- core/coverage.py | 5 + core/external_search.py | 296 +---------------- core/feed/acquisition.py | 7 +- core/lane.py | 40 +-- core/metadata_layer.py | 3 +- core/model/collection.py | 8 +- core/model/configuration.py | 6 +- core/model/patron.py | 2 +- core/model/work.py | 19 +- core/monitor.py | 6 +- core/query/customlist.py | 16 +- core/scripts.py | 21 +- core/search/coverage_provider.py | 80 +++++ core/search/service.py | 11 +- core/selftest.py | 19 +- core/service/container.py | 41 ++- core/service/search/configuration.py | 13 + core/service/search/container.py | 35 ++ docker-compose.yml | 68 ++-- docker/ci/check_service_status.sh | 8 +- docker/ci/test_migrations.sh | 8 +- docker/ci/test_scripts.sh | 3 + docker/ci/test_webapp.sh | 6 +- scripts.py | 24 +- .../api/admin/controller/test_custom_lists.py | 5 +- tests/api/admin/controller/test_feed.py | 5 +- .../test_search_service_self_tests.py | 93 ------ .../admin/controller/test_search_services.py | 301 ------------------ .../controller/test_sitewide_services.py | 34 -- .../api/admin/controller/test_work_editor.py | 9 +- tests/api/admin/test_routes.py | 42 +-- tests/api/conftest.py | 2 +- tests/api/controller/test_crawlfeed.py | 10 +- tests/api/controller/test_loan.py | 7 +- tests/api/controller/test_opds_feed.py | 51 +-- tests/api/controller/test_work.py | 48 +-- tests/api/discovery/test_opds_registration.py | 2 +- tests/api/feed/{fixtures.py => conftest.py} | 0 tests/api/feed/test_admin.py | 28 +- tests/api/feed/test_annotators.py | 2 +- tests/api/feed/test_library_annotator.py | 7 +- .../api/feed/test_loan_and_hold_annotator.py | 7 +- tests/api/feed/test_opds_acquisition_feed.py | 6 +- tests/api/mockapi/circulation.py | 27 +- tests/api/test_controller_cm.py | 24 -- tests/api/test_lanes.py | 2 +- tests/api/test_scripts.py | 95 +++--- tests/core/conftest.py | 1 - tests/core/models/test_collection.py | 18 +- tests/core/models/test_work.py | 6 +- tests/core/search/test_migration_states.py | 47 +-- tests/core/search/test_service.py | 22 +- tests/core/test_external_search.py | 116 ++----- tests/core/test_lane.py | 13 +- tests/core/test_local_analytics_provider.py | 7 +- tests/core/test_s3_analytics_provider.py | 29 +- tests/core/test_scripts.py | 77 ++--- tests/fixtures/api_controller.py | 51 ++- tests/fixtures/api_routes.py | 3 +- tests/fixtures/container.py | 9 - tests/fixtures/search.py | 193 ++++++----- tests/fixtures/services.py | 159 +++++++-- tests/migration/conftest.py | 10 +- tests/migration/test_instance_init_script.py | 12 +- tests/mocks/search.py | 13 +- tox.ini | 2 +- 77 files changed, 880 insertions(+), 1784 deletions(-) delete mode 100644 api/admin/controller/search_service_self_tests.py delete mode 100644 api/admin/controller/sitewide_services.py create mode 100644 core/search/coverage_provider.py create mode 100644 core/service/search/configuration.py create mode 100644 core/service/search/container.py delete mode 100644 tests/api/admin/controller/test_search_service_self_tests.py delete mode 100644 tests/api/admin/controller/test_search_services.py delete mode 100644 tests/api/admin/controller/test_sitewide_services.py rename tests/api/feed/{fixtures.py => conftest.py} (100%) delete mode 100644 tests/fixtures/container.py diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index b6974df12..20ce270cf 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -269,18 +269,6 @@ jobs: matrix: platform: ["linux/amd64", "linux/arm64"] image: ["scripts", "webapp"] - env: - POSTGRES_USER: palace_user - POSTGRES_PASSWORD: test - POSTGRES_DB: palace_circulation - - services: - postgres: - image: postgres:12 - env: - POSTGRES_USER: ${{ env.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ env.POSTGRES_DB }} steps: - uses: actions/checkout@v4 @@ -297,36 +285,23 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile - tags: test_image - load: true - target: ${{ matrix.image }} - cache-from: type=gha,scope=buildkit-${{ github.run_id }} - platforms: ${{ matrix.platform }} - build-args: | - BASE_IMAGE=${{ needs.docker-image-build.outputs.baseimage }} - - - name: Start container - run: > - docker run --rm --name test_container -d --platform ${{ matrix.platform }} - --network ${{job.services.postgres.network}} - -e SIMPLIFIED_PRODUCTION_DATABASE="postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@postgres:5432/${{ env.POSTGRES_DB }}" - test_image + - name: Build & Start container + run: docker compose up -d --build ${{ matrix.image }} + env: + BUILD_PLATFORM: ${{ matrix.platform }} + BUILD_CACHE_FROM: type=gha,scope=buildkit-${{ github.run_id }} + BUILD_BASE_IMAGE: ${{ needs.docker-image-build.outputs.baseimage }} - name: Run tests - run: ./docker/ci/test_${{ matrix.image }}.sh test_container + run: ./docker/ci/test_${{ matrix.image }}.sh ${{ matrix.image }} - name: Output logs if: failure() - run: docker logs test_container + run: docker logs circulation-${{ matrix.image }}-1 - name: Stop container if: always() - run: docker stop test_container + run: docker compose down docker-image-push: name: Push circ-${{ matrix.image }} diff --git a/README.md b/README.md index bf3a3dab4..ccce54e54 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Elasticsearch is no longer supported. We recommend that you run Opensearch with docker using the following docker commands: ```sh -docker run --name opensearch -d --rm -p 9006:9200 -e "discovery.type=single-node" -e "plugins.security.disabled=true" "opensearchproject/opensearch:1" +docker run --name opensearch -d --rm -p 9200:9200 -e "discovery.type=single-node" -e "plugins.security.disabled=true" "opensearchproject/opensearch:1" docker exec opensearch opensearch-plugin -s install analysis-icu docker restart opensearch ``` @@ -161,6 +161,22 @@ To let the application know which database to use, set the `SIMPLIFIED_PRODUCTIO export SIMPLIFIED_PRODUCTION_DATABASE="postgresql://palace:test@localhost:5432/circ" ``` +#### Opensearch + +To let the application know which Opensearch instance to use, you can set the following environment variables: + +- `PALACE_SEARCH_URL`: The url of the Opensearch instance (**required**). +- `PALACE_SEARCH_INDEX_PREFIX`: The prefix to use for the Opensearch indices. The default is `circulation-works`. + This is useful if you want to use the same Opensearch instance for multiple CM (optional). +- `PALACE_SEARCH_TIMEOUT`: The timeout in seconds to use when connecting to the Opensearch instance. The default is `20` + (optional). +- `PALACE_SEARCH_MAXSIZE`: The maximum size of the connection pool to use when connecting to the Opensearch instance. + (optional). + +```sh +export PALACE_SEARCH_URL="http://localhost:9200" +``` + #### Storage Service The application optionally uses a s3 compatible storage service to store files. To configure the application to use @@ -642,7 +658,7 @@ If you already have elastic search or postgres running locally, you can run them following environment variables: - `SIMPLIFIED_TEST_DATABASE` -- `SIMPLIFIED_TEST_OPENSEARCH` +- `PALACE_TEST_SEARCH_URL` Make sure the ports and usernames are updated to reflect the local configuration. diff --git a/api/admin/config.py b/api/admin/config.py index 9b3f8f2b5..427d913c8 100644 --- a/api/admin/config.py +++ b/api/admin/config.py @@ -16,7 +16,7 @@ class OperationalMode(str, Enum): class Configuration(LoggerMixin): APP_NAME = "Palace Collection Manager" PACKAGE_NAME = "@thepalaceproject/circulation-admin" - PACKAGE_VERSION = "1.12.0" + PACKAGE_VERSION = "1.13.0" STATIC_ASSETS = { "admin_js": "circulation-admin.js", diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index ff613a1d5..02e9438ca 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -37,16 +37,9 @@ def setup_admin_controllers(manager: CirculationManager): ) from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.search_service_self_tests import ( - SearchServiceSelfTestsController, - ) from api.admin.controller.self_tests import SelfTestsController from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController - from api.admin.controller.sitewide_services import ( - SearchServicesController, - SitewideServicesController, - ) from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, ) @@ -92,11 +85,6 @@ def setup_admin_controllers(manager: CirculationManager): manager.admin_individual_admin_settings_controller = ( IndividualAdminSettingsController(manager) ) - manager.admin_sitewide_services_controller = SitewideServicesController(manager) - manager.admin_search_service_self_tests_controller = ( - SearchServiceSelfTestsController(manager) - ) - manager.admin_search_services_controller = SearchServicesController(manager) manager.admin_catalog_services_controller = CatalogServicesController(manager) manager.admin_announcement_service = AnnouncementSettings(manager) manager.admin_search_controller = AdminSearchController(manager) diff --git a/api/admin/controller/search_service_self_tests.py b/api/admin/controller/search_service_self_tests.py deleted file mode 100644 index 2ec4385f9..000000000 --- a/api/admin/controller/search_service_self_tests.py +++ /dev/null @@ -1,28 +0,0 @@ -from flask_babel import lazy_gettext as _ - -from api.admin.controller.self_tests import SelfTestsController -from core.external_search import ExternalSearchIndex -from core.model import ExternalIntegration - - -class SearchServiceSelfTestsController(SelfTestsController): - def __init__(self, manager): - super().__init__(manager) - self.type = _("search service") - - def process_search_service_self_tests(self, identifier): - return self._manage_self_tests(identifier) - - def _find_protocol_class(self, integration): - # There's only one possibility for search integrations. - return ExternalSearchIndex, ( - None, - self._db, - ) - - def look_up_by_id(self, identifier): - return self.look_up_service_by_id( - identifier, - ExternalIntegration.OPENSEARCH, - ExternalIntegration.SEARCH_GOAL, - ) diff --git a/api/admin/controller/settings.py b/api/admin/controller/settings.py index c9e6ceaa9..39e3eadcb 100644 --- a/api/admin/controller/settings.py +++ b/api/admin/controller/settings.py @@ -24,7 +24,6 @@ ) from api.admin.validator import Validator from api.controller.circulation_manager import CirculationManagerController -from core.external_search import ExternalSearchIndex from core.integration.base import ( HasChildIntegrationConfiguration, HasIntegrationConfiguration, @@ -406,11 +405,7 @@ def _get_prior_test_results(self, item, protocol_class=None, *extra_args): self_test_results = None try: - if self.type == "search service": - self_test_results = ExternalSearchIndex.prior_test_results( - self._db, None, self._db, item - ) - elif self.type == "metadata service" and protocol_class: + if self.type == "metadata service" and protocol_class: self_test_results = protocol_class.prior_test_results( self._db, *extra_args ) diff --git a/api/admin/controller/sitewide_services.py b/api/admin/controller/sitewide_services.py deleted file mode 100644 index 15f54cdec..000000000 --- a/api/admin/controller/sitewide_services.py +++ /dev/null @@ -1,127 +0,0 @@ -import flask -from flask import Response -from flask_babel import lazy_gettext as _ - -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - MULTIPLE_SITEWIDE_SERVICES, - NO_PROTOCOL_FOR_NEW_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.external_search import ExternalSearchIndex -from core.model import ExternalIntegration, get_one_or_create -from core.util.problem_detail import ProblemDetail - - -class SitewideServicesController(SettingsController): - def _manage_sitewide_service( - self, - goal, - provider_apis, - service_key_name, - multiple_sitewide_services_detail, - protocol_name_attr="NAME", - ): - protocols = self._get_integration_protocols( - provider_apis, protocol_name_attr=protocol_name_attr - ) - - self.require_system_admin() - if flask.request.method == "GET": - return self.process_get(protocols, goal, service_key_name) - else: - return self.process_post(protocols, goal, multiple_sitewide_services_detail) - - def process_get(self, protocols, goal, service_key_name): - services = self._get_integration_info(goal, protocols) - return { - service_key_name: services, - "protocols": protocols, - } - - def process_post(self, protocols, goal, multiple_sitewide_services_detail): - name = flask.request.form.get("name") - protocol = flask.request.form.get("protocol") - fields = {"name": name, "protocol": protocol} - form_field_error = self.validate_form_fields(protocols, **fields) - if form_field_error: - return form_field_error - - settings = protocols[0].get("settings") - wrong_format = self.validate_formats(settings) - if wrong_format: - return wrong_format - - is_new = False - id = flask.request.form.get("id") - - if id: - # Find an existing service in order to edit it - service = self.look_up_service_by_id(id, protocol, goal) - else: - if protocol: - service, is_new = get_one_or_create( - self._db, ExternalIntegration, protocol=protocol, goal=goal - ) - # There can only be one of each sitewide service. - if not is_new: - self._db.rollback() - return MULTIPLE_SITEWIDE_SERVICES.detailed( - multiple_sitewide_services_detail - ) - else: - return NO_PROTOCOL_FOR_NEW_SERVICE - - if isinstance(service, ProblemDetail): - self._db.rollback() - return service - - name_error = self.check_name_unique(service, name) - if name_error: - self._db.rollback() - return name_error - - protocol_error = self.set_protocols(service, protocol, protocols) - if protocol_error: - self._db.rollback() - return protocol_error - - service.name = name - - if is_new: - return Response(str(service.id), 201) - else: - return Response(str(service.id), 200) - - def validate_form_fields(self, protocols, **fields): - """The 'name' and 'protocol' fields cannot be blank, and the protocol must - be selected from the list of recognized protocols.""" - - name = fields.get("name") - protocol = fields.get("protocol") - - if not name: - return INCOMPLETE_CONFIGURATION - if protocol and protocol not in [p.get("name") for p in protocols]: - return UNKNOWN_PROTOCOL - - -class SearchServicesController(SitewideServicesController): - def __init__(self, manager): - super().__init__(manager) - self.type = _("search service") - - def process_services(self): - detail = _( - "You tried to create a new search service, but a search service is already configured." - ) - return self._manage_sitewide_service( - ExternalIntegration.SEARCH_GOAL, - [ExternalSearchIndex], - "search_services", - detail, - ) - - def process_delete(self, service_id): - return self._delete_integration(service_id, ExternalIntegration.SEARCH_GOAL) diff --git a/api/admin/controller/work_editor.py b/api/admin/controller/work_editor.py index 7363cdc7e..40b236f47 100644 --- a/api/admin/controller/work_editor.py +++ b/api/admin/controller/work_editor.py @@ -699,6 +699,6 @@ def custom_lists(self, identifier_type, identifier): # NOTE: This may not make a difference until the # works are actually re-indexed. for lane in affected_lanes: - lane.update_size(self._db, self.search_engine) + lane.update_size(self._db, search_engine=self.search_engine) return Response(str(_("Success")), 200) diff --git a/api/admin/routes.py b/api/admin/routes.py index d9de20fc3..8b37d5441 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -484,32 +484,6 @@ def metadata_service_self_tests(identifier): ) -@app.route("/admin/search_services", methods=["GET", "POST"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_services(): - return app.manager.admin_search_services_controller.process_services() - - -@app.route("/admin/search_service/", methods=["DELETE"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_service(service_id): - return app.manager.admin_search_services_controller.process_delete(service_id) - - -@app.route("/admin/search_service_self_tests/", methods=["GET", "POST"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_service_self_tests(identifier): - return app.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - identifier - ) - - @app.route("/admin/catalog_services", methods=["GET", "POST"]) @returns_json_or_response_or_problem_detail @requires_admin diff --git a/api/circulation_manager.py b/api/circulation_manager.py index b8df1ce48..ddf36d173 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -31,7 +31,6 @@ from api.problem_details import * from api.saml.controller import SAMLController from core.app_server import ApplicationVersionController, load_facets_from_request -from core.external_search import ExternalSearchIndex from core.feed.annotator.circulation import ( CirculationManagerAnnotator, LibraryAnnotator, @@ -72,16 +71,9 @@ from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.search_service_self_tests import ( - SearchServiceSelfTestsController, - ) from api.admin.controller.self_tests import SelfTestsController from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController - from api.admin.controller.sitewide_services import ( - SearchServicesController, - SitewideServicesController, - ) from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, ) @@ -130,9 +122,6 @@ class CirculationManager(LoggerMixin): admin_sitewide_configuration_settings_controller: SitewideConfigurationSettingsController admin_library_settings_controller: LibrarySettingsController admin_individual_admin_settings_controller: IndividualAdminSettingsController - admin_sitewide_services_controller: SitewideServicesController - admin_search_service_self_tests_controller: SearchServiceSelfTestsController - admin_search_services_controller: SearchServicesController admin_catalog_services_controller: CatalogServicesController admin_announcement_service: AnnouncementSettings admin_search_controller: AdminSearchController @@ -148,6 +137,7 @@ def __init__( self._db = _db self.services = services self.analytics = services.analytics.analytics() + self.external_search = services.search.index() self.site_configuration_last_update = ( Configuration.site_configuration_last_update(self._db, timeout=0) ) @@ -214,8 +204,6 @@ def load_settings(self): self.auth = Authenticator(self._db, libraries, self.analytics) - self.setup_external_search() - # Track the Lane configuration for each library by mapping its # short name to the top-level lane. new_top_level_lanes = {} @@ -249,14 +237,9 @@ def get_domain(url): url = url.strip() if url == "*": return url - ( - scheme, - netloc, - path, - parameters, - query, - fragment, - ) = urllib.parse.urlparse(url) + scheme, netloc, path, parameters, query, fragment = urllib.parse.urlparse( + url + ) if scheme and netloc: return scheme + "://" + netloc else: @@ -290,28 +273,6 @@ def get_domain(url): max_len=1000, max_age_seconds=authentication_document_cache_time ) - @property - def external_search(self): - """Retrieve or create a connection to the search interface. - - This is created lazily so that a failure to connect only - affects feeds that depend on the search engine, not the whole - circulation manager. - """ - if not self._external_search: - self.setup_external_search() - return self._external_search - - def setup_external_search(self): - try: - self._external_search = self.setup_search() - self.external_search_initialization_exception = None - except Exception as e: - self.log.error("Exception initializing search engine: %s", e) - self._external_search = None - self.external_search_initialization_exception = e - return self._external_search - def log_lanes(self, lanelist=None, level=0): """Output information about the lane layout.""" lanelist = lanelist or self.top_level_lane.sublanes @@ -320,14 +281,6 @@ def log_lanes(self, lanelist=None, level=0): if lane.sublanes: self.log_lanes(lane.sublanes, level + 1) - def setup_search(self): - """Set up a search client.""" - search = ExternalSearchIndex(self._db) - if not search: - self.log.warn("No external search server configured.") - return None - return search - def setup_circulation(self, library, analytics): """Set up the Circulation object.""" return CirculationAPI(self._db, library, analytics=analytics) diff --git a/bin/search_index_refresh b/bin/search_index_refresh index f0dfb2a86..eb78dc090 100755 --- a/bin/search_index_refresh +++ b/bin/search_index_refresh @@ -8,7 +8,7 @@ import sys bin_dir = os.path.split(__file__)[0] package_dir = os.path.join(bin_dir, "..") sys.path.append(os.path.abspath(package_dir)) -from core.external_search import SearchIndexCoverageProvider from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider RunWorkCoverageProviderScript(SearchIndexCoverageProvider).run() diff --git a/core/coverage.py b/core/coverage.py index 556d993c3..3160b02c7 100644 --- a/core/coverage.py +++ b/core/coverage.py @@ -22,6 +22,7 @@ get_one, ) from core.model.coverage import EquivalencyCoverageRecord +from core.service.container import container_instance from core.util.datetime_helpers import utc_now from core.util.worker_pools import DatabaseJob @@ -201,6 +202,10 @@ def __init__( self.registered_only = registered_only self.collection_id = None + # Call init_resources() to initialize the logging configuration. + self.services = container_instance() + self.services.init_resources() + @property def log(self): if not hasattr(self, "_log"): diff --git a/core/external_search.py b/core/external_search.py index c3fe8af23..fa0aea533 100644 --- a/core/external_search.py +++ b/core/external_search.py @@ -1,18 +1,15 @@ from __future__ import annotations -import contextlib import datetime import json -import logging import re import time from collections import defaultdict -from collections.abc import Callable, Iterable -from typing import Any +from collections.abc import Iterable from attr import define from flask_babel import lazy_gettext as _ -from opensearch_dsl import SF, Search +from opensearch_dsl import SF from opensearch_dsl.query import ( Bool, DisMax, @@ -27,7 +24,6 @@ ) from opensearch_dsl.query import Query as BaseQuery from opensearch_dsl.query import Range, Regexp, Term, Terms -from opensearchpy import OpenSearch from spellchecker import SpellChecker from core.classifier import ( @@ -36,131 +32,44 @@ GradeLevelClassifier, KeywordBasedClassifier, ) -from core.config import CannotLoadConfiguration -from core.coverage import CoverageFailure, WorkPresentationProvider from core.facets import FacetConstants from core.lane import Pagination from core.metadata_layer import IdentifierData from core.model import ( - Collection, ConfigurationSetting, Contributor, DataSource, Edition, - ExternalIntegration, Identifier, Library, Work, - WorkCoverageRecord, numericrange_to_tuple, ) from core.problem_details import INVALID_INPUT -from core.search.coverage_remover import RemovesSearchCoverage from core.search.migrator import ( SearchDocumentReceiver, - SearchDocumentReceiverType, SearchMigrationInProgress, SearchMigrator, ) -from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory -from core.search.service import SearchService, SearchServiceOpensearch1 -from core.selftest import HasSelfTests +from core.search.service import SearchService from core.util import Values from core.util.cache import CachedData from core.util.datetime_helpers import from_timestamp from core.util.languages import LanguageNames +from core.util.log import LoggerMixin from core.util.personal_names import display_name_to_sort_name from core.util.problem_detail import ProblemDetail from core.util.stopwords import ENGLISH_STOPWORDS -@contextlib.contextmanager -def mock_search_index(mock=None): - """Temporarily mock the ExternalSearchIndex implementation - returned by the load() class method. - """ - try: - ExternalSearchIndex.MOCK_IMPLEMENTATION = mock - yield mock - finally: - ExternalSearchIndex.MOCK_IMPLEMENTATION = None - - -class ExternalSearchIndex(HasSelfTests): - NAME = ExternalIntegration.OPENSEARCH - - # A test may temporarily set this to a mock of this class. - # While that's true, load() will return the mock instead of - # instantiating new ExternalSearchIndex objects. - MOCK_IMPLEMENTATION = None - - WORKS_INDEX_PREFIX_KEY = "works_index_prefix" - DEFAULT_WORKS_INDEX_PREFIX = "circulation-works" - - TEST_SEARCH_TERM_KEY = "a search term" - DEFAULT_TEST_SEARCH_TERM = "test" - CURRENT_ALIAS_SUFFIX = "current" - - SETTINGS = [ - { - "key": ExternalIntegration.URL, - "label": _("URL"), - "required": True, - "format": "url", - }, - { - "key": WORKS_INDEX_PREFIX_KEY, - "label": _("Index prefix"), - "default": DEFAULT_WORKS_INDEX_PREFIX, - "required": True, - "description": _( - "Any Search indexes needed for this application will be created with this unique prefix. In most cases, the default will work fine. You may need to change this if you have multiple application servers using a single Search server." - ), - }, - { - "key": TEST_SEARCH_TERM_KEY, - "label": _("Test search term"), - "default": DEFAULT_TEST_SEARCH_TERM, - "description": _("Self tests will use this value as the search term."), - }, - ] - - SITEWIDE = True - - @classmethod - def search_integration(cls, _db) -> ExternalIntegration | None: - """Look up the ExternalIntegration for Opensearch.""" - return ExternalIntegration.lookup( - _db, ExternalIntegration.OPENSEARCH, goal=ExternalIntegration.SEARCH_GOAL - ) - - @classmethod - def load(cls, _db, *args, **kwargs): - """Load a generic implementation.""" - if cls.MOCK_IMPLEMENTATION: - return cls.MOCK_IMPLEMENTATION - return cls(_db, *args, **kwargs) - - _bulk: Callable[..., Any] - _revision: SearchSchemaRevision - _revision_base_name: str - _revision_directory: SearchRevisionDirectory - _search: Search - _search_migrator: SearchMigrator - _search_service: SearchService - _search_read_pointer: str - _test_search_term: str - +class ExternalSearchIndex(LoggerMixin): def __init__( self, - _db, - url: str | None = None, - test_search_term: str | None = None, - revision_directory: SearchRevisionDirectory | None = None, + service: SearchService, + revision_directory: SearchRevisionDirectory, version: int | None = None, - custom_client_service: SearchService | None = None, - ): + ) -> None: """Constructor :param revision_directory Override the directory of revisions that will be used. If this isn't provided, @@ -168,57 +77,16 @@ def __init__( :param version The specific revision that will be used. If not specified, the highest version in the revision directory will be used. """ - self.log = logging.getLogger("External search index") - - # We can't proceed without a database. - if not _db: - raise CannotLoadConfiguration( - "Cannot load Search configuration without a database.", - ) - - # Load the search integration. - integration = self.search_integration(_db) - if not integration: - raise CannotLoadConfiguration("No search integration configured.") - - if not url: - url = url or integration.url - test_search_term = integration.setting(self.TEST_SEARCH_TERM_KEY).value - - self._test_search_term = test_search_term or self.DEFAULT_TEST_SEARCH_TERM - - if not url: - raise CannotLoadConfiguration("No URL configured to the search server.") - - # Determine the base name we're going to use for storing revisions. - self._revision_base_name = integration.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value - - # Create the necessary search client, and the service used by the schema migrator. - if custom_client_service: - self._search_service = custom_client_service - else: - use_ssl = url.startswith("https://") - self.log.info("Connecting to the search cluster at %s", url) - new_client = OpenSearch(url, use_ssl=use_ssl, timeout=20, maxsize=25) - self._search_service = SearchServiceOpensearch1( - new_client, self._revision_base_name - ) + self._search_service = service # Locate the revision of the search index that we're going to use. # This will fail fast if the requested version isn't available. - self._revision_directory = ( - revision_directory or SearchRevisionDirectory.create() - ) + self._revision_directory = revision_directory if version: self._revision = self._revision_directory.find(version) else: self._revision = self._revision_directory.highest() - # initialize the cached data if not already done so - CachedData.initialize(_db) - # Get references to the read and write pointers. self._search_read_pointer = self._search_service.read_pointer_name() self._search_write_pointer = self._search_service.write_pointer_name() @@ -234,7 +102,8 @@ def start_migration(self) -> SearchMigrationInProgress | None: service=self._search_service, ) return migrator.migrate( - base_name=self._revision_base_name, version=self._revision.version + base_name=self._search_service.base_revision_name, + version=self._revision.version, ) def start_updating_search_documents(self) -> SearchDocumentReceiver: @@ -246,9 +115,6 @@ def start_updating_search_documents(self) -> SearchDocumentReceiver: def clear_search_documents(self) -> None: self._search_service.index_clear_documents(pointer=self._search_write_pointer) - def prime_query_values(self, _db): - JSONQuery.data_sources = _db.query(DataSource).all() - def create_search_doc(self, query_string, filter, pagination, debug): if filter and filter.search_type == "json": query = JSONQuery(query_string, filter) @@ -419,74 +285,6 @@ def remove_work(self, work): pointer=self._search_read_pointer, id=work.id ) - def _run_self_tests(self, _db): - # Helper methods for setting up the self-tests: - - def _search(): - return self.create_search_doc( - self._test_search_term, filter=None, pagination=None, debug=True - ) - - def _works(): - return self.query_works( - self._test_search_term, filter=None, pagination=None, debug=True - ) - - # The self-tests: - - def _search_for_term(): - titles = [(f"{x.sort_title} ({x.sort_author})") for x in _works()] - return titles - - yield self.run_test( - ("Search results for '%s':" % self._test_search_term), _search_for_term - ) - - def _get_raw_doc(): - search = _search() - return json.dumps(search.to_dict(), indent=1) - - yield self.run_test( - ("Search document for '%s':" % (self._test_search_term)), _get_raw_doc - ) - - def _get_raw_results(): - return [json.dumps(x.to_dict(), indent=1) for x in _works()] - - yield self.run_test( - ("Raw search results for '%s':" % (self._test_search_term)), - _get_raw_results, - ) - - def _count_docs(): - service = self.search_service() - client = service.search_client() - return str(client.count()) - - yield self.run_test( - ("Total number of search results for '%s':" % (self._test_search_term)), - _count_docs, - ) - - def _total_count(): - return str(self.count_works(None)) - - yield self.run_test( - "Total number of documents in this search index:", _total_count - ) - - def _collections(): - result = {} - - collections = _db.query(Collection) - for collection in collections: - filter = Filter(collections=[collection]) - result[collection.name] = self.count_works(filter) - - return json.dumps(result, indent=1) - - yield self.run_test("Total number of documents per collection:", _collections) - def initialize_indices(self) -> bool: """Attempt to initialize the indices and pointers for a first time run""" service = self.search_service() @@ -2742,73 +2540,3 @@ def __init__(self, work, hit): def __getattr__(self, k): return getattr(self._work, k) - - -class SearchIndexCoverageProvider(RemovesSearchCoverage, WorkPresentationProvider): - """Make sure all Works have up-to-date representation in the - search index. - """ - - SERVICE_NAME = "Search index coverage provider" - - DEFAULT_BATCH_SIZE = 500 - - OPERATION = WorkCoverageRecord.UPDATE_SEARCH_INDEX_OPERATION - - def __init__(self, *args, **kwargs): - search_index_client = kwargs.pop("search_index_client", None) - super().__init__(*args, **kwargs) - self.search_index_client = search_index_client or ExternalSearchIndex(self._db) - - # - # Try to migrate to the latest schema. If the function returns None, it means - # that no migration is necessary, and we're already at the latest version. If - # we're already at the latest version, then simply upload search documents instead. - # - self.receiver = None - self.migration: None | ( - SearchMigrationInProgress - ) = self.search_index_client.start_migration() - if self.migration is None: - self.receiver: SearchDocumentReceiver = ( - self.search_index_client.start_updating_search_documents() - ) - else: - # We do have a migration, we must clear out the index and repopulate the index - self.remove_search_coverage_records() - - def on_completely_finished(self): - # Tell the search migrator that no more documents are going to show up. - target: SearchDocumentReceiverType = self.migration or self.receiver - target.finish() - - def run_once_and_update_timestamp(self): - # We do not catch exceptions here, so that the on_completely finished should not run - # if there was a runtime error - result = super().run_once_and_update_timestamp() - self.on_completely_finished() - return result - - def process_batch(self, works) -> list[Work | CoverageFailure]: - target: SearchDocumentReceiverType = self.migration or self.receiver - failures = target.add_documents( - documents=self.search_index_client.create_search_documents_from_works(works) - ) - - # Maintain a dictionary of works so that we can efficiently remove failed works later. - work_map: dict[int, Work] = {} - for work in works: - work_map[work.id] = work - - # Remove all the works that failed and create failure records for them. - results: list[Work | CoverageFailure] = [] - for failure in failures: - work = work_map[failure.id] - del work_map[failure.id] - results.append(CoverageFailure(work, repr(failure))) - - # Append all the remaining works that didn't fail. - for work in work_map.values(): - results.append(work) - - return results diff --git a/core/feed/acquisition.py b/core/feed/acquisition.py index 476b9566c..68dba2829 100644 --- a/core/feed/acquisition.py +++ b/core/feed/acquisition.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any +from dependency_injector.wiring import Provide, inject from sqlalchemy.orm import Query, Session from api.problem_details import NOT_FOUND_ON_REMOTE @@ -426,6 +427,7 @@ def error_message( # Each classmethod creates a different kind of feed @classmethod + @inject def page( cls, _db: Session, @@ -435,7 +437,7 @@ def page( annotator: CirculationManagerAnnotator, facets: FacetsWithEntryPoint | None, pagination: Pagination | None, - search_engine: ExternalSearchIndex | None, + search_engine: ExternalSearchIndex = Provide["search.index"], ) -> OPDSAcquisitionFeed: works = worklist.works( _db, facets=facets, pagination=pagination, search_engine=search_engine @@ -653,6 +655,7 @@ def single_entry( return None @classmethod + @inject def groups( cls, _db: Session, @@ -662,7 +665,7 @@ def groups( annotator: LibraryAnnotator, pagination: Pagination | None = None, facets: FacetsWithEntryPoint | None = None, - search_engine: ExternalSearchIndex | None = None, + search_engine: ExternalSearchIndex = Provide["search.index"], search_debug: bool = False, ) -> OPDSAcquisitionFeed: """Internal method called by groups() when a grouped feed diff --git a/core/lane.py b/core/lane.py index 4d04fcd3f..29d7b88f6 100644 --- a/core/lane.py +++ b/core/lane.py @@ -4,9 +4,10 @@ import logging import time from collections import defaultdict -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import quote_plus +from dependency_injector.wiring import Provide, inject from flask_babel import lazy_gettext as _ from opensearchpy.exceptions import OpenSearchException from sqlalchemy import ( @@ -69,6 +70,9 @@ from core.util.opds_writer import OPDSFeed from core.util.problem_detail import ProblemDetail +if TYPE_CHECKING: + from core.external_search import ExternalSearchIndex, WorkSearchResult + class BaseFacets(FacetConstants): """Basic faceting class that doesn't modify a search filter at all. @@ -1745,13 +1749,14 @@ def overview_facets(self, _db, facets): """ return facets + @inject def groups( self, _db, include_sublanes=True, pagination=None, facets=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Extract a list of samples from each child of this WorkList. This @@ -1814,12 +1819,13 @@ def groups( ): yield work, worklist + @inject def works( self, _db, facets=None, pagination=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, **kwargs, ): @@ -1842,9 +1848,6 @@ def works( that generates such a list when executed. """ - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) filter = self.filter(_db, facets) hits = search_engine.query_works( query_string=None, filter=filter, pagination=pagination, debug=debug @@ -1997,6 +2000,7 @@ def search( return results + @inject def _groups_for_lanes( self, _db, @@ -2004,7 +2008,7 @@ def _groups_for_lanes( queryable_lanes, pagination, facets, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Ask the search engine for groups of featurable works in the @@ -2041,10 +2045,6 @@ def _groups_for_lanes( else: target_size = pagination.size - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) - if isinstance(self, Lane): parent_lane = self else: @@ -2075,15 +2075,15 @@ def _done_with_lane(lane): by_lane[lane].extend(list(might_need_to_reuse.values())[:num_missing]) used_works = set() - by_lane = defaultdict(list) + by_lane: dict[Lane, list[WorkSearchResult]] = defaultdict(list) working_lane = None - might_need_to_reuse = dict() + might_need_to_reuse: dict[int, WorkSearchResult] = dict() for work, lane in works_and_lanes: if lane != working_lane: # Either we're done with the old lane, or we're just # starting and there was no old lane. if working_lane: - _done_with_lane(working_lane) + _done_with_lane(working_lane) # type: ignore[unreachable] working_lane = lane used_works_this_lane = set() might_need_to_reuse = dict() @@ -2918,12 +2918,12 @@ def uses_customlists(self): return True return False - def update_size(self, _db, search_engine=None): + @inject + def update_size( + self, _db, search_engine: ExternalSearchIndex = Provide["search.index"] + ): """Update the stored estimate of the number of Works in this Lane.""" library = self.get_library(_db) - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) # Do the estimate for every known entry point. by_entrypoint = dict() @@ -3130,13 +3130,14 @@ def _size_for_facets(self, facets): size = self.size_by_entrypoint[entrypoint_name] return size + @inject def groups( self, _db, include_sublanes=True, pagination=None, facets=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Return a list of (Work, Lane) 2-tuples @@ -3169,7 +3170,6 @@ def groups( queryable_lanes, pagination=pagination, facets=facets, - search_engine=search_engine, debug=debug, ) diff --git a/core/metadata_layer.py b/core/metadata_layer.py index 9368a522d..442d754b1 100644 --- a/core/metadata_layer.py +++ b/core/metadata_layer.py @@ -42,7 +42,6 @@ get_one_or_create, ) from core.model.licensing import LicenseFunctions, LicenseStatus -from core.service.container import Services from core.util import LanguageCodes from core.util.datetime_helpers import to_utc, utc_now from core.util.median import median @@ -83,7 +82,7 @@ def __init__( @classmethod @inject def from_license_source( - cls, _db, analytics: Analytics = Provide[Services.analytics.analytics], **args + cls, _db, analytics: Analytics = Provide["analytics.analytics"], **args ): """When gathering data from the license source, overwrite all old data from this source with new data from the same source. Also diff --git a/core/model/collection.py b/core/model/collection.py index b9876ba4e..82a8b07ea 100644 --- a/core/model/collection.py +++ b/core/model/collection.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import TYPE_CHECKING, Any, TypeVar +from dependency_injector.wiring import Provide, inject from sqlalchemy import ( Boolean, Column, @@ -570,7 +571,10 @@ def restrict_to_ready_deliverable_works( ) return query - def delete(self, search_index: ExternalSearchIndex | None = None) -> None: + @inject + def delete( + self, search_index: ExternalSearchIndex = Provide["search.index"] + ) -> None: """Delete a collection. Collections can have hundreds of thousands of @@ -599,7 +603,7 @@ def delete(self, search_index: ExternalSearchIndex | None = None) -> None: # https://docs.sqlalchemy.org/en/14/orm/cascades.html#notes-on-delete-deleting-objects-referenced-from-collections-and-scalar-relationships work.license_pools.remove(pool) if not work.license_pools: - work.delete(search_index) + work.delete(search_index=search_index) _db.delete(pool) diff --git a/core/model/configuration.py b/core/model/configuration.py index 079b5222b..fe27a01cd 100644 --- a/core/model/configuration.py +++ b/core/model/configuration.py @@ -287,7 +287,7 @@ def setting(self, key): """ return ConfigurationSetting.for_externalintegration(key, self) - @hybrid_property + @property def url(self): return self.setting(self.URL).value @@ -295,7 +295,7 @@ def url(self): def url(self, new_url): self.set_setting(self.URL, new_url) - @hybrid_property + @property def username(self): return self.setting(self.USERNAME).value @@ -303,7 +303,7 @@ def username(self): def username(self, new_username): self.set_setting(self.USERNAME, new_username) - @hybrid_property + @property def password(self): return self.setting(self.PASSWORD).value diff --git a/core/model/patron.py b/core/model/patron.py index 445657991..ec756015d 100644 --- a/core/model/patron.py +++ b/core/model/patron.py @@ -272,7 +272,7 @@ def is_last_loan_activity_stale(self) -> bool: seconds=self.loan_activity_max_age ) - @hybrid_property + @property def last_loan_activity_sync(self): """When was the last time we asked the vendors about this patron's loan activity? diff --git a/core/model/work.py b/core/model/work.py index d53c0e2cf..36f59c310 100644 --- a/core/model/work.py +++ b/core/model/work.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast import pytz +from dependency_injector.wiring import Provide, inject from sqlalchemy import ( Boolean, Column, @@ -30,7 +31,6 @@ from sqlalchemy.sql.functions import func from core.classifier import Classifier, WorkClassifier -from core.config import CannotLoadConfiguration from core.model import ( Base, PresentationCalculationPolicy, @@ -58,6 +58,7 @@ # Import related models when doing type checking if TYPE_CHECKING: + from core.external_search import ExternalSearchIndex from core.model import CustomListEntry, Library, LicensePool @@ -2169,19 +2170,13 @@ def top_genre(self): ) return genre.name if genre else None - def delete(self, search_index=None): + @inject + def delete( + self, search_index: ExternalSearchIndex = Provide["search.index"] + ) -> None: """Delete the work from both the DB and search index.""" _db = Session.object_session(self) - if search_index is None: - try: - from core.external_search import ExternalSearchIndex - - search_index = ExternalSearchIndex(_db) - except CannotLoadConfiguration as e: - # No search index is configured. This is fine -- just skip that part. - pass - if search_index is not None: - search_index.remove_work(self) + search_index.remove_work(self) _db.delete(self) diff --git a/core/monitor.py b/core/monitor.py index 4f664757c..b7e3b011d 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -920,11 +920,9 @@ class WorkReaper(ReaperMonitor): MODEL_CLASS = Work def __init__(self, *args, **kwargs): - from core.external_search import ExternalSearchIndex - search_index_client = kwargs.pop("search_index_client", None) super().__init__(*args, **kwargs) - self.search_index_client = search_index_client or ExternalSearchIndex(self._db) + self.search_index_client = search_index_client or self.services.search.index() def query(self): return ( @@ -935,7 +933,7 @@ def query(self): def delete(self, work): """Delete work from opensearch and database.""" - work.delete(self.search_index_client) + work.delete(search_index=self.search_index_client) ReaperMonitor.REGISTRY.append(WorkReaper) diff --git a/core/query/customlist.py b/core/query/customlist.py index 8a50bafd4..fb3a4ab87 100644 --- a/core/query/customlist.py +++ b/core/query/customlist.py @@ -4,6 +4,8 @@ import json from typing import TYPE_CHECKING +from dependency_injector.wiring import Provide, inject + from api.admin.problem_details import ( CUSTOMLIST_ENTRY_NOT_VALID_FOR_LIBRARY, CUSTOMLIST_SOURCE_COLLECTION_MISSING, @@ -13,6 +15,7 @@ from core.model.customlist import CustomList, CustomListEntry from core.model.library import Library from core.model.licensing import LicensePool +from core.service.container import Services from core.util.log import LoggerMixin from core.util.problem_detail import ProblemDetail @@ -60,6 +63,7 @@ def share_locally_with_library( return True @classmethod + @inject def populate_query_pages( cls, _db: Session, @@ -68,9 +72,10 @@ def populate_query_pages( max_pages: int = 100000, page_size: int = 100, json_query: dict | None = None, + search: ExternalSearchIndex = Provide[Services.search.index], ) -> int: """Populate the custom list while paging through the search query results - :param _db: The database conenction + :param _db: The database connection :param custom_list: The list to be populated :param start_page: Offset of the search will be used from here (based on page_size) :param max_pages: Maximum number of pages to search through @@ -78,11 +83,8 @@ def populate_query_pages( :param json_query: If provided, use this json query rather than that of the custom list """ - log = cls.logger() - search = ExternalSearchIndex(_db) - if not custom_list.auto_update_query: - log.info( + cls.logger().info( f"Cannot populate entries: Custom list {custom_list.name} is missing an auto update query" ) return 0 @@ -113,7 +115,7 @@ def populate_query_pages( ## No more works if not len(works): - log.info( + cls.logger().info( f"{custom_list.name} customlist updated with {total_works_updated} works, moving on..." ) break @@ -131,7 +133,7 @@ def populate_query_pages( for work in works: custom_list.add_entry(work, update_external_index=True) - log.info( + cls.logger().info( f"Updated customlist {custom_list.name} with {total_works_updated} works" ) diff --git a/core/scripts.py b/core/scripts.py index 19e839533..9dcb648c1 100644 --- a/core/scripts.py +++ b/core/scripts.py @@ -16,13 +16,9 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound -from core.config import CannotLoadConfiguration, Configuration, ConfigurationConstants +from core.config import Configuration, ConfigurationConstants from core.coverage import CollectionCoverageProviderJob, CoverageProviderProgress -from core.external_search import ( - ExternalSearchIndex, - Filter, - SearchIndexCoverageProvider, -) +from core.external_search import ExternalSearchIndex, Filter from core.integration.goals import Goals from core.lane import Lane from core.metadata_layer import TimestampData @@ -58,6 +54,7 @@ from core.monitor import CollectionMonitor, ReaperMonitor from core.opds_import import OPDSImporter, OPDSImportMonitor from core.query.customlist import CustomListQueries +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.coverage_remover import RemovesSearchCoverage from core.service.container import Services, container_instance from core.util import fast_query_count @@ -2472,13 +2469,7 @@ def __init__( _db = _db or self._db super().__init__(_db) self.output = output or sys.stdout - try: - self.search = search or ExternalSearchIndex(_db) - except CannotLoadConfiguration: - self.out( - "Here's your problem: the search integration is missing or misconfigured." - ) - raise + self.search = search or self.services.search.index() def out(self, s, *args): if not s.endswith("\n"): @@ -2580,7 +2571,7 @@ class UpdateLaneSizeScript(LaneSweeperScript): def __init__(self, _db=None, *args, **kwargs): super().__init__(_db, *args, **kwargs) search = kwargs.get("search_index_client", None) - self._search: ExternalSearchIndex = search or ExternalSearchIndex(self._db) + self._search: ExternalSearchIndex = search or self.services.search.index() def should_process_lane(self, lane): """We don't want to process generic WorkLists -- there's nowhere @@ -2616,7 +2607,7 @@ class RebuildSearchIndexScript(RunWorkCoverageProviderScript, RemovesSearchCover def __init__(self, *args, **kwargs): search = kwargs.get("search_index_client", None) - self.search: ExternalSearchIndex = search or ExternalSearchIndex(self._db) + self.search: ExternalSearchIndex = search or self.services.search.index() super().__init__(SearchIndexCoverageProvider, *args, **kwargs) def do_run(self): diff --git a/core/search/coverage_provider.py b/core/search/coverage_provider.py new file mode 100644 index 000000000..5269df074 --- /dev/null +++ b/core/search/coverage_provider.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from core.coverage import CoverageFailure, WorkPresentationProvider +from core.model import Work, WorkCoverageRecord +from core.search.coverage_remover import RemovesSearchCoverage +from core.search.migrator import ( + SearchDocumentReceiver, + SearchDocumentReceiverType, + SearchMigrationInProgress, +) + + +class SearchIndexCoverageProvider(RemovesSearchCoverage, WorkPresentationProvider): + """Make sure all Works have up-to-date representation in the + search index. + """ + + SERVICE_NAME = "Search index coverage provider" + + DEFAULT_BATCH_SIZE = 500 + + OPERATION = WorkCoverageRecord.UPDATE_SEARCH_INDEX_OPERATION + + def __init__(self, *args, **kwargs): + search_index_client = kwargs.pop("search_index_client", None) + super().__init__(*args, **kwargs) + self.search_index_client = search_index_client or self.services.search.index() + + # + # Try to migrate to the latest schema. If the function returns None, it means + # that no migration is necessary, and we're already at the latest version. If + # we're already at the latest version, then simply upload search documents instead. + # + self.receiver = None + self.migration: None | ( + SearchMigrationInProgress + ) = self.search_index_client.start_migration() + if self.migration is None: + self.receiver: SearchDocumentReceiver = ( + self.search_index_client.start_updating_search_documents() + ) + else: + # We do have a migration, we must clear out the index and repopulate the index + self.remove_search_coverage_records() + + def on_completely_finished(self): + # Tell the search migrator that no more documents are going to show up. + target: SearchDocumentReceiverType = self.migration or self.receiver + target.finish() + + def run_once_and_update_timestamp(self): + # We do not catch exceptions here, so that the on_completely finished should not run + # if there was a runtime error + result = super().run_once_and_update_timestamp() + self.on_completely_finished() + return result + + def process_batch(self, works) -> list[Work | CoverageFailure]: + target: SearchDocumentReceiverType = self.migration or self.receiver + failures = target.add_documents( + documents=self.search_index_client.create_search_documents_from_works(works) + ) + + # Maintain a dictionary of works so that we can efficiently remove failed works later. + work_map: dict[int, Work] = {} + for work in works: + work_map[work.id] = work + + # Remove all the works that failed and create failure records for them. + results: list[Work | CoverageFailure] = [] + for failure in failures: + work = work_map[failure.id] + del work_map[failure.id] + results.append(CoverageFailure(work, repr(failure))) + + # Append all the remaining works that didn't fail. + for work in work_map.values(): + results.append(work) + + return results diff --git a/core/search/service.py b/core/search/service.py index bf751fd91..b1a39a9d2 100644 --- a/core/search/service.py +++ b/core/search/service.py @@ -72,6 +72,11 @@ class SearchService(ABC): sensible types, rather than the untyped pile of JSON the actual search client provides. """ + @property + @abstractmethod + def base_revision_name(self) -> str: + """The base name used for all indexes.""" + @abstractmethod def read_pointer_name(self) -> str: """Get the name used for the read pointer.""" @@ -164,7 +169,7 @@ def __init__(self, client: OpenSearch, base_revision_name: str): self._logger = logging.getLogger(SearchServiceOpensearch1.__name__) self._client = client self._search = Search(using=self._client) - self.base_revision_name = base_revision_name + self._base_revision_name = base_revision_name self._multi_search = MultiSearch(using=self._client) self._indexes_created: list[str] = [] @@ -174,6 +179,10 @@ def __init__(self, client: OpenSearch, base_revision_name: str): body={"persistent": {"action.auto_create_index": "false"}} ) + @property + def base_revision_name(self) -> str: + return self._base_revision_name + def indexes_created(self) -> list[str]: return self._indexes_created diff --git a/core/selftest.py b/core/selftest.py index 5136629b3..2bf4924c0 100644 --- a/core/selftest.py +++ b/core/selftest.py @@ -295,16 +295,7 @@ def store_self_test_results( self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] ) -> None: """Store the results of a self-test in the database.""" - integration: ExternalIntegration | None - from core.external_search import ExternalSearchIndex - - if isinstance(self, ExternalSearchIndex): - integration = self.search_integration(_db) - for idx, result in enumerate(value.get("results")): # type: ignore[arg-type] - if isinstance(results[idx].result, list): - result["result"] = results[idx].result - else: - integration = self.external_integration(_db) + integration = self.external_integration(_db) if integration is not None: integration.setting(self.SELF_TEST_RESULTS_SETTING).value = json.dumps( @@ -325,14 +316,8 @@ def prior_test_results( """ constructor_method = constructor_method or cls instance = constructor_method(*args, **kwargs) - integration: ExternalIntegration | None - - from core.external_search import ExternalSearchIndex - if isinstance(instance, ExternalSearchIndex): - integration = instance.search_integration(_db) - else: - integration = instance.external_integration(_db) + integration = instance.external_integration(_db) if integration: return ( diff --git a/core/service/container.py b/core/service/container.py index 273dbdc3b..a02f71a75 100644 --- a/core/service/container.py +++ b/core/service/container.py @@ -6,6 +6,8 @@ from core.service.analytics.container import AnalyticsContainer from core.service.logging.configuration import LoggingConfiguration from core.service.logging.container import Logging +from core.service.search.configuration import SearchConfiguration +from core.service.search.container import Search from core.service.storage.configuration import StorageConfiguration from core.service.storage.container import Storage @@ -29,28 +31,43 @@ class Services(DeclarativeContainer): storage=storage, ) - -def create_container() -> Services: - container = Services() - container.config.from_dict( - { - "storage": StorageConfiguration().dict(), - "logging": LoggingConfiguration().dict(), - "analytics": AnalyticsConfiguration().dict(), - } + search = Container( + Search, + config=config.search, ) + + +def wire_container(container: Services) -> None: container.wire( modules=[ - "core.metadata_layer", - "api.odl", "api.axis", "api.bibliotheca", - "api.enki", "api.circulation_manager", + "api.enki", + "api.odl", "api.overdrive", "core.feed.annotator.circulation", + "core.feed.acquisition", + "core.lane", + "core.metadata_layer", + "core.model.collection", + "core.model.work", + "core.query.customlist", ] ) + + +def create_container() -> Services: + container = Services() + container.config.from_dict( + { + "storage": StorageConfiguration().dict(), + "logging": LoggingConfiguration().dict(), + "analytics": AnalyticsConfiguration().dict(), + "search": SearchConfiguration().dict(), + } + ) + wire_container(container) return container diff --git a/core/service/search/configuration.py b/core/service/search/configuration.py new file mode 100644 index 000000000..b3e2a14e7 --- /dev/null +++ b/core/service/search/configuration.py @@ -0,0 +1,13 @@ +from pydantic import AnyHttpUrl + +from core.service.configuration import ServiceConfiguration + + +class SearchConfiguration(ServiceConfiguration): + url: AnyHttpUrl + index_prefix: str = "circulation-works" + timeout: int = 20 + maxsize: int = 25 + + class Config: + env_prefix = "PALACE_SEARCH_" diff --git a/core/service/search/container.py b/core/service/search/container.py new file mode 100644 index 000000000..6a171052a --- /dev/null +++ b/core/service/search/container.py @@ -0,0 +1,35 @@ +from dependency_injector import providers +from dependency_injector.containers import DeclarativeContainer +from dependency_injector.providers import Provider +from opensearchpy import OpenSearch + +from core.external_search import ExternalSearchIndex +from core.search.revision_directory import SearchRevisionDirectory +from core.search.service import SearchServiceOpensearch1 + + +class Search(DeclarativeContainer): + config = providers.Configuration() + + client: Provider[OpenSearch] = providers.Singleton( + OpenSearch, + hosts=config.url, + timeout=config.timeout, + maxsize=config.maxsize, + ) + + service: Provider[SearchServiceOpensearch1] = providers.Singleton( + SearchServiceOpensearch1, + client=client, + base_revision_name=config.index_prefix, + ) + + revision_directory: Provider[SearchRevisionDirectory] = providers.Singleton( + SearchRevisionDirectory.create, + ) + + index: Provider[ExternalSearchIndex] = providers.Singleton( + ExternalSearchIndex, + service=service, + revision_directory=revision_directory, + ) diff --git a/docker-compose.yml b/docker-compose.yml index 258847373..f8921f3d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,51 @@ version: "3.9" -# Common set of CM environment variables +# Common CM setup # see: https://github.com/compose-spec/compose-spec/blob/master/spec.md#extension -x-cm-env-variables: &cm-env-variables - SIMPLIFIED_PRODUCTION_DATABASE: "postgresql://palace:test@pg:5432/circ" - PALACE_STORAGE_ACCESS_KEY: "palace" - PALACE_STORAGE_SECRET_KEY: "test123456789" - PALACE_STORAGE_ENDPOINT_URL: "http://minio:9000" - PALACE_STORAGE_PUBLIC_ACCESS_BUCKET: "public" - PALACE_STORAGE_ANALYTICS_BUCKET: "analytics" - PALACE_STORAGE_URL_TEMPLATE: "http://localhost:9000/{bucket}/{key}" - PALACE_REPORTING_NAME: "TEST CM" +x-cm-variables: &cm + platform: "${BUILD_PLATFORM-}" + environment: + SIMPLIFIED_PRODUCTION_DATABASE: "postgresql://palace:test@pg:5432/circ" + PALACE_SEARCH_URL: "http://os:9200" + PALACE_STORAGE_ACCESS_KEY: "palace" + PALACE_STORAGE_SECRET_KEY: "test123456789" + PALACE_STORAGE_ENDPOINT_URL: "http://minio:9000" + PALACE_STORAGE_PUBLIC_ACCESS_BUCKET: "public" + PALACE_STORAGE_ANALYTICS_BUCKET: "analytics" + PALACE_STORAGE_URL_TEMPLATE: "http://localhost:9000/{bucket}/{key}" + PALACE_REPORTING_NAME: "TEST CM" + depends_on: + pg: + condition: service_healthy + minio: + condition: service_healthy + os: + condition: service_healthy + +x-cm-build: &cm-build + context: . + dockerfile: docker/Dockerfile + args: + - BASE_IMAGE=${BUILD_BASE_IMAGE-ghcr.io/thepalaceproject/circ-baseimage:latest} + cache_from: + - ${BUILD_CACHE_FROM-ghcr.io/thepalaceproject/circ-webapp:main} services: # example docker compose configuration for testing and development webapp: + <<: *cm build: - context: . - dockerfile: docker/Dockerfile + <<: *cm-build target: webapp ports: - "6500:80" - environment: *cm-env-variables scripts: + <<: *cm build: - context: . - dockerfile: docker/Dockerfile + <<: *cm-build target: scripts - environment: *cm-env-variables pg: image: "postgres:12" @@ -37,6 +53,11 @@ services: POSTGRES_USER: palace POSTGRES_PASSWORD: test POSTGRES_DB: circ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U palace -d circ"] + interval: 30s + timeout: 30s + retries: 3 minio: image: "bitnami/minio:2023.2.27" @@ -48,11 +69,22 @@ services: MINIO_ROOT_PASSWORD: "test123456789" MINIO_SCHEME: "http" MINIO_DEFAULT_BUCKETS: "public:download,analytics" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 os: build: dockerfile: docker/Dockerfile.ci target: opensearch + context: . environment: - discovery.type: single-node - DISABLE_SECURITY_PLUGIN: true + discovery.type: "single-node" + DISABLE_SECURITY_PLUGIN: "true" + healthcheck: + test: curl --silent http://localhost:9200 >/dev/null; if [[ $$? == 52 ]]; then echo 0; else echo 1; fi + interval: 30s + timeout: 10s + retries: 5 diff --git a/docker/ci/check_service_status.sh b/docker/ci/check_service_status.sh index a76ff6d02..9adbe35f8 100644 --- a/docker/ci/check_service_status.sh +++ b/docker/ci/check_service_status.sh @@ -4,7 +4,7 @@ function wait_for_runit() # The container to run the command in container="$1" - timeout 120s grep -q 'Runit started' <(docker logs "$container" -f 2>&1) + timeout 120s grep -q 'Runit started' <(docker compose logs "$container" -f 2>&1) } # A method to check that runit services are running inside the container @@ -17,7 +17,7 @@ function check_service_status() service="$2" # Check the status of the service. - service_status=$(docker exec "$container" /bin/bash -c "sv check $service") + service_status=$(docker compose exec "$container" /bin/bash -c "sv check $service") # Get the exit code for the sv call. sv_status=$? @@ -34,7 +34,7 @@ function check_crontab() { container="$1" # Installing the crontab will reveal any errors and exit with an error code - $(docker exec "$container" /bin/bash -c "crontab /etc/cron.d/circulation") + $(docker compose exec "$container" /bin/bash -c "crontab /etc/cron.d/circulation") validate_status=$? if [[ "$validate_status" != 0 ]]; then echo " FAIL: crontab is incorrect" @@ -48,7 +48,7 @@ function run_script() { container="$1" script="$2" - output=$(docker exec "$container" /bin/bash -c "$script") + output=$(docker compose exec "$container" /bin/bash -c "$script") script_status=$? if [[ "$script_status" != 0 ]]; then echo " FAIL: script run failed" diff --git a/docker/ci/test_migrations.sh b/docker/ci/test_migrations.sh index 2ebe133e1..e6523c04d 100755 --- a/docker/ci/test_migrations.sh +++ b/docker/ci/test_migrations.sh @@ -26,7 +26,7 @@ compose_cmd() { run_in_container() { - compose_cmd run --build --rm webapp /bin/bash -c "source env/bin/activate && $*" + compose_cmd run --build --rm --no-deps webapp /bin/bash -c "source env/bin/activate && $*" } cleanup() { @@ -105,10 +105,12 @@ if [[ -z $first_migration_commit ]]; then exit 1 fi -echo "Starting containers and initializing database at commit ${first_migration_commit}" -git checkout -q "${first_migration_commit}" +echo "Starting containers" compose_cmd down compose_cmd up -d pg + +echo "Initializing database at commit ${first_migration_commit}" +git checkout -q "${first_migration_commit}" run_in_container "./bin/util/initialize_instance" initialize_exit_code=$? if [[ $initialize_exit_code -ne 0 ]]; then diff --git a/docker/ci/test_scripts.sh b/docker/ci/test_scripts.sh index d283e8709..55dc74de1 100755 --- a/docker/ci/test_scripts.sh +++ b/docker/ci/test_scripts.sh @@ -12,6 +12,9 @@ source "${dir}/check_service_status.sh" # Wait for container to start wait_for_runit "$container" +# Make sure database initialization completed successfully +timeout 240s grep -q 'Initialization complete' <(docker compose logs "$container" -f 2>&1) + # Make sure that cron is running in the scripts container check_service_status "$container" /etc/service/cron diff --git a/docker/ci/test_webapp.sh b/docker/ci/test_webapp.sh index aa5680b1c..51241f919 100755 --- a/docker/ci/test_webapp.sh +++ b/docker/ci/test_webapp.sh @@ -17,10 +17,10 @@ check_service_status "$container" /etc/service/nginx check_service_status "$container" /etc/service/uwsgi # Wait for UWSGI to be ready to accept connections. -timeout 240s grep -q 'WSGI app .* ready in [0-9]* seconds' <(docker logs "$container" -f 2>&1) +timeout 240s grep -q 'WSGI app .* ready in [0-9]* seconds' <(docker compose logs "$container" -f 2>&1) # Make sure the web server is running. -healthcheck=$(docker exec "$container" curl --write-out "%{http_code}" --silent --output /dev/null http://localhost/healthcheck.html) +healthcheck=$(docker compose exec "$container" curl --write-out "%{http_code}" --silent --output /dev/null http://localhost/healthcheck.html) if ! [[ ${healthcheck} == "200" ]]; then exit 1 else @@ -28,7 +28,7 @@ else fi # Also make sure the app server is running. -feed_type=$(docker exec "$container" curl --write-out "%{content_type}" --silent --output /dev/null http://localhost/version.json) +feed_type=$(docker compose exec "$container" curl --write-out "%{content_type}" --silent --output /dev/null http://localhost/version.json) if ! [[ ${feed_type} == "application/json" ]]; then exit 1 else diff --git a/scripts.py b/scripts.py index 1a99db4b7..25515015c 100644 --- a/scripts.py +++ b/scripts.py @@ -31,7 +31,6 @@ OPDSForDistributorsReaperMonitor, ) from api.overdrive import OverdriveAPI -from core.external_search import ExternalSearchIndex from core.integration.goals import Goals from core.lane import Lane from core.marc import Annotator as MarcAnnotator @@ -96,7 +95,7 @@ def q(self): def run(self): q = self.q() - search_index_client = ExternalSearchIndex(self._db) + search_index_client = self.services.search.index() self.log.info("Attempting to repair metadata for %d works" % q.count()) success = 0 @@ -528,26 +527,12 @@ def initialize_database(self, connection: Connection) -> None: # Create a secret key if one doesn't already exist. ConfigurationSetting.sitewide_secret(session, Configuration.SECRET_KEY) - # Initialize the search client to create the "-current" alias. - try: - ExternalSearchIndex(session) - except CannotLoadConfiguration: - # Opensearch isn't configured, so do nothing. - pass - # Stamp the most recent migration as the current state of the DB alembic_conf = self._get_alembic_config(connection) command.stamp(alembic_conf, "head") - def initialize_search_indexes(self, _db: Session) -> bool: - try: - search = ExternalSearchIndex(_db) - except CannotLoadConfiguration as ex: - self.log.error( - "No search integration found yet, cannot initialize search indices." - ) - self.log.error(f"Error: {ex}") - return False + def initialize_search_indexes(self) -> bool: + search = self._container.search.index() return search.initialize_indices() def initialize(self, connection: Connection): @@ -569,8 +554,7 @@ def initialize(self, connection: Connection): self.initialize_database(connection) self.log.info("Initialization complete.") - with Session(connection) as session: - self.initialize_search_indexes(session) + self.initialize_search_indexes() def run(self) -> None: """ diff --git a/tests/api/admin/controller/test_custom_lists.py b/tests/api/admin/controller/test_custom_lists.py index eff19c8ce..724e431e6 100644 --- a/tests/api/admin/controller/test_custom_lists.py +++ b/tests/api/admin/controller/test_custom_lists.py @@ -449,7 +449,10 @@ def test_custom_list_get(self, admin_librarian_fixture: AdminLibrarianFixture): list.add_entry(work1) list.add_entry(work2) - with admin_librarian_fixture.request_context_with_library_and_admin("/"): + with ( + admin_librarian_fixture.request_context_with_library_and_admin("/"), + admin_librarian_fixture.ctrl.wired_container(), + ): assert isinstance(list.id, int) response = admin_librarian_fixture.manager.admin_custom_lists_controller.custom_list( list.id diff --git a/tests/api/admin/controller/test_feed.py b/tests/api/admin/controller/test_feed.py index 42f3d3f8b..29415667f 100644 --- a/tests/api/admin/controller/test_feed.py +++ b/tests/api/admin/controller/test_feed.py @@ -15,7 +15,10 @@ def test_suppressed(self, admin_librarian_fixture): unsuppressed_work = admin_librarian_fixture.ctrl.db.work() - with admin_librarian_fixture.request_context_with_library_and_admin("/"): + with ( + admin_librarian_fixture.request_context_with_library_and_admin("/"), + admin_librarian_fixture.ctrl.wired_container(), + ): response = ( admin_librarian_fixture.manager.admin_feed_controller.suppressed() ) diff --git a/tests/api/admin/controller/test_search_service_self_tests.py b/tests/api/admin/controller/test_search_service_self_tests.py deleted file mode 100644 index f32b61575..000000000 --- a/tests/api/admin/controller/test_search_service_self_tests.py +++ /dev/null @@ -1,93 +0,0 @@ -from api.admin.problem_details import * -from core.model import ExternalIntegration, create -from core.selftest import HasSelfTests - - -class TestSearchServiceSelfTests: - def test_search_service_self_tests_with_no_identifier(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - None - ) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_search_service_self_tests_with_no_search_service_found( - self, settings_ctrl_fixture - ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - -1 - ) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_search_service_self_tests_test_get(self, settings_ctrl_fixture): - old_prior_test_results = HasSelfTests.prior_test_results - HasSelfTests.prior_test_results = settings_ctrl_fixture.mock_prior_test_results - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - search_service.id - ) - response_search_service = response.get("self_test_results") - - assert response_search_service.get("id") == search_service.id - assert response_search_service.get("name") == search_service.name - assert ( - response_search_service.get("protocol").get("label") - == search_service.protocol - ) - assert response_search_service.get("goal") == search_service.goal - assert ( - response_search_service.get("self_test_results") - == HasSelfTests.prior_test_results() - ) - - HasSelfTests.prior_test_results = old_prior_test_results - - def test_search_service_self_tests_post(self, settings_ctrl_fixture): - old_run_self_tests = HasSelfTests.run_self_tests - HasSelfTests.run_self_tests = settings_ctrl_fixture.mock_run_self_tests - - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - m = ( - settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.self_tests_process_post - ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - response = m(search_service.id) - assert response._status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - positional, keyword = settings_ctrl_fixture.run_self_tests_called_with - # run_self_tests was called with positional arguments: - # * The database connection - # * The method to call to instantiate a HasSelfTests implementation - # (None -- this means to use the default ExternalSearchIndex - # constructor.) - # * The database connection again (to be passed into - # the ExternalSearchIndex constructor). - assert ( - settings_ctrl_fixture.ctrl.db.session, - None, - settings_ctrl_fixture.ctrl.db.session, - ) == positional - - # run_self_tests was not called with any keyword arguments. - assert {} == keyword - - # Undo the mock. - HasSelfTests.run_self_tests = old_run_self_tests diff --git a/tests/api/admin/controller/test_search_services.py b/tests/api/admin/controller/test_search_services.py deleted file mode 100644 index ebd470ea0..000000000 --- a/tests/api/admin/controller/test_search_services.py +++ /dev/null @@ -1,301 +0,0 @@ -import flask -import pytest -from werkzeug.datastructures import MultiDict - -from api.admin.exceptions import AdminNotAuthorized -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - INTEGRATION_NAME_ALREADY_IN_USE, - MISSING_SERVICE, - MULTIPLE_SITEWIDE_SERVICES, - NO_PROTOCOL_FOR_NEW_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.external_search import ExternalSearchIndex -from core.model import AdminRole, ExternalIntegration, create, get_one - - -class TestSearchServices: - def test_search_services_get_with_no_services(self, settings_ctrl_fixture): - # Delete the search integration - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.get("search_services") == [] - protocols = response.get("protocols") - assert ExternalIntegration.OPENSEARCH in [p.get("name") for p in protocols] - assert "settings" in protocols[0] - - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() - pytest.raises( - AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_search_services_controller.process_services, - ) - - def test_search_services_get_with_one_service(self, settings_ctrl_fixture): - # Delete the pre-existing integration - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - search_service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - search_service.setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ).value = "search-term-for-self-tests" - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - [service] = response.get("search_services") - - assert search_service.id == service.get("id") - assert search_service.protocol == service.get("protocol") - assert "search url" == service.get("settings").get(ExternalIntegration.URL) - assert "works-index-prefix" == service.get("settings").get( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ) - assert "search-term-for-self-tests" == service.get("settings").get( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ) - - def test_search_services_post_errors(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_search_services_controller - - # Delete the previous integrations - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", "Unknown"), - ] - ) - response = controller.process_services() - assert response == UNKNOWN_PROTOCOL - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([("name", "Name")]) - response = controller.process_services() - assert response == NO_PROTOCOL_FOR_NEW_SERVICE - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", "123"), - ] - ) - response = controller.process_services() - assert response == MISSING_SERVICE - - service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response.uri == MULTIPLE_SITEWIDE_SERVICES.uri - - session.delete(service) - service, ignore = create( - session, - ExternalIntegration, - protocol="test", - goal="test", - name="name", - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", service.name), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response == INTEGRATION_NAME_ALREADY_IN_USE - - service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response.uri == INCOMPLETE_CONFIGURATION.uri - - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "search url"), - (ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "works-index-prefix"), - ] - ) - pytest.raises(AdminNotAuthorized, controller.process_services) - - def test_search_services_post_create(self, settings_ctrl_fixture): - # Delete the previous integrations - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "http://search_url"), - (ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "works-index-prefix"), - (ExternalSearchIndex.TEST_SEARCH_TERM_KEY, "sample-search-term"), - ] - ) - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.status_code == 201 - - service = get_one( - session, - ExternalIntegration, - goal=ExternalIntegration.SEARCH_GOAL, - ) - assert service.id == int(response.response[0]) - assert ExternalIntegration.OPENSEARCH == service.protocol - assert "http://search_url" == service.url - assert ( - "works-index-prefix" - == service.setting(ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY).value - ) - assert ( - "sample-search-term" - == service.setting(ExternalSearchIndex.TEST_SEARCH_TERM_KEY).value - ) - - def test_search_services_post_edit(self, settings_ctrl_fixture): - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - search_service.setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ).value = "sample-search-term" - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", search_service.id), - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "http://new_search_url"), - ( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, - "new-works-index-prefix", - ), - ( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY, - "new-sample-search-term", - ), - ] - ) - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.status_code == 200 - - assert search_service.id == int(response.response[0]) - assert ExternalIntegration.OPENSEARCH == search_service.protocol - assert "http://new_search_url" == search_service.url - assert ( - "new-works-index-prefix" - == search_service.setting(ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY).value - ) - assert ( - "new-sample-search-term" - == search_service.setting(ExternalSearchIndex.TEST_SEARCH_TERM_KEY).value - ) - - def test_search_service_delete(self, settings_ctrl_fixture): - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - pytest.raises( - AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_search_services_controller.process_delete, - search_service.id, - ) - - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_search_services_controller.process_delete( - search_service.id - ) - assert response.status_code == 200 - - service = get_one( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - id=search_service.id, - ) - assert None == service diff --git a/tests/api/admin/controller/test_sitewide_services.py b/tests/api/admin/controller/test_sitewide_services.py deleted file mode 100644 index dfa3f8432..000000000 --- a/tests/api/admin/controller/test_sitewide_services.py +++ /dev/null @@ -1,34 +0,0 @@ -from api.admin.controller.sitewide_services import * -from core.model import ExternalIntegration - - -class TestSitewideServices: - def test_sitewide_service_management(self, settings_ctrl_fixture): - # The configuration of search and logging collections is delegated to - # the _manage_sitewide_service and _delete_integration methods. - # - # Search collections are more comprehensively tested in test_search_services. - - EI = ExternalIntegration - - class MockSearch(SearchServicesController): - def _manage_sitewide_service(self, *args): - self.manage_called_with = args - - def _delete_integration(self, *args): - self.delete_called_with = args - - controller = MockSearch(settings_ctrl_fixture.manager) - - with settings_ctrl_fixture.request_context_with_admin("/"): - controller.process_services() - goal, apis, key_name, problem = controller.manage_called_with - assert EI.SEARCH_GOAL == goal - assert ExternalSearchIndex in apis - assert "search_services" == key_name - assert "new search service" in problem - - with settings_ctrl_fixture.request_context_with_admin("/"): - id = object() - controller.process_delete(id) - assert (id, EI.SEARCH_GOAL) == controller.delete_called_with diff --git a/tests/api/admin/controller/test_work_editor.py b/tests/api/admin/controller/test_work_editor.py index fbe7b8c62..4c6605a36 100644 --- a/tests/api/admin/controller/test_work_editor.py +++ b/tests/api/admin/controller/test_work_editor.py @@ -1,4 +1,5 @@ import json +from collections.abc import Generator import feedparser import flask @@ -64,8 +65,12 @@ def __init__(self, controller_fixture: ControllerFixture): @pytest.fixture(scope="function") -def work_fixture(controller_fixture: ControllerFixture) -> WorkFixture: - return WorkFixture(controller_fixture) +def work_fixture( + controller_fixture: ControllerFixture, +) -> Generator[WorkFixture, None, None]: + fixture = WorkFixture(controller_fixture) + with fixture.ctrl.wired_container(): + yield fixture class TestWorkController: diff --git a/tests/api/admin/test_routes.py b/tests/api/admin/test_routes.py index 4ce6f8ac3..6ffeb047d 100644 --- a/tests/api/admin/test_routes.py +++ b/tests/api/admin/test_routes.py @@ -2,6 +2,7 @@ from collections.abc import Generator from pathlib import Path from typing import Any +from unittest.mock import MagicMock import flask import pytest @@ -80,7 +81,7 @@ def __init__( self.controller_fixture = controller_fixture self.setup_circulation_manager = False if not self.REAL_CIRCULATION_MANAGER: - circ_manager = MockCirculationManager(self.db.session) + circ_manager = MockCirculationManager(self.db.session, MagicMock()) setup_admin_controllers(circ_manager) self.REAL_CIRCULATION_MANAGER = circ_manager @@ -620,45 +621,6 @@ def test_process_delete(self, fixture: AdminRouteFixture): fixture.assert_supported_methods(url, "DELETE") -class TestAdminSearchServices: - CONTROLLER_NAME = "admin_search_services_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_process_services(self, fixture: AdminRouteFixture): - url = "/admin/search_services" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_services # type: ignore - ) - fixture.assert_supported_methods(url, "GET", "POST") - - def test_process_delete(self, fixture: AdminRouteFixture): - url = "/admin/search_service/" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_delete, "", http_method="DELETE" # type: ignore - ) - fixture.assert_supported_methods(url, "DELETE") - - -class TestAdminSearchServicesSelfTests: - CONTROLLER_NAME = "admin_search_service_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_process_search_service_self_tests(self, fixture: AdminRouteFixture): - url = "/admin/search_service_self_tests/" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_search_service_self_tests, "" # type: ignore - ) - fixture.assert_supported_methods(url, "GET", "POST") - - class TestAdminCatalogServices: CONTROLLER_NAME = "admin_catalog_services_controller" diff --git a/tests/api/conftest.py b/tests/api/conftest.py index d24165c8e..10c6427c0 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -25,7 +25,6 @@ "tests.fixtures.api_overdrive_files", "tests.fixtures.api_routes", "tests.fixtures.authenticator", - "tests.fixtures.container", "tests.fixtures.csv_files", "tests.fixtures.database", "tests.fixtures.files", @@ -36,6 +35,7 @@ "tests.fixtures.opds_files", "tests.fixtures.sample_covers", "tests.fixtures.search", + "tests.fixtures.services", "tests.fixtures.time", "tests.fixtures.tls_server", "tests.fixtures.vendor_id", diff --git a/tests/api/controller/test_crawlfeed.py b/tests/api/controller/test_crawlfeed.py index a868ac55e..2345196ca 100644 --- a/tests/api/controller/test_crawlfeed.py +++ b/tests/api/controller/test_crawlfeed.py @@ -235,11 +235,6 @@ def works(self, _db, facets, pagination, *args, **kwargs): assert INVALID_INPUT.uri == response.uri assert None == self.page_called_with - # Bad search engine -> problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds._crawlable_feed(**in_kwargs) - ) - # Good pagination data -> feed_class.page() is called. sort_key = ["sort", "pagination", "key"] with circulation_fixture.request_context_with_library( @@ -297,7 +292,10 @@ def works(self, _db, facets, pagination, *args, **kwargs): # Finally, remove the mock feed class and verify that a real OPDS # feed is generated from the result of MockLane.works() del in_kwargs["feed_class"] - with circulation_fixture.request_context_with_library("/"): + with ( + circulation_fixture.request_context_with_library("/"), + circulation_fixture.wired_container(), + ): response = circulation_fixture.manager.opds_feeds._crawlable_feed( **in_kwargs ) diff --git a/tests/api/controller/test_loan.py b/tests/api/controller/test_loan.py index 110cab527..e71395326 100644 --- a/tests/api/controller/test_loan.py +++ b/tests/api/controller/test_loan.py @@ -1,5 +1,6 @@ import datetime import urllib.parse +from collections.abc import Generator from decimal import Decimal from unittest.mock import MagicMock, patch @@ -84,8 +85,10 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") -def loan_fixture(db: DatabaseTransactionFixture): - return LoanFixture(db) +def loan_fixture(db: DatabaseTransactionFixture) -> Generator[LoanFixture, None, None]: + fixture = LoanFixture(db) + with fixture.wired_container(): + yield fixture class TestLoanController: diff --git a/tests/api/controller/test_opds_feed.py b/tests/api/controller/test_opds_feed.py index 4ed4885b8..499f3554c 100644 --- a/tests/api/controller/test_opds_feed.py +++ b/tests/api/controller/test_opds_feed.py @@ -6,9 +6,7 @@ import feedparser from flask import url_for -from api.circulation_manager import CirculationManager from api.lanes import HasSeriesFacets, JackpotFacets, JackpotWorkList -from api.problem_details import REMOTE_INTEGRATION_FAILED from core.app_server import load_facets_from_request from core.entrypoint import AudiobooksEntryPoint, EverythingEntryPoint from core.external_search import SortKeyPagination @@ -78,11 +76,6 @@ def test_feed( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.feed(lane_id) - ) - # Now let's make a real feed. # Set up configuration settings for links and entry points @@ -94,8 +87,11 @@ def test_feed( settings.about = "d" # type: ignore[assignment] # Make a real OPDS feed and poke at it. - with circulation_fixture.request_context_with_library( - "/?entrypoint=Book&size=10" + with ( + circulation_fixture.request_context_with_library( + "/?entrypoint=Book&size=10" + ), + circulation_fixture.wired_container(), ): response = circulation_fixture.manager.opds_feeds.feed( circulation_fixture.english_adult_fiction.id @@ -276,11 +272,6 @@ def test_groups( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.groups(None) - ) - # A grouped feed has no pagination, and the FeaturedFacets # constructor never raises an exception. So we don't need to # test for those error conditions. @@ -491,11 +482,6 @@ def test_search( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.search(None) - ) - # Loading the SearchFacets object from a request can't return # a problem detail, so we can't test that case. @@ -666,28 +652,6 @@ def test_lane_search_params( == response.detail ) - def test_misconfigured_search( - self, circulation_fixture: CirculationControllerFixture - ): - circulation_fixture.add_works(self._EXTRA_BOOKS) - - class BadSearch(CirculationManager): - @property - def setup_search(self): - raise Exception("doomed!") - - circulation = BadSearch(circulation_fixture.db.session) - - # An attempt to call FeedController.search() will return a - # problem detail. - with circulation_fixture.request_context_with_library("/?q=t"): - problem = circulation.opds_feeds.search(None) - assert REMOTE_INTEGRATION_FAILED.uri == problem.uri - assert ( - "The search index for this site is not properly configured." - == problem.detail - ) - def test__qa_feed(self, circulation_fixture: CirculationControllerFixture): circulation_fixture.add_works(self._EXTRA_BOOKS) @@ -702,11 +666,6 @@ def test__qa_feed(self, circulation_fixture: CirculationControllerFixture): m = circulation_fixture.manager.opds_feeds._qa_feed args = (feed_method, "QA test feed", "qa_feed", Facets, worklist_factory) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: m(*args) - ) - # Bad faceting information -> Problem detail with circulation_fixture.request_context_with_library("/?order=nosuchorder"): response = m(*args) diff --git a/tests/api/controller/test_work.py b/tests/api/controller/test_work.py index 70796b177..68ff6ac68 100644 --- a/tests/api/controller/test_work.py +++ b/tests/api/controller/test_work.py @@ -1,6 +1,7 @@ import datetime import json import urllib.parse +from collections.abc import Generator from typing import Any from unittest.mock import MagicMock @@ -22,7 +23,7 @@ from api.problem_details import NO_SUCH_LANE, NOT_FOUND_ON_REMOTE from core.classifier import Classifier from core.entrypoint import AudiobooksEntryPoint -from core.external_search import SortKeyPagination, mock_search_index +from core.external_search import SortKeyPagination from core.feed.acquisition import OPDSAcquisitionFeed from core.feed.annotator.circulation import LibraryAnnotator from core.feed.types import WorkEntry @@ -64,8 +65,10 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") -def work_fixture(db: DatabaseTransactionFixture): - return WorkFixture(db) +def work_fixture(db: DatabaseTransactionFixture) -> Generator[WorkFixture, None, None]: + fixture = WorkFixture(db) + with fixture.wired_container(): + yield fixture class TestWorkController: @@ -100,11 +103,6 @@ def test_contributor(self, work_fixture: WorkFixture): contributor = contributor.display_name - # Search index misconfiguration -> Problem detail - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.series(contributor, None, None) - ) - # Bad facet data -> ProblemDetail with work_fixture.request_context_with_library("/?order=nosuchorder"): response = m(contributor, None, None) @@ -187,7 +185,7 @@ def page(cls, **kwargs): kwargs = self.called_with # type: ignore assert work_fixture.db.session == kwargs.pop("_db") - assert work_fixture.manager._external_search == kwargs.pop("search_engine") + assert work_fixture.manager.external_search == kwargs.pop("search_engine") # The feed is named after the contributor the request asked # about. @@ -517,13 +515,6 @@ def test_recommendations(self, work_fixture: WorkFixture): ) assert 400 == response.status_code - # Or if the search index is misconfigured. - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.recommendations( - *args, **kwargs - ) - ) - # If no NoveList API is configured, the lane does not exist. with work_fixture.request_context_with_library("/"): response = work_fixture.manager.work_controller.recommendations(*args) @@ -666,13 +657,6 @@ def test_related_books(self, work_fixture: WorkFixture): # creation process -- an invalid entrypoint will simply be # ignored. - # Bad search index setup -> Problem detail - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.related( - identifier.type, identifier.identifier - ) - ) - # The mock search engine will return this Work for every # search. That means this book will show up as a 'same author' # recommendation, a 'same series' recommentation, and a @@ -699,13 +683,12 @@ def test_related_books(self, work_fixture: WorkFixture): mock_api.setup_method(metadata) # Now, ask for works related to work_fixture.english_1. - with mock_search_index(work_fixture.manager.external_search): - with work_fixture.request_context_with_library("/?entrypoint=Book"): - response = work_fixture.manager.work_controller.related( - work_fixture.identifier.type, - work_fixture.identifier.identifier, - novelist_api=mock_api, - ) + with work_fixture.request_context_with_library("/?entrypoint=Book"): + response = work_fixture.manager.work_controller.related( + work_fixture.identifier.type, + work_fixture.identifier.identifier, + novelist_api=mock_api, + ) assert 200 == response.status_code assert OPDSFeed.ACQUISITION_FEED_TYPE == response.headers["content-type"] feed = feedparser.parse(response.data) @@ -870,11 +853,6 @@ def test_series(self, work_fixture: WorkFixture): ) assert 400 == response.status_code - # Or if the search index isn't set up. - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.series(series_name, None, None) - ) - # Set up the mock search engine to return our work no matter # what query it's given. The fact that this book isn't # actually in the series doesn't matter, since determining diff --git a/tests/api/discovery/test_opds_registration.py b/tests/api/discovery/test_opds_registration.py index 60e67f23a..1cc079f0a 100644 --- a/tests/api/discovery/test_opds_registration.py +++ b/tests/api/discovery/test_opds_registration.py @@ -746,7 +746,7 @@ def process_library( # type: ignore[override] "--stage=testing", "--registry-url=http://registry.com/", ] - manager = MockCirculationManager(db.session) + manager = MockCirculationManager(db.session, MagicMock()) script.do_run(cmd_args=cmd_args, manager=manager) # One library was processed. diff --git a/tests/api/feed/fixtures.py b/tests/api/feed/conftest.py similarity index 100% rename from tests/api/feed/fixtures.py rename to tests/api/feed/conftest.py diff --git a/tests/api/feed/test_admin.py b/tests/api/feed/test_admin.py index cd2757880..1fa405a0c 100644 --- a/tests/api/feed/test_admin.py +++ b/tests/api/feed/test_admin.py @@ -5,8 +5,9 @@ from core.lane import Pagination from core.model.datasource import DataSource from core.model.measurement import Measurement -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.search import ExternalSearchFixtureFake class TestOPDS: @@ -20,7 +21,10 @@ def links(self, feed: FeedData, rel=None): return r def test_feed_includes_staff_rating( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -44,7 +48,10 @@ def test_feed_includes_staff_rating( assert Measurement.RATING == entry.computed.ratings[1].additionalType # type: ignore[attr-defined] def test_feed_includes_refresh_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -67,7 +74,10 @@ def test_feed_includes_refresh_link( ] def test_feed_includes_suppress_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -120,7 +130,10 @@ def test_feed_includes_suppress_link( assert 0 == len(suppress_links) def test_feed_includes_edit_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -137,7 +150,10 @@ def test_feed_includes_edit_link( assert edit_link.href and lp.identifier.identifier in edit_link.href def test_suppressed_feed( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): # Test the ability to show a paginated feed of suppressed works. diff --git a/tests/api/feed/test_annotators.py b/tests/api/feed/test_annotators.py index 3e000914c..b34ea6c4c 100644 --- a/tests/api/feed/test_annotators.py +++ b/tests/api/feed/test_annotators.py @@ -25,7 +25,7 @@ from core.model.resource import Hyperlink, Resource from core.model.work import Work from core.util.datetime_helpers import datetime_utc, utc_now -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import ( # noqa DatabaseTransactionFixture, DBStatementCounter, diff --git a/tests/api/feed/test_library_annotator.py b/tests/api/feed/test_library_annotator.py index f9b1dff34..ac16ecb55 100644 --- a/tests/api/feed/test_library_annotator.py +++ b/tests/api/feed/test_library_annotator.py @@ -43,9 +43,10 @@ from core.util.datetime_helpers import utc_now from core.util.flask_util import OPDSFeedResponse from core.util.opds_writer import OPDSFeed -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.library import LibraryFixture +from tests.fixtures.search import ExternalSearchFixtureFake from tests.fixtures.vendor_id import VendorIDFixture @@ -77,7 +78,9 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") def annotator_fixture( - db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ) -> LibraryAnnotatorFixture: return LibraryAnnotatorFixture(db) diff --git a/tests/api/feed/test_loan_and_hold_annotator.py b/tests/api/feed/test_loan_and_hold_annotator.py index 1af8c79ce..e57656a72 100644 --- a/tests/api/feed/test_loan_and_hold_annotator.py +++ b/tests/api/feed/test_loan_and_hold_annotator.py @@ -16,6 +16,7 @@ from core.model.licensing import LicensePool from core.model.patron import Loan from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.search import ExternalSearchFixtureFake class TestLibraryLoanAndHoldAnnotator: @@ -195,7 +196,11 @@ def test_choose_best_hold_for_work(self, db: DatabaseTransactionFixture): [hold_1, hold_2] ) - def test_annotate_work_entry(self, db: DatabaseTransactionFixture): + def test_annotate_work_entry( + self, + db: DatabaseTransactionFixture, + external_search_fake_fixture: ExternalSearchFixtureFake, + ): library = db.default_library() patron = db.patron() identifier = db.identifier() diff --git a/tests/api/feed/test_opds_acquisition_feed.py b/tests/api/feed/test_opds_acquisition_feed.py index a8231bb82..9ee466f8f 100644 --- a/tests/api/feed/test_opds_acquisition_feed.py +++ b/tests/api/feed/test_opds_acquisition_feed.py @@ -36,7 +36,7 @@ from core.util.datetime_helpers import utc_now from core.util.flask_util import OPDSEntryResponse, OPDSFeedResponse from core.util.opds_writer import OPDSFeed, OPDSMessage -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import DatabaseTransactionFixture @@ -996,7 +996,7 @@ class TestEntrypointLinkInsertionFixture: @pytest.fixture() def entrypoint_link_insertion_fixture( - db, + db: DatabaseTransactionFixture, ) -> Generator[TestEntrypointLinkInsertionFixture, None, None]: data = TestEntrypointLinkInsertionFixture() data.db = db @@ -1076,7 +1076,7 @@ def run(wl=None, facets=None): MockAnnotator(), None, facets, - search, + search_engine=search, ) return data.mock.called_with diff --git a/tests/api/mockapi/circulation.py b/tests/api/mockapi/circulation.py index eb693495d..6e3fffe79 100644 --- a/tests/api/mockapi/circulation.py +++ b/tests/api/mockapi/circulation.py @@ -11,11 +11,9 @@ PatronActivityCirculationAPI, ) from api.circulation_manager import CirculationManager -from core.external_search import ExternalSearchIndex from core.integration.settings import BaseSettings -from core.model import DataSource, Hold, Loan, get_one_or_create -from core.model.configuration import ExternalIntegration -from tests.mocks.search import ExternalSearchIndexFake +from core.model import DataSource, Hold, Loan +from core.service.container import Services class MockPatronActivityCirculationAPI(PatronActivityCirculationAPI, ABC): @@ -173,25 +171,8 @@ def api_for_license_pool(self, licensepool): class MockCirculationManager(CirculationManager): d_circulation: MockCirculationAPI - def __init__(self, db: Session): - super().__init__(db) - - def setup_search(self): - """Set up a search client.""" - integration, _ = get_one_or_create( - self._db, - ExternalIntegration, - goal=ExternalIntegration.SEARCH_GOAL, - protocol=ExternalIntegration.OPENSEARCH, - ) - integration.set_setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "test_index" - ) - integration.set_setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY, "a search term" - ) - integration.url = "http://does-not-exist.com/" - return ExternalSearchIndexFake(self._db) + def __init__(self, db: Session, services: Services): + super().__init__(db, services) def setup_circulation(self, library, analytics): """Set up the Circulation object.""" diff --git a/tests/api/test_controller_cm.py b/tests/api/test_controller_cm.py index 7be453e76..c4aa58176 100644 --- a/tests/api/test_controller_cm.py +++ b/tests/api/test_controller_cm.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock from api.authenticator import LibraryAuthenticator -from api.circulation_manager import CirculationManager from api.config import Configuration from api.custom_index import CustomIndexView from api.problem_details import * @@ -18,7 +17,6 @@ # TODO: we can drop this when we drop support for Python 3.6 and 3.7 from tests.fixtures.api_controller import CirculationControllerFixture from tests.fixtures.database import IntegrationConfigurationFixture -from tests.mocks.search import SearchServiceFake class TestCirculationManager: @@ -34,7 +32,6 @@ def test_load_settings( # Certain fields of the CirculationManager have certain values # which are about to be reloaded. - manager._external_search = object() manager.auth = object() manager.patron_web_domains = object() @@ -109,9 +106,6 @@ def mock_for_library(incoming_library): LibraryAuthenticator, ) - # The ExternalSearch object has been reset. - assert isinstance(manager.external_search.search_service(), SearchServiceFake) - # So have the patron web domains, and their paths have been # removed. assert {"http://sitewide", "http://registration"} == manager.patron_web_domains @@ -146,24 +140,6 @@ def mock_for_library(incoming_library): # Restore the CustomIndexView.for_library implementation CustomIndexView.for_library = old_for_library - def test_exception_during_external_search_initialization_is_stored( - self, circulation_fixture: CirculationControllerFixture - ): - class BadSearch(CirculationManager): - @property - def setup_search(self): - raise Exception("doomed!") - - circulation = BadSearch(circulation_fixture.db.session) - - # We didn't get a search object. - assert None == circulation.external_search - - # The reason why is stored here. - ex = circulation.external_search_initialization_exception - assert isinstance(ex, Exception) - assert "doomed!" == str(ex) - def test_annotator(self, circulation_fixture: CirculationControllerFixture): # Test our ability to find an appropriate OPDSAnnotator for # any request context. diff --git a/tests/api/test_lanes.py b/tests/api/test_lanes.py index f4379e50a..7beab88a5 100644 --- a/tests/api/test_lanes.py +++ b/tests/api/test_lanes.py @@ -1048,7 +1048,7 @@ def test_works( lane = CrawlableCollectionBasedLane() lane.initialize([db.default_collection()]) search = external_search_fake_fixture.external_search - search.query_works = MagicMock(return_value=[]) # type: ignore [method-assign] + search.query_works = MagicMock(return_value=[]) lane.works( db.session, facets=CrawlableFacets.default(None), search_engine=search ) diff --git a/tests/api/test_scripts.py b/tests/api/test_scripts.py index d172241f6..3d9f0367c 100644 --- a/tests/api/test_scripts.py +++ b/tests/api/test_scripts.py @@ -15,7 +15,6 @@ from api.adobe_vendor_id import AuthdataUtility from api.config import Configuration from api.novelist import NoveListAPI -from core.external_search import ExternalSearchIndex from core.integration.goals import Goals from core.marc import MARCExporter, MarcExporterLibrarySettings, MarcExporterSettings from core.model import ( @@ -41,7 +40,7 @@ NovelistSnapshotScript, ) from tests.fixtures.library import LibraryFixture -from tests.fixtures.search import EndToEndSearchFixture +from tests.fixtures.search import EndToEndSearchFixture, ExternalSearchFixtureFake if TYPE_CHECKING: from tests.fixtures.authenticator import SimpleAuthIntegrationFixture @@ -615,15 +614,11 @@ def test_initialize_database(self, db: DatabaseTransactionFixture): with patch( "scripts.SessionManager", autospec=SessionManager ) as session_manager: - with patch( - "scripts.ExternalSearchIndex", autospec=ExternalSearchIndex - ) as search_index: - with patch("scripts.command") as alemic_command: - script.initialize_database(mock_db) + with patch("scripts.command") as alemic_command: + script.initialize_database(mock_db) session_manager.initialize_data.assert_called_once() session_manager.initialize_schema.assert_called_once() - search_index.assert_called_once() alemic_command.stamp.assert_called_once() def test_migrate_database(self, db: DatabaseTransactionFixture): @@ -645,67 +640,59 @@ def test_find_alembic_ini(self, db: DatabaseTransactionFixture): assert conf.attributes["connection"] == mock_connection.engine assert conf.attributes["configure_logger"] is False - def test_initialize_search_indexes( - self, end_to_end_search_fixture: EndToEndSearchFixture + def test_initialize_search_indexes_mocked( + self, + external_search_fake_fixture: ExternalSearchFixtureFake, + caplog: LogCaptureFixture, ): - db = end_to_end_search_fixture.db - search = end_to_end_search_fixture.external_search_index - base_name = search._revision_base_name + caplog.set_level(logging.WARNING) + script = InstanceInitializationScript() - _mockable_search = ExternalSearchIndex(db.session) - _mockable_search.start_migration = MagicMock() # type: ignore [method-assign] - _mockable_search.search_service = MagicMock() # type: ignore [method-assign] - _mockable_search.log = MagicMock() + search_service = external_search_fake_fixture.external_search + search_service.start_migration = MagicMock() + search_service.search_service = MagicMock() - def mockable_search(*args): - return _mockable_search + # To fake "no migration is available", mock all the values + search_service.start_migration.return_value = None + search_service.search_service().is_pointer_empty.return_value = True - # Initially this should not exist, if InstanceInit has not been run - assert search.search_service().read_pointer() == None - - with patch("scripts.ExternalSearchIndex", new=mockable_search): - # To fake "no migration is available", mock all the values - - _mockable_search.start_migration.return_value = None - _mockable_search.search_service().is_pointer_empty.return_value = True - # Migration should fail - assert script.initialize_search_indexes(db.session) == False - # Logs were emitted - assert _mockable_search.log.warning.call_count == 1 - assert ( - "no migration was available" - in _mockable_search.log.warning.call_args[0][0] - ) + # Migration should fail + assert script.initialize_search_indexes() is False + + # Logs were emitted + record = caplog.records.pop() + assert "WARNING" in record.levelname + assert "no migration was available" in record.message - _mockable_search.search_service.reset_mock() - _mockable_search.start_migration.reset_mock() - _mockable_search.log.reset_mock() + search_service.search_service.reset_mock() + search_service.start_migration.reset_mock() - # In case there is no need for a migration, read pointer exists as a non-empty pointer - _mockable_search.search_service().is_pointer_empty.return_value = False - # Initialization should pass, as a no-op - assert script.initialize_search_indexes(db.session) == True - assert _mockable_search.start_migration.call_count == 0 + # In case there is no need for a migration, read pointer exists as a non-empty pointer + search_service.search_service().is_pointer_empty.return_value = False + + # Initialization should pass, as a no-op + assert script.initialize_search_indexes() is True + assert search_service.start_migration.call_count == 0 + + def test_initialize_search_indexes( + self, end_to_end_search_fixture: EndToEndSearchFixture + ): + search = end_to_end_search_fixture.external_search_index + base_name = end_to_end_search_fixture.external_search.service.base_revision_name + script = InstanceInitializationScript() + + # Initially this should not exist, if InstanceInit has not been run + assert search.search_service().read_pointer() is None # Initialization should work now - assert script.initialize_search_indexes(db.session) == True + assert script.initialize_search_indexes() is True # Then we have the latest version index assert ( search.search_service().read_pointer() == search._revision.name_for_index(base_name) ) - def test_initialize_search_indexes_no_integration( - self, db: DatabaseTransactionFixture - ): - script = InstanceInitializationScript() - script._log = MagicMock() - # No integration mean no migration - assert script.initialize_search_indexes(db.session) == False - assert script._log.error.call_count == 2 - assert "No search integration" in script._log.error.call_args[0][0] - class TestLanguageListScript: def test_languages(self, db: DatabaseTransactionFixture): diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 1a48ae45f..bb6feff57 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -3,7 +3,6 @@ pytest_plugins = [ "tests.fixtures.announcements", "tests.fixtures.csv_files", - "tests.fixtures.container", "tests.fixtures.database", "tests.fixtures.library", "tests.fixtures.opds2_files", diff --git a/tests/core/models/test_collection.py b/tests/core/models/test_collection.py index a33602ab9..a8f887a3d 100644 --- a/tests/core/models/test_collection.py +++ b/tests/core/models/test_collection.py @@ -19,6 +19,7 @@ from core.model.licensing import Hold, License, LicensePool, Loan from core.model.work import Work from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.services import ServicesFixture class ExampleCollectionFixture: @@ -551,7 +552,11 @@ def expect(qu, works): setting.value = json.dumps([DataSource.OVERDRIVE, DataSource.FEEDBOOKS]) expect(qu, [overdrive_ebook]) - def test_delete(self, example_collection_fixture: ExampleCollectionFixture): + def test_delete( + self, + example_collection_fixture: ExampleCollectionFixture, + services_fixture_wired: ServicesFixture, + ): """Verify that Collection.delete will only operate on collections flagged for deletion, and that deletion cascades to all relevant related database objects. @@ -649,13 +654,16 @@ def test_delete(self, example_collection_fixture: ExampleCollectionFixture): index.remove_work.assert_called_once_with(work) # If no search_index is passed into delete() (the default behavior), - # we try to instantiate the normal ExternalSearchIndex object. Since - # no search index is configured, this will raise an exception -- but - # delete() will catch the exception and carry out the delete, - # without trying to delete any Works from the search index. + # then it will use the search index injected from the services + # container. collection2.marked_for_deletion = True collection2.delete() + # The search index was injected and told to remove the second work. + services_fixture_wired.search_fixture.index_mock.remove_work.assert_called_once_with( + work2 + ) + # We've now deleted every LicensePool created for this test. assert 0 == db.session.query(LicensePool).count() assert [] == work2.license_pools diff --git a/tests/core/models/test_work.py b/tests/core/models/test_work.py index f2a12c271..f8408de5a 100644 --- a/tests/core/models/test_work.py +++ b/tests/core/models/test_work.py @@ -260,7 +260,7 @@ def test_calculate_presentation( assert (utc_now() - work.last_update_time) < datetime.timedelta(seconds=2) # type: ignore[unreachable] # The index has not been updated. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() # The Work now has a complete set of WorkCoverageRecords # associated with it, reflecting all the operations that @@ -480,7 +480,7 @@ def test_set_presentation_ready_based_on_content( assert True == work.presentation_ready # The work has not been added to the search index. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() # But the work of adding it to the search engine has been # registered. @@ -1606,7 +1606,7 @@ def mock_reset_coverage(operation): # The work was not added to the search index when we called # external_index_needs_updating. That happens later, when the # WorkCoverageRecord is processed. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() def test_for_unchecked_subjects(self, db: DatabaseTransactionFixture): w1 = db.work(with_license_pool=True) diff --git a/tests/core/search/test_migration_states.py b/tests/core/search/test_migration_states.py index aa3ba92c0..f9c6f0fe1 100644 --- a/tests/core/search/test_migration_states.py +++ b/tests/core/search/test_migration_states.py @@ -14,8 +14,9 @@ import pytest -from core.external_search import ExternalSearchIndex, SearchIndexCoverageProvider +from core.external_search import ExternalSearchIndex from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.document import SearchMappingDocument from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -29,20 +30,19 @@ def test_initial_migration_case( ): fx = external_search_fixture db = fx.db + index = fx.index + service = fx.service # Ensure we are in the initial state, no test indices and pointer available - prefix = fx.integration.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value - all_indices = fx.search.indices.get("*") + prefix = fx.search_config.index_prefix + all_indices = fx.client.indices.get("*") for index_name in all_indices.keys(): - assert prefix not in index_name - - client = ExternalSearchIndex(db.session) + if prefix in index_name: + fx.client.indices.delete(index_name) # We cannot make any requests before we intitialize with pytest.raises(Exception) as raised: - client.query_works("") + index.query_works("") assert "index_not_found" in str(raised.value) # When a new sytem comes up the first code to run is the InstanceInitailization script @@ -50,22 +50,22 @@ def test_initial_migration_case( InstanceInitializationScript().initialize(db.session.connection()) # Ensure we have created the index and pointers - new_index_name = client._revision.name_for_index(client._revision_base_name) - empty_index_name = client.search_service()._empty(client._revision_base_name) # type: ignore [attr-defined] - all_indices = fx.search.indices.get("*") + new_index_name = index._revision.name_for_index(prefix) + empty_index_name = service._empty(prefix) + all_indices = fx.client.indices.get("*") assert prefix in new_index_name assert new_index_name in all_indices.keys() assert empty_index_name in all_indices.keys() - assert fx.search.indices.exists_alias( - client._search_read_pointer, index=new_index_name + assert fx.client.indices.exists_alias( + index._search_read_pointer, index=new_index_name ) - assert fx.search.indices.exists_alias( - client._search_write_pointer, index=new_index_name + assert fx.client.indices.exists_alias( + index._search_write_pointer, index=new_index_name ) # The same client should work without issue once the pointers are setup - assert client.query_works("").hits == [] + assert index.query_works("").hits == [] def test_migration_case(self, external_search_fixture: ExternalSearchFixture): fx = external_search_fixture @@ -85,23 +85,24 @@ def mapping_document(self) -> SearchMappingDocument: return SearchMappingDocument() client = ExternalSearchIndex( - db.session, + fx.search_container.service(), revision_directory=SearchRevisionDirectory( {MOCK_VERSION: MockSchema(MOCK_VERSION)} ), ) + # The search client works just fine assert client.query_works("") is not None receiver = client.start_updating_search_documents() receiver.add_documents([{"work_id": 123}]) receiver.finish() - mock_index_name = client._revision.name_for_index(client._revision_base_name) + mock_index_name = client._revision.name_for_index(fx.service.base_revision_name) assert str(MOCK_VERSION) in mock_index_name # The mock index does not exist yet with pytest.raises(Exception) as raised: - fx.search.indices.get(mock_index_name) + fx.client.indices.get(mock_index_name) assert "index_not_found" in str(raised.value) # This should run the migration @@ -110,10 +111,10 @@ def mapping_document(self) -> SearchMappingDocument: ).run() # The new version is created, and the aliases point to the right index - assert fx.search.indices.get(mock_index_name) is not None - assert mock_index_name in fx.search.indices.get_alias( + assert fx.client.indices.get(mock_index_name) is not None + assert mock_index_name in fx.client.indices.get_alias( name=client._search_read_pointer ) - assert mock_index_name in fx.search.indices.get_alias( + assert mock_index_name in fx.client.indices.get_alias( name=client._search_write_pointer ) diff --git a/tests/core/search/test_service.py b/tests/core/search/test_service.py index 4192e5199..59c9ad6c7 100644 --- a/tests/core/search/test_service.py +++ b/tests/core/search/test_service.py @@ -30,7 +30,7 @@ def test_create_empty_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Creating the empty index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) service.create_empty_index() # Log the index so that the fixture cleans it up afterward. @@ -38,7 +38,7 @@ def test_create_empty_idempotent( service.create_empty_index() - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists("base-empty") @@ -46,7 +46,7 @@ def test_create_index_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Creating any index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) service.index_create(revision) @@ -54,23 +54,23 @@ def test_create_index_idempotent( # Log the index so that the fixture cleans it up afterward. external_search_fixture.record_index("base-v23") - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists(revision.name_for_index("base")) def test_read_pointer_none(self, external_search_fixture: ExternalSearchFixture): """The read pointer is initially unset.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) assert None == service.read_pointer() def test_write_pointer_none(self, external_search_fixture: ExternalSearchFixture): """The write pointer is initially unset.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) assert None == service.write_pointer() def test_read_pointer_set(self, external_search_fixture: ExternalSearchFixture): """Setting the read pointer works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) @@ -84,7 +84,7 @@ def test_read_pointer_set_empty( self, external_search_fixture: ExternalSearchFixture ): """Setting the read pointer to the empty index works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) service.create_empty_index() # Log the index so that the fixture cleans it up afterward. @@ -95,7 +95,7 @@ def test_read_pointer_set_empty( def test_write_pointer_set(self, external_search_fixture: ExternalSearchFixture): """Setting the write pointer works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) @@ -112,7 +112,7 @@ def test_populate_index_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Populating an index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) mappings = revision.mapping_document() @@ -150,7 +150,7 @@ def test_populate_index_idempotent( service.index_submit_documents("base-v23", documents) service.index_submit_documents("base-v23", documents) - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists(revision.name_for_index("base")) assert indices.get(revision.name_for_index("base"))["base-v23"]["mappings"] == { diff --git a/tests/core/test_external_search.py b/tests/core/test_external_search.py index b92d1b17a..dcc8972f2 100644 --- a/tests/core/test_external_search.py +++ b/tests/core/test_external_search.py @@ -34,7 +34,6 @@ QueryParseException, QueryParser, SearchBase, - SearchIndexCoverageProvider, SortKeyPagination, WorkSearchResult, ) @@ -56,6 +55,7 @@ from core.model.work import Work from core.problem_details import INVALID_INPUT from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.document import SearchMappingDocument from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -132,85 +132,6 @@ def query_works_multi(self, queries, debug=False): assert pagination.offset == default.offset assert pagination.size == default.size - def test__run_self_tests(self, end_to_end_search_fixture: EndToEndSearchFixture): - transaction = end_to_end_search_fixture.db - session = transaction.session - index = end_to_end_search_fixture.external_search_index - - # Intrusively set the search term to something useful. - index._test_search_term = "How To Search" - - # Start with an up-to-date but empty index. - index.start_migration().finish() - - # First, see what happens when the search returns no results. - test_results = [x for x in index._run_self_tests(session)] - - assert "Search results for 'How To Search':" == test_results[0].name - assert True == test_results[0].success - assert [] == test_results[0].result - - assert "Search document for 'How To Search':" == test_results[1].name - assert True == test_results[1].success - assert {} != test_results[1].result - - assert "Raw search results for 'How To Search':" == test_results[2].name - assert True == test_results[2].success - assert [] == test_results[2].result - - assert ( - "Total number of search results for 'How To Search':" - == test_results[3].name - ) - assert True == test_results[3].success - assert "0" == test_results[3].result - - assert "Total number of documents in this search index:" == test_results[4].name - assert True == test_results[4].success - assert "0" == test_results[4].result - - assert "Total number of documents per collection:" == test_results[5].name - assert True == test_results[5].success - assert "{}" == test_results[5].result - - # Set up the search index so it will return a result. - work = end_to_end_search_fixture.external_search.default_work( - title="How To Search" - ) - work.presentation_ready = True - work.presentation_edition.subtitle = "How To Search" - work.presentation_edition.series = "Classics" - work.summary_text = "How To Search!" - work.presentation_edition.publisher = "Project Gutenberg" - work.last_update_time = datetime_utc(2019, 1, 1) - work.license_pools[0].licenses_available = 100000 - - docs = index.start_updating_search_documents() - docs.add_documents(index.create_search_documents_from_works([work])) - docs.finish() - - test_results = [x for x in index._run_self_tests(session)] - - assert "Search results for 'How To Search':" == test_results[0].name - assert True == test_results[0].success - assert [f"How To Search ({work.author})"] == test_results[0].result - - assert ( - "Total number of search results for 'How To Search':" - == test_results[3].name - ) - assert True == test_results[3].success - assert "1" == test_results[3].result - - assert "Total number of documents in this search index:" == test_results[4].name - assert True == test_results[4].success - assert "1" == test_results[4].result - - assert "Total number of documents per collection:" == test_results[5].name - assert True == test_results[5].success - result = json.loads(test_results[5].result) - assert {"Default Collection": 1} == result - class TestSearchV5: def test_character_filters(self): @@ -978,7 +899,10 @@ def pages(worklist): while pagination: pages.append( worklist.works( - session, facets, pagination, fixture.external_search_index + session, + facets, + pagination, + search_engine=fixture.external_search_index, ) ) pagination = pagination.next_page @@ -1106,14 +1030,14 @@ def pages(worklist): expect([data.lincoln_vampire], "fantasy") def test_remove_work(self, end_to_end_search_fixture: EndToEndSearchFixture): - search = end_to_end_search_fixture.external_search.search + client = end_to_end_search_fixture.external_search.client data = self._populate_works(end_to_end_search_fixture) end_to_end_search_fixture.populate_search_index() end_to_end_search_fixture.external_search_index.remove_work(data.moby_dick) end_to_end_search_fixture.external_search_index.remove_work(data.moby_duck) # Immediately querying never works, the search index needs to refresh its cache/index/data - search.indices.refresh() + client.indices.refresh() end_to_end_search_fixture.expect_results([], "Moby") @@ -2113,7 +2037,11 @@ def assert_featured(description, worklist, facets, expect): # available books will show up before all of the unavailable # books. only_availability_matters = worklist.works( - session, facets, None, fixture.external_search_index, debug=True + session, + facets, + None, + search_engine=fixture.external_search_index, + debug=True, ) assert 5 == len(only_availability_matters) last_two = only_availability_matters[-2:] @@ -4723,7 +4651,9 @@ def test_works_not_presentation_ready_kept_in_index( # All three works were inserted into the index, even the one # that's not presentation-ready. ids = set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) assert {w1.id, w2.id, w3.id} == ids @@ -4736,7 +4666,9 @@ def test_works_not_presentation_ready_kept_in_index( ) docs.finish() assert {w1.id, w2.id, w3.id} == set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) assert [] == failures @@ -4750,7 +4682,7 @@ def test_search_connection_timeout( external_search_fake_fixture.db, ) - search.search.set_failing_mode( + search.service.set_failing_mode( mode=SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS_TIMEOUT ) work = transaction.work() @@ -4766,7 +4698,7 @@ def test_search_connection_timeout( # Submissions are not retried by the base service assert [work.id] == [ - docs["_id"] for docs in search.search.document_submission_attempts + docs["_id"] for docs in search.service.document_submission_attempts ] def test_search_single_document_error( @@ -4777,7 +4709,7 @@ def test_search_single_document_error( external_search_fake_fixture.db, ) - search.search.set_failing_mode( + search.service.set_failing_mode( mode=SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS ) work = transaction.work() @@ -4793,7 +4725,7 @@ def test_search_single_document_error( # Submissions are not retried by the base service assert [work.id] == [ - docs["_id"] for docs in search.search.document_submission_attempts + docs["_id"] for docs in search.service.document_submission_attempts ] @@ -4942,7 +4874,7 @@ def test_success( assert [work] == results # The work was added to the search index. - search_service = external_search_fake_fixture.search + search_service = external_search_fake_fixture.service assert 1 == len(search_service.documents_all()) def test_failure( @@ -4953,7 +4885,7 @@ def test_failure( work = db.work() work.set_presentation_ready() index = external_search_fake_fixture.external_search - external_search_fake_fixture.search.set_failing_mode( + external_search_fake_fixture.service.set_failing_mode( SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS ) diff --git a/tests/core/test_lane.py b/tests/core/test_lane.py index 2676883d2..f3959ff4c 100644 --- a/tests/core/test_lane.py +++ b/tests/core/test_lane.py @@ -1,6 +1,7 @@ import datetime import logging import random +from typing import cast from unittest.mock import MagicMock, call import pytest @@ -15,7 +16,7 @@ EntryPoint, EverythingEntryPoint, ) -from core.external_search import Filter, WorkSearchResult, mock_search_index +from core.external_search import ExternalSearchIndex, Filter, WorkSearchResult from core.lane import ( DatabaseBackedFacets, DatabaseBackedWorkList, @@ -2341,7 +2342,11 @@ def works_for_hits(self, _db, work_ids, facets=None): # Ask the WorkList for a page of works, using the search index # to drive the query instead of the database. result = wl.works( - db.session, facets, mock_pagination, search_client, mock_debug + db.session, + facets, + mock_pagination, + search_engine=cast(ExternalSearchIndex, search_client), + debug=mock_debug, ) # MockSearchClient.query_works was used to grab a list of work @@ -3551,8 +3556,7 @@ def count_works(self, filter): fiction = db.lane(display_name="Fiction", fiction=True) fiction.size = 44 fiction.size_by_entrypoint = {"Nonexistent entrypoint": 33} - with mock_search_index(search_engine): - fiction.update_size(db.session) + fiction.update_size(db.session, search_engine=search_engine) # The lane size is also calculated individually for every # enabled entry point. EverythingEntryPoint is used for the @@ -4222,7 +4226,6 @@ def test_groups( fixture.external_search.db, fixture.external_search.db.session, ) - fixture.external_search_index.start_migration().finish() # type: ignore [union-attr] # Tell the fixture to call our populate_works method. # In this library, the groups feed includes at most two books diff --git a/tests/core/test_local_analytics_provider.py b/tests/core/test_local_analytics_provider.py index 7b54da8f5..0ac174b27 100644 --- a/tests/core/test_local_analytics_provider.py +++ b/tests/core/test_local_analytics_provider.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: from tests.fixtures.database import DatabaseTransactionFixture - from tests.fixtures.services import MockServicesFixture class LocalAnalyticsProviderFixture: @@ -21,18 +20,16 @@ class LocalAnalyticsProviderFixture: def __init__( self, transaction: DatabaseTransactionFixture, - mock_services_fixture: MockServicesFixture, ): self.transaction = transaction - self.services = mock_services_fixture.services self.la = LocalAnalyticsProvider() @pytest.fixture() def local_analytics_provider_fixture( - db: DatabaseTransactionFixture, mock_services_fixture: MockServicesFixture + db: DatabaseTransactionFixture, ) -> LocalAnalyticsProviderFixture: - return LocalAnalyticsProviderFixture(db, mock_services_fixture) + return LocalAnalyticsProviderFixture(db) class TestLocalAnalyticsProvider: diff --git a/tests/core/test_s3_analytics_provider.py b/tests/core/test_s3_analytics_provider.py index bcb64444d..f95725918 100644 --- a/tests/core/test_s3_analytics_provider.py +++ b/tests/core/test_s3_analytics_provider.py @@ -3,7 +3,7 @@ import datetime import json from typing import TYPE_CHECKING -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import pytest @@ -11,29 +11,25 @@ from core.classifier import Classifier from core.config import CannotLoadConfiguration from core.model import CirculationEvent, DataSource, MediaTypes +from core.service.storage.s3 import S3Service if TYPE_CHECKING: from tests.fixtures.database import DatabaseTransactionFixture - from tests.fixtures.services import MockServicesFixture class S3AnalyticsFixture: - def __init__( - self, db: DatabaseTransactionFixture, services_fixture: MockServicesFixture - ) -> None: + def __init__(self, db: DatabaseTransactionFixture) -> None: self.db = db - self.services = services_fixture.services - self.analytics_storage = services_fixture.storage.analytics + + self.analytics_storage = create_autospec(S3Service) self.analytics_provider = S3AnalyticsProvider( - services_fixture.services.storage.analytics(), + self.analytics_storage, ) @pytest.fixture(scope="function") -def s3_analytics_fixture( - db: DatabaseTransactionFixture, mock_services_fixture: MockServicesFixture -): - return S3AnalyticsFixture(db, mock_services_fixture) +def s3_analytics_fixture(db: DatabaseTransactionFixture): + return S3AnalyticsFixture(db) class TestS3AnalyticsProvider: @@ -52,13 +48,8 @@ def timestamp_to_string(timestamp): def test_exception_is_raised_when_no_analytics_bucket_configured( self, s3_analytics_fixture: S3AnalyticsFixture ): - # The services container returns None when there is no analytics storage service configured, - # so we override the analytics storage service with None to simulate this situation. - s3_analytics_fixture.services.storage.analytics.override(None) - - provider = S3AnalyticsProvider( - s3_analytics_fixture.services.storage.analytics() - ) + # The services container returns None when there is no analytics storage service configured + provider = S3AnalyticsProvider(None) # Act, Assert with pytest.raises(CannotLoadConfiguration): diff --git a/tests/core/test_scripts.py b/tests/core/test_scripts.py index 18344d44b..10213984c 100644 --- a/tests/core/test_scripts.py +++ b/tests/core/test_scripts.py @@ -13,7 +13,7 @@ from api.lanes import create_default_lanes from core.classifier import Classifier -from core.config import CannotLoadConfiguration, Configuration, ConfigurationConstants +from core.config import Configuration, ConfigurationConstants from core.external_search import ExternalSearchIndex, Filter from core.lane import Lane, WorkList from core.metadata_layer import TimestampData @@ -90,6 +90,7 @@ ) from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.search import EndToEndSearchFixture, ExternalSearchFixtureFake +from tests.fixtures.services import ServicesFixture class TestScript: @@ -1608,23 +1609,6 @@ def out(self, s, *args): class TestWhereAreMyBooksScript: - def test_no_search_integration(self, db: DatabaseTransactionFixture): - # We can't even get started without a working search integration. - - # We'll also test the out() method by mocking the script's - # standard output and using the normal out() implementation. - # In other tests, which have more complicated output, we mock - # out(), so this verifies that output actually gets written - # out. - output = StringIO() - pytest.raises( - CannotLoadConfiguration, WhereAreMyBooksScript, db.session, output=output - ) - assert ( - "Here's your problem: the search integration is missing or misconfigured.\n" - == output.getvalue() - ) - @pytest.mark.skip( reason="This test currently freezes inside pytest and has to be killed with SIGKILL." ) @@ -2018,7 +2002,9 @@ def test_do_run( # The mock methods were called with the values we expect. assert {work.id, work2.id} == set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) # The script returned a list containing a single @@ -2286,38 +2272,33 @@ def test_process_custom_list( assert custom_list.auto_update_last_update == frozen_time.time_to_freeze assert custom_list1.auto_update_last_update == frozen_time.time_to_freeze - def test_search_facets(self, end_to_end_search_fixture: EndToEndSearchFixture): - with patch("core.query.customlist.ExternalSearchIndex") as mock_index: - fixture = end_to_end_search_fixture - db, session = ( - fixture.external_search.db, - fixture.external_search.db.session, - ) - data = self._populate_works(fixture) - fixture.populate_search_index() + def test_search_facets( + self, db: DatabaseTransactionFixture, services_fixture_wired: ServicesFixture + ): + mock_index = services_fixture_wired.search_fixture.index_mock - last_updated = datetime.datetime.now() - datetime.timedelta(hours=1) - custom_list, _ = db.customlist() - custom_list.library = db.default_library() - custom_list.auto_update_enabled = True - custom_list.auto_update_query = json.dumps( - dict(query=dict(key="title", value="Populated Book")) - ) - custom_list.auto_update_facets = json.dumps( - dict(order="title", languages="fr", media=["book", "audio"]) - ) - custom_list.auto_update_last_update = last_updated + last_updated = datetime.datetime.now() - datetime.timedelta(hours=1) + custom_list, _ = db.customlist() + custom_list.library = db.default_library() + custom_list.auto_update_enabled = True + custom_list.auto_update_query = json.dumps( + dict(query=dict(key="title", value="Populated Book")) + ) + custom_list.auto_update_facets = json.dumps( + dict(order="title", languages="fr", media=["book", "audio"]) + ) + custom_list.auto_update_last_update = last_updated - script = CustomListUpdateEntriesScript(session) - script.process_custom_list(custom_list) + script = CustomListUpdateEntriesScript(db.session) + script.process_custom_list(custom_list) - assert mock_index().query_works.call_count == 1 - filter: Filter = mock_index().query_works.call_args_list[0][0][1] - assert filter.sort_order[0] == { - "sort_title": "asc" - } # since we asked for title ordering this should come up first - assert filter.languages == ["fr"] - assert filter.media == ["book", "audio"] + assert mock_index.query_works.call_count == 1 + filter: Filter = mock_index.query_works.call_args_list[0][0][1] + assert filter.sort_order[0] == { + "sort_title": "asc" + } # since we asked for title ordering this should come up first + assert filter.languages == ["fr"] + assert filter.media == ["book", "audio"] @freeze_time("2022-01-01", as_kwarg="frozen_time") def test_no_last_update( diff --git a/tests/fixtures/api_controller.py b/tests/fixtures/api_controller.py index ae4d01ba3..0187413c9 100644 --- a/tests/fixtures/api_controller.py +++ b/tests/fixtures/api_controller.py @@ -37,9 +37,11 @@ IntegrationConfiguration, IntegrationLibraryConfiguration, ) +from core.service.container import Services, wire_container from core.util import base64 from tests.api.mockapi.circulation import MockCirculationManager from tests.fixtures.database import DatabaseTransactionFixture +from tests.mocks.search import ExternalSearchIndexFake class ControllerFixtureSetupOverrides: @@ -97,11 +99,17 @@ def __init__( # were created in the test setup. app.config["PRESERVE_CONTEXT_ON_EXCEPTION"] = False + # Set up the fake search index. + self.search_index = ExternalSearchIndexFake() + self.services_container = Services() + self.services_container.search.index.override(self.search_index) + if setup_cm: # NOTE: Any reference to self._default_library below this # point in this method will cause the tests in # TestScopedSession to hang. self.set_base_url() + app.manager = self.circulation_manager_setup() def set_base_url(self): @@ -110,6 +118,18 @@ def set_base_url(self): ) base_url.value = "http://test-circulation-manager/" + def wire_container(self): + wire_container(self.services_container) + + def unwire_container(self): + self.services_container.unwire() + + @contextmanager + def wired_container(self): + self.wire_container() + yield + self.unwire_container() + def circulation_manager_setup_with_session( self, session: Session, overrides: ControllerFixtureSetupOverrides | None = None ) -> CirculationManager: @@ -159,7 +179,9 @@ def circulation_manager_setup_with_session( self.default_patron = self.default_patrons[self.library] self.authdata = AuthdataUtility.from_config(self.library) - self.manager = MockCirculationManager(session) + + # Create mock CM instance + self.manager = MockCirculationManager(session, self.services_container) # Set CirculationAPI and top-level lane for the default # library, for convenience in tests. @@ -339,33 +361,6 @@ def add_works(self, works: list[WorkSpec]): ) self.manager.external_search.mock_query_works_multi(self.works) - def assert_bad_search_index_gives_problem_detail(self, test_function): - """Helper method to test that a controller method serves a problem - detail document when the search index isn't set up. - - Mocking a broken search index is a lot of work; thus the helper method. - """ - old_setup = self.manager.setup_external_search - old_value = self.manager._external_search - - try: - self.manager._external_search = None - self.manager.setup_external_search = lambda: None - with self.request_context_with_library("/"): - response = test_function() - assert 502 == response.status_code - assert ( - "http://librarysimplified.org/terms/problem/remote-integration-failed" - == response.uri - ) - assert ( - "The search index for this site is not properly configured." - == response.detail - ) - finally: - self.manager.setup_external_search = old_setup - self.manager._external_search = old_value - @pytest.fixture(scope="function") def circulation_fixture(db: DatabaseTransactionFixture): diff --git a/tests/fixtures/api_routes.py b/tests/fixtures/api_routes.py index e9823dbad..3ecdb0b69 100644 --- a/tests/fixtures/api_routes.py +++ b/tests/fixtures/api_routes.py @@ -1,6 +1,7 @@ import logging from collections.abc import Generator from typing import Any +from unittest.mock import MagicMock import flask import pytest @@ -128,7 +129,7 @@ def __init__( self.controller_fixture = controller_fixture self.setup_circulation_manager = False if not RouteTestFixture.REAL_CIRCULATION_MANAGER: - manager = MockCirculationManager(self.db.session) + manager = MockCirculationManager(self.db.session, MagicMock()) RouteTestFixture.REAL_CIRCULATION_MANAGER = manager app = MockApp() diff --git a/tests/fixtures/container.py b/tests/fixtures/container.py deleted file mode 100644 index 8d3077493..000000000 --- a/tests/fixtures/container.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from core.service.container import container_instance - - -@pytest.fixture(autouse=True) -def services_container_instance(): - # This creates and wires the container - return container_instance() diff --git a/tests/fixtures/search.py b/tests/fixtures/search.py index bb5429094..63f7c4c75 100644 --- a/tests/fixtures/search.py +++ b/tests/fixtures/search.py @@ -1,74 +1,82 @@ -import logging -import os -from collections.abc import Iterable +from __future__ import annotations + +from collections.abc import Generator import pytest from opensearchpy import OpenSearch - -from core.external_search import ExternalSearchIndex, SearchIndexCoverageProvider -from core.model import ExternalIntegration, Work +from pydantic import AnyHttpUrl + +from core.external_search import ExternalSearchIndex +from core.model import Work +from core.search.coverage_provider import SearchIndexCoverageProvider +from core.search.service import SearchServiceOpensearch1 +from core.service.configuration import ServiceConfiguration +from core.service.container import Services, wire_container +from core.service.search.container import Search +from core.util.log import LoggerMixin from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.services import ServicesFixture from tests.mocks.search import SearchServiceFake -class ExternalSearchFixture: +class SearchTestConfiguration(ServiceConfiguration): + url: AnyHttpUrl + index_prefix: str = "test_index" + timeout: int = 20 + maxsize: int = 25 + + class Config: + env_prefix = "PALACE_TEST_SEARCH_" + + +class ExternalSearchFixture(LoggerMixin): """ - These tests require opensearch to be running locally. If it's not, or there's - an error creating the index, the tests will pass without doing anything. + These tests require opensearch to be running locally. Tests for opensearch are useful for ensuring that we haven't accidentally broken a type of search by changing analyzers or queries, but search needs to be tested manually to ensure that it works well overall, with a realistic index. """ - integration: ExternalIntegration - db: DatabaseTransactionFixture - search: OpenSearch - _indexes_created: list[str] + def __init__(self, db: DatabaseTransactionFixture, services: Services): + self.search_config = SearchTestConfiguration() + self.services_container = services - def __init__(self): + # Set up our testing search instance in the services container + self.search_container = Search() + self.search_container.config.from_dict(self.search_config.dict()) + self.services_container.search.override(self.search_container) + + self._indexes_created: list[str] = [] + self.db = db + self.client: OpenSearch = services.search.client() + self.service: SearchServiceOpensearch1 = services.search.service() + self.index: ExternalSearchIndex = services.search.index() self._indexes_created = [] - self._logger = logging.getLogger(ExternalSearchFixture.__name__) - - @classmethod - def create(cls, db: DatabaseTransactionFixture) -> "ExternalSearchFixture": - fixture = ExternalSearchFixture() - fixture.db = db - fixture.integration = db.external_integration( - ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - url=fixture.url, - settings={ - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY: "test_index", - ExternalSearchIndex.TEST_SEARCH_TERM_KEY: "test_search_term", - }, - ) - fixture.search = OpenSearch(fixture.url, use_ssl=False, timeout=20, maxsize=25) - return fixture - @property - def url(self) -> str: - env = os.environ.get("SIMPLIFIED_TEST_OPENSEARCH") - if env is None: - raise OSError("SIMPLIFIED_TEST_OPENSEARCH is not defined.") - return env + # Make sure the services container is wired up with the newly created search container + wire_container(self.services_container) def record_index(self, name: str): - self._logger.info(f"Recording index {name} for deletion") + self.log.info(f"Recording index {name} for deletion") self._indexes_created.append(name) def close(self): for index in self._indexes_created: try: - self._logger.info(f"Deleting index {index}") - self.search.indices.delete(index) + self.log.info(f"Deleting index {index}") + self.client.indices.delete(index) except Exception as e: - self._logger.info(f"Failed to delete index {index}: {e}") + self.log.info(f"Failed to delete index {index}: {e}") # Force test index deletion - self.search.indices.delete("test_index*") - self._logger.info("Waiting for operations to complete.") - self.search.indices.refresh() + self.client.indices.delete("test_index*") + self.log.info("Waiting for operations to complete.") + self.client.indices.refresh() + + # Unwire the services container + self.services_container.unwire() + self.services_container.search.reset_override() return None def default_work(self, *args, **kwargs): @@ -83,54 +91,38 @@ def default_work(self, *args, **kwargs): return work def init_indices(self): - client = ExternalSearchIndex(self.db.session) - client.initialize_indices() + self.index.initialize_indices() @pytest.fixture(scope="function") def external_search_fixture( - db: DatabaseTransactionFixture, -) -> Iterable[ExternalSearchFixture]: + db: DatabaseTransactionFixture, services_fixture: ServicesFixture +) -> Generator[ExternalSearchFixture, None, None]: """Ask for an external search system.""" """Note: You probably want EndToEndSearchFixture instead.""" - data = ExternalSearchFixture.create(db) - yield data - data.close() + fixture = ExternalSearchFixture(db, services_fixture.services) + yield fixture + fixture.close() class EndToEndSearchFixture: """An external search system fixture that can be populated with data for end-to-end tests.""" """Tests are expected to call the `populate()` method to populate the fixture with test-specific data.""" - external_search: ExternalSearchFixture - external_search_index: ExternalSearchIndex - db: DatabaseTransactionFixture - - def __init__(self): - self._logger = logging.getLogger(EndToEndSearchFixture.__name__) - @classmethod - def create(cls, transaction: DatabaseTransactionFixture) -> "EndToEndSearchFixture": - data = EndToEndSearchFixture() - data.db = transaction - data.external_search = ExternalSearchFixture.create(transaction) - data.external_search_index = ExternalSearchIndex(transaction.session) - return data + def __init__(self, search_fixture: ExternalSearchFixture): + self.db = search_fixture.db + self.external_search = search_fixture + self.external_search_index = search_fixture.index def populate_search_index(self): """Populate the search index with a set of works. The given callback is passed this fixture instance.""" - - # Create some works. - if not self.external_search.search: - # No search index is configured -- nothing to do. - return - # Add all the works created in the setup to the search index. SearchIndexCoverageProvider( self.external_search.db.session, search_index_client=self.external_search_index, ).run() - self.external_search.search.indices.refresh() + self.external_search.client.indices.refresh() @staticmethod def assert_works(description, expect, actual, should_be_ordered=True): @@ -249,48 +241,43 @@ def close(self): for index in self.external_search_index.search_service().indexes_created(): self.external_search.record_index(index) - self.external_search.close() - @pytest.fixture(scope="function") def end_to_end_search_fixture( - db: DatabaseTransactionFixture, -) -> Iterable[EndToEndSearchFixture]: + external_search_fixture: ExternalSearchFixture, +) -> Generator[EndToEndSearchFixture, None, None]: """Ask for an external search system that can be populated with data for end-to-end tests.""" - data = EndToEndSearchFixture.create(db) - try: - yield data - except Exception: - raise - finally: - data.close() + fixture = EndToEndSearchFixture(external_search_fixture) + yield fixture + fixture.close() class ExternalSearchFixtureFake: - integration: ExternalIntegration - db: DatabaseTransactionFixture - search: SearchServiceFake - external_search: ExternalSearchIndex + def __init__(self, db: DatabaseTransactionFixture, services: Services): + self.db = db + self.services = services + self.search_container = Search() + self.services.search.override(self.search_container) + + self.service = SearchServiceFake() + self.search_container.service.override(self.service) + self.external_search: ExternalSearchIndex = self.services.search.index() + + wire_container(self.services) + + def close(self): + self.services.unwire() + self.services.search.reset_override() @pytest.fixture(scope="function") def external_search_fake_fixture( - db: DatabaseTransactionFixture, -) -> ExternalSearchFixtureFake: + db: DatabaseTransactionFixture, services_fixture: ServicesFixture +) -> Generator[ExternalSearchFixtureFake, None, None]: """Ask for an external search system that can be populated with data for end-to-end tests.""" - data = ExternalSearchFixtureFake() - data.db = db - data.integration = db.external_integration( - ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - url="http://does-not-exist.com/", - settings={ - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY: "test_index", - ExternalSearchIndex.TEST_SEARCH_TERM_KEY: "a search term", - }, - ) - data.search = SearchServiceFake() - data.external_search = ExternalSearchIndex( - _db=db.session, custom_client_service=data.search + fixture = ExternalSearchFixtureFake( + db=db, + services=services_fixture.services, ) - return data + yield fixture + fixture.close() diff --git a/tests/fixtures/services.py b/tests/fixtures/services.py index edbeccb52..52600241f 100644 --- a/tests/fixtures/services.py +++ b/tests/fixtures/services.py @@ -1,42 +1,155 @@ -from unittest.mock import MagicMock +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from unittest.mock import MagicMock, create_autospec +import boto3 import pytest -from core.service.container import Services +from core.analytics import Analytics +from core.external_search import ExternalSearchIndex +from core.search.revision_directory import SearchRevisionDirectory +from core.search.service import SearchServiceOpensearch1 +from core.service.analytics.container import AnalyticsContainer +from core.service.container import Services, wire_container +from core.service.logging.container import Logging +from core.service.logging.log import setup_logging +from core.service.search.container import Search from core.service.storage.container import Storage from core.service.storage.s3 import S3Service -class MockStorageFixture: - def __init__(self): - self.storage = Storage() - self.analytics = MagicMock(spec=S3Service) - self.storage.analytics.override(self.analytics) - self.public = MagicMock(spec=S3Service) - self.storage.public.override(self.public) - self.s3_client = MagicMock() - self.storage.s3_client.override(self.s3_client) +@contextmanager +def mock_services_container( + services_container: Services, +) -> Generator[None, None, None]: + from core.service import container + + container._container_instance = services_container + yield + container._container_instance = None + + +@dataclass +class ServicesLoggingFixture: + logging_container: Logging + logging_mock: MagicMock @pytest.fixture -def mock_storage_fixture() -> MockStorageFixture: - return MockStorageFixture() +def services_logging_fixture() -> ServicesLoggingFixture: + logging_container = Logging() + logging_mock = create_autospec(setup_logging) + logging_container.logging.override(logging_mock) + return ServicesLoggingFixture(logging_container, logging_mock) -class MockServicesFixture: +@dataclass +class ServicesStorageFixture: + storage_container: Storage + s3_client_mock: MagicMock + analytics_mock: MagicMock + public_mock: MagicMock + + +@pytest.fixture +def services_storage_fixture() -> ServicesStorageFixture: + storage_container = Storage() + s3_client_mock = create_autospec(boto3.client) + analytics_mock = create_autospec(S3Service.factory) + public_mock = create_autospec(S3Service.factory) + storage_container.s3_client.override(s3_client_mock) + storage_container.analytics.override(analytics_mock) + storage_container.public.override(public_mock) + return ServicesStorageFixture( + storage_container, s3_client_mock, analytics_mock, public_mock + ) + + +@dataclass +class ServicesSearchFixture: + search_container: Search + client_mock: MagicMock + service_mock: MagicMock + revision_directory_mock: MagicMock + index_mock: MagicMock + + +@pytest.fixture +def services_search_fixture() -> ServicesSearchFixture: + search_container = Search() + client_mock = create_autospec(boto3.client) + service_mock = create_autospec(SearchServiceOpensearch1) + revision_directory_mock = create_autospec(SearchRevisionDirectory.create) + index_mock = create_autospec(ExternalSearchIndex) + search_container.client.override(client_mock) + search_container.service.override(service_mock) + search_container.revision_directory.override(revision_directory_mock) + search_container.index.override(index_mock) + return ServicesSearchFixture( + search_container, client_mock, service_mock, revision_directory_mock, index_mock + ) + + +@dataclass +class ServicesAnalyticsFixture: + analytics_container: AnalyticsContainer + analytics_mock: MagicMock + + +@pytest.fixture +def services_analytics_fixture() -> ServicesAnalyticsFixture: + analytics_container = AnalyticsContainer() + analytics_mock = create_autospec(Analytics) + analytics_container.analytics.override(analytics_mock) + return ServicesAnalyticsFixture(analytics_container, analytics_mock) + + +class ServicesFixture: """ - Provide a services container with all the services mocked out - by MagicMock objects. + Provide a real services container, with all services mocked out. """ - def __init__(self, storage: MockStorageFixture): + def __init__( + self, + logging: ServicesLoggingFixture, + storage: ServicesStorageFixture, + search: ServicesSearchFixture, + analytics: ServicesAnalyticsFixture, + ) -> None: + self.logging_fixture = logging + self.storage_fixture = storage + self.search_fixture = search + self.analytics_fixture = analytics + self.services = Services() - self.services.storage.override(storage.storage) - self.storage = storage + self.services.logging.override(logging.logging_container) + self.services.storage.override(storage.storage_container) + self.services.search.override(search.search_container) + self.services.analytics.override(analytics.analytics_container) + + +@pytest.fixture(autouse=True) +def services_fixture( + services_logging_fixture: ServicesLoggingFixture, + services_storage_fixture: ServicesStorageFixture, + services_search_fixture: ServicesSearchFixture, + services_analytics_fixture: ServicesAnalyticsFixture, +) -> Generator[ServicesFixture, None, None]: + fixture = ServicesFixture( + logging=services_logging_fixture, + storage=services_storage_fixture, + search=services_search_fixture, + analytics=services_analytics_fixture, + ) + with mock_services_container(fixture.services): + yield fixture @pytest.fixture -def mock_services_fixture( - mock_storage_fixture: MockStorageFixture, -) -> MockServicesFixture: - return MockServicesFixture(mock_storage_fixture) +def services_fixture_wired( + services_fixture: ServicesFixture, +) -> Generator[ServicesFixture, None, None]: + wire_container(services_fixture.services) + yield services_fixture + services_fixture.services.unwire() diff --git a/tests/migration/conftest.py b/tests/migration/conftest.py index c537d93b1..85957ecd4 100644 --- a/tests/migration/conftest.py +++ b/tests/migration/conftest.py @@ -13,6 +13,7 @@ from core.model import json_serializer from tests.fixtures.database import ApplicationFixture, DatabaseFixture +from tests.fixtures.services import ServicesFixture if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -21,8 +22,15 @@ import alembic.config +pytest_plugins = [ + "tests.fixtures.services", +] + + @pytest.fixture(scope="function") -def application() -> Generator[ApplicationFixture, None, None]: +def application( + services_fixture: ServicesFixture, +) -> Generator[ApplicationFixture, None, None]: app = ApplicationFixture.create() yield app app.close() diff --git a/tests/migration/test_instance_init_script.py b/tests/migration/test_instance_init_script.py index 62a8f8b4a..8c413c548 100644 --- a/tests/migration/test_instance_init_script.py +++ b/tests/migration/test_instance_init_script.py @@ -2,7 +2,7 @@ import sys from io import StringIO from multiprocessing import Process -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from pytest_alembic import MigrationContext from sqlalchemy import inspect @@ -11,15 +11,19 @@ from core.model import SessionManager from scripts import InstanceInitializationScript from tests.fixtures.database import ApplicationFixture +from tests.fixtures.services import mock_services_container def _run_script() -> None: try: - # Run the script, capturing the log output - script = InstanceInitializationScript() + # Capturing the log output stream = StringIO() logging.basicConfig(stream=stream, level=logging.INFO, force=True) - script.run() + + mock_services = MagicMock() + with mock_services_container(mock_services): + script = InstanceInitializationScript() + script.run() # Set our exit code to the number of upgrades we ran sys.exit(stream.getvalue().count("Running upgrade")) diff --git a/tests/mocks/search.py b/tests/mocks/search.py index eebf8f399..ffdb9e487 100644 --- a/tests/mocks/search.py +++ b/tests/mocks/search.py @@ -9,7 +9,6 @@ from opensearchpy import OpenSearchException from core.external_search import ExternalSearchIndex -from core.model import Work from core.model.work import Work from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -50,6 +49,10 @@ def __init__(self): self._indexes_created = [] self._document_submission_attempts = [] + @property + def base_revision_name(self) -> str: + return self.base_name + @property def document_submission_attempts(self) -> list[dict]: return self._document_submission_attempts @@ -227,14 +230,14 @@ class ExternalSearchIndexFake(ExternalSearchIndex): def __init__( self, - _db, - url: str | None = None, - test_search_term: str | None = None, revision_directory: SearchRevisionDirectory | None = None, version: int | None = None, ): + revision_directory = revision_directory or SearchRevisionDirectory.create() super().__init__( - _db, url, test_search_term, revision_directory, version, SearchServiceFake() + service=SearchServiceFake(), + revision_directory=revision_directory, + version=version, ) self._mock_multi_works: list[dict] = [] diff --git a/tox.ini b/tox.ini index b95fd8cbe..f77003a43 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ passenv = setenv = {api,core}: COVERAGE_FILE = .coverage.{envname} docker: SIMPLIFIED_TEST_DATABASE=postgresql://simplified_test:test@localhost:9005/simplified_circulation_test - docker: SIMPLIFIED_TEST_OPENSEARCH=http://localhost:9007 + docker: PALACE_TEST_SEARCH_URL=http://localhost:9007 core-docker: PALACE_TEST_MINIO_ENDPOINT_URL=http://localhost:9004 core-docker: PALACE_TEST_MINIO_USER=palace core-docker: PALACE_TEST_MINIO_PASSWORD=12345678901234567890 From 3e8445707e7e27b2fe3caad5bbfb9549931f0525 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 22 Jan 2024 13:35:02 -0400 Subject: [PATCH 09/33] Allow ODL feeds to have open access titles (PP-847) (#1617) * Allow OA titles to be imported in ODL feed. * Add a comment * Code review feedback: fix comment --- api/odl.py | 80 ++++++++++++++------ api/odl2.py | 17 +++-- tests/api/files/odl2/oa-title.json | 116 +++++++++++++++++++++++++++++ tests/api/test_odl.py | 103 +++++++++++++++++++++++++ tests/api/test_odl2.py | 45 ++++++++++- 5 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 tests/api/files/odl2/oa-title.json diff --git a/api/odl.py b/api/odl.py index 2a79c4ec0..1dbf461ae 100644 --- a/api/odl.py +++ b/api/odl.py @@ -346,6 +346,11 @@ def checkin(self, patron: Patron, pin: str, licensepool: LicensePool) -> None: if loan.count() < 1: raise NotCheckedOut() loan_result = loan.one() + + if loan_result.license_pool.open_access: + # If this is an open-access book, we don't need to do anything. + return + self._checkin(loan_result) def _checkin(self, loan: Loan) -> bool: @@ -410,16 +415,25 @@ def checkout( if loan.count() > 0: raise AlreadyCheckedOut() - hold = get_one(_db, Hold, patron=patron, license_pool_id=licensepool.id) - loan_obj = self._checkout(patron, licensepool, hold) + if licensepool.open_access: + loan_start = None + loan_end = None + external_identifier = None + else: + hold = get_one(_db, Hold, patron=patron, license_pool_id=licensepool.id) + loan_obj = self._checkout(patron, licensepool, hold) + loan_start = loan_obj.start + loan_end = loan_obj.end + external_identifier = loan_obj.external_identifier + return LoanInfo( licensepool.collection, licensepool.data_source.name, licensepool.identifier.type, licensepool.identifier.identifier, - loan_obj.start, - loan_obj.end, - external_identifier=loan_obj.external_identifier, + loan_start, + loan_end, + external_identifier=external_identifier, ) def _checkout( @@ -548,25 +562,42 @@ def _fulfill( delivery_mechanism: LicensePoolDeliveryMechanism, ) -> FulfillmentInfo: licensepool = loan.license_pool - doc = self.get_license_status_document(loan) - status = doc.get("status") - if status not in [self.READY_STATUS, self.ACTIVE_STATUS]: - # This loan isn't available for some reason. It's possible - # the distributor revoked it or the patron already returned it - # through the DRM system, and we didn't get a notification - # from the distributor yet. - self.update_loan(loan, doc) - raise CannotFulfill() - - expires = doc.get("potential_rights", {}).get("end") - expires = dateutil.parser.parse(expires) + if licensepool.open_access: + expires = None + requested_mechanism = delivery_mechanism.delivery_mechanism + fulfillment = next( + ( + lpdm + for lpdm in licensepool.delivery_mechanisms + if lpdm.delivery_mechanism == requested_mechanism + ), + None, + ) + if fulfillment is None: + raise FormatNotAvailable() + content_link = fulfillment.resource.representation.public_url + content_type = fulfillment.resource.representation.media_type + else: + doc = self.get_license_status_document(loan) + status = doc.get("status") + + if status not in [self.READY_STATUS, self.ACTIVE_STATUS]: + # This loan isn't available for some reason. It's possible + # the distributor revoked it or the patron already returned it + # through the DRM system, and we didn't get a notification + # from the distributor yet. + self.update_loan(loan, doc) + raise CannotFulfill() + + expires = doc.get("potential_rights", {}).get("end") + expires = dateutil.parser.parse(expires) - links = doc.get("links", []) + links = doc.get("links", []) - content_link, content_type = self._find_content_link_and_type( - links, delivery_mechanism.delivery_mechanism.drm_scheme - ) + content_link, content_type = self._find_content_link_and_type( + links, delivery_mechanism.delivery_mechanism.drm_scheme + ) return FulfillmentInfo( licensepool.collection, @@ -822,7 +853,12 @@ def patron_activity(self, patron: Patron, pin: str) -> list[LoanInfo | HoldInfo] .join(Loan.license_pool) .filter(LicensePool.collection_id == self.collection_id) .filter(Loan.patron == patron) - .filter(Loan.end >= utc_now()) + .filter( + or_( + Loan.end >= utc_now(), + Loan.end == None, + ) + ) ) # Get the patron's holds. If there are any expired holds, delete them. diff --git a/api/odl2.py b/api/odl2.py index 74777b250..f02318591 100644 --- a/api/odl2.py +++ b/api/odl2.py @@ -284,13 +284,16 @@ def _extract_publication_metadata( ) ) - metadata.circulation.licenses = licenses - metadata.circulation.licenses_owned = None - metadata.circulation.licenses_available = None - metadata.circulation.licenses_reserved = None - metadata.circulation.patrons_in_hold_queue = None - metadata.circulation.formats.extend(formats) - metadata.medium = medium + # If we don't have any licenses, then this title is an open-access title. + # So we don't change the circulation data. + if len(licenses) != 0: + metadata.circulation.licenses = licenses + metadata.circulation.licenses_owned = None + metadata.circulation.licenses_available = None + metadata.circulation.licenses_reserved = None + metadata.circulation.patrons_in_hold_queue = None + metadata.circulation.formats.extend(formats) + metadata.medium = medium return metadata diff --git a/tests/api/files/odl2/oa-title.json b/tests/api/files/odl2/oa-title.json new file mode 100644 index 000000000..88fd6a12a --- /dev/null +++ b/tests/api/files/odl2/oa-title.json @@ -0,0 +1,116 @@ +{ + "metadata": { + "title": "Feedbooks" + }, + "links": [ + { + "type": "application/opds+json", + "rel": "self", + "href": "https://market.feedbooks.com/api/libraries/harvest.json" + } + ], + "publications": [ + { + "metadata": { + "@type": "http://schema.org/Book", + "title": "Maria: or, The Wrongs of Woman", + "language": "en", + "modified": "2024-01-17T14:34:03+01:00", + "published": "1798-01-01T00:00:00+00:09", + "identifier": "https://www.feedbooks.com/book/7256", + "schema:encodingFormat": "application/epub+zip", + "presentation": { + "layout": "reflowable" + }, + "author": [ + { + "name": "Mary Wollstonecraft", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/recent.json?author_id=1315&lang=en" + } + ] + } + ], + "publisher": { + "name": "Feedbooks", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/recent.json?lang=en&publisher=Feedbooks" + } + ] + }, + "description": "Wollstonecraft's philosophical and gothic novel revolves around the story of a woman imprisoned in an insane asylum by her husband. It focuses on the societal rather than the individual \"wrongs of woman\" and criticizes what Wollstonecraft viewed as the patriarchal institution of marriage in eighteenth-century Britain and the legal system that protected it. [Source: Wikipedia]", + "subject": [ + { + "code": "FBFIC000000", + "name": "Fiction", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?cat=FBFIC000000&lang=en" + } + ] + }, + { + "code": "FBFIC019000", + "name": "Literary", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?cat=FBFIC019000&lang=en" + } + ] + }, + { + "code": "READ0000", + "scheme": "http://schema.org/Audience", + "name": "Adult", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?age=READ0000&lang=en" + } + ] + } + ] + }, + "images": [ + { + "href": "https://covers.feedbooks.net/book/7256.jpg?size=large&t=1549045914", + "type": "image/jpeg", + "width": 260, + "height": 420 + }, + { + "href": "https://covers.feedbooks.net/book/7256.jpg?t=1549045914", + "type": "image/jpeg", + "width": 100, + "height": 180 + } + ], + "links": [ + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "https://license.feedbooks.net/loan/open_content", + "type": "application/epub+zip" + }, + { + "rel": "self", + "href": "https://market.feedbooks.com/book/7256.json", + "type": "application/opds-publication+json", + "properties": { + "authenticate": { + "href": "https://market.feedbooks.com/user/authentication", + "type": "application/opds-authentication+json" + } + } + } + ] + } + ] +} diff --git a/tests/api/test_odl.py b/tests/api/test_odl.py index 040a285c8..6e025bece 100644 --- a/tests/api/test_odl.py +++ b/tests/api/test_odl.py @@ -288,6 +288,23 @@ def test_checkin_cannot_return( odl_api_test_fixture.patron, "pin", odl_api_test_fixture.pool ) + def test_checkin_open_access( + self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture + ) -> None: + # Checking in an open-access book doesn't need to call out to the distributor API. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool = oa_work.license_pools[0] + loan, ignore = pool.loan_to(odl_api_test_fixture.patron) + + # make sure that _checkin isn't called since it is not needed for an open access work + odl_api_test_fixture.api._checkin = MagicMock( + side_effect=Exception("Should not be called") + ) + + odl_api_test_fixture.api.checkin(odl_api_test_fixture.patron, "pin", pool) + def test_checkout_success( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -321,6 +338,24 @@ def test_checkout_success( assert 5 == odl_api_test_fixture.pool.licenses_available assert 29 == odl_api_test_fixture.license.checkouts_left + def test_checkout_open_access( + self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture + ) -> None: + # This book is available to check out. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + loan = odl_api_test_fixture.api.checkout( + odl_api_test_fixture.patron, "pin", oa_work.license_pools[0], None + ) + + assert loan.collection(db.session) == odl_api_test_fixture.collection + assert loan.identifier == oa_work.license_pools[0].identifier.identifier + assert loan.identifier_type == oa_work.license_pools[0].identifier.type + assert loan.start_date is None + assert loan.end_date is None + assert loan.external_identifier is None + def test_checkout_success_with_hold( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -656,6 +691,42 @@ def test_fulfill_success( assert correct_link == fulfillment.content_link assert correct_type == fulfillment.content_type + def test_fulfill_open_access( + self, + odl_api_test_fixture: ODLAPITestFixture, + db: DatabaseTransactionFixture, + ) -> None: + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool = oa_work.license_pools[0] + loan, ignore = pool.loan_to(odl_api_test_fixture.patron) + + # If we can't find a delivery mechanism, we can't fulfill the loan. + pytest.raises( + CannotFulfill, + odl_api_test_fixture.api.fulfill, + odl_api_test_fixture.patron, + "pin", + pool, + MagicMock(spec=LicensePoolDeliveryMechanism), + ) + + lpdm = pool.delivery_mechanisms[0] + fulfillment = odl_api_test_fixture.api.fulfill( + odl_api_test_fixture.patron, "pin", pool, lpdm + ) + + assert odl_api_test_fixture.collection == fulfillment.collection(db.session) + assert ( + odl_api_test_fixture.pool.data_source.name == fulfillment.data_source_name + ) + assert fulfillment.identifier_type == pool.identifier.type + assert fulfillment.identifier == pool.identifier.identifier + assert fulfillment.content_expires is None + assert fulfillment.content_link == pool.open_access_download_url + assert fulfillment.content_type == lpdm.delivery_mechanism.content_type + def test_fulfill_cannot_fulfill( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -1300,6 +1371,38 @@ def test_patron_activity_loan( assert odl_api_test_fixture.pool.identifier.identifier == activity[0].identifier odl_api_test_fixture.checkin(pool=pool2) + # Open access loans are included. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool3 = oa_work.license_pools[0] + loan3, ignore = pool3.loan_to(odl_api_test_fixture.patron) + + activity = odl_api_test_fixture.api.patron_activity( + odl_api_test_fixture.patron, "pin" + ) + assert 2 == len(activity) + [l1, l2] = sorted(activity, key=lambda x: x.start_date) + + assert odl_api_test_fixture.collection == l1.collection(db.session) + assert odl_api_test_fixture.pool.data_source.name == l1.data_source_name + assert odl_api_test_fixture.pool.identifier.type == l1.identifier_type + assert odl_api_test_fixture.pool.identifier.identifier == l1.identifier + assert loan.start == l1.start_date + assert loan.end == l1.end_date + assert loan.external_identifier == l1.external_identifier + + assert odl_api_test_fixture.collection == l2.collection(db.session) + assert pool3.data_source.name == l2.data_source_name + assert pool3.identifier.type == l2.identifier_type + assert pool3.identifier.identifier == l2.identifier + assert loan3.start == l2.start_date + assert loan3.end == l2.end_date + assert loan3.external_identifier == l2.external_identifier + + # remove the open access loan + db.session.delete(loan3) + # One hold. other_patron = db.patron() odl_api_test_fixture.checkout(patron=other_patron, pool=pool2) diff --git a/tests/api/test_odl2.py b/tests/api/test_odl2.py index cbca67109..220dab6cf 100644 --- a/tests/api/test_odl2.py +++ b/tests/api/test_odl2.py @@ -41,7 +41,7 @@ class TestODL2Importer: def _get_delivery_mechanism_by_drm_scheme_and_content_type( delivery_mechanisms: list[LicensePoolDeliveryMechanism], content_type: str, - drm_scheme: str, + drm_scheme: str | None, ) -> DeliveryMechanism | None: """Find a license pool in the list by its identifier. @@ -327,6 +327,49 @@ def test_import_audiobook_no_streaming( ) assert lcp_delivery_mechanism is not None + @freeze_time("2016-01-01T00:00:00+00:00") + def test_import_open_access( + self, + odl2_importer: ODL2Importer, + api_odl2_files_fixture: ODL2APIFilesFixture, + ) -> None: + """ + Ensure that ODL2Importer2 correctly processes and imports a feed with an + open access book. + """ + feed = api_odl2_files_fixture.sample_text("oa-title.json") + imported_editions, pools, works, failures = odl2_importer.import_from_feed(feed) + + assert isinstance(imported_editions, list) + assert 1 == len(imported_editions) + + [edition] = imported_editions + assert isinstance(edition, Edition) + assert ( + edition.primary_identifier.identifier + == "https://www.feedbooks.com/book/7256" + ) + assert edition.primary_identifier.type == "URI" + assert edition.medium == EditionConstants.BOOK_MEDIUM + + # Make sure that license pools have correct configuration + assert isinstance(pools, list) + assert 1 == len(pools) + + [license_pool] = pools + assert license_pool.open_access is True + + assert 1 == len(license_pool.delivery_mechanisms) + + oa_ebook_delivery_mechanism = ( + self._get_delivery_mechanism_by_drm_scheme_and_content_type( + license_pool.delivery_mechanisms, + MediaTypes.EPUB_MEDIA_TYPE, + None, + ) + ) + assert oa_ebook_delivery_mechanism is not None + class TestODL2API: def test_loan_limit(self, odl2_api_test_fixture: ODL2APITestFixture): From cb8ec7783b2de331150ad89318fb491e21ab2ec3 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Wed, 24 Jan 2024 08:51:28 -0500 Subject: [PATCH 10/33] Borrow single item feed response respects accept header. (PP-829) (#1623) * Pass along Flask mime_types for borrows. * Convert serialized object to a string. * Update loans test to include OPDS2 serialization. --- api/controller/loan.py | 11 ++--- core/feed/opds.py | 4 +- tests/api/controller/test_loan.py | 72 +++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/api/controller/loan.py b/api/controller/loan.py index 9d8c33f68..f2e1e1291 100644 --- a/api/controller/loan.py +++ b/api/controller/loan.py @@ -145,11 +145,12 @@ def borrow(self, identifier_type, identifier, mechanism_id=None): # At this point we have either a loan or a hold. If a loan, serve # a feed that tells the patron how to fulfill the loan. If a hold, # serve a feed that talks about the hold. - response_kwargs = {} - if is_new: - response_kwargs["status"] = 201 - else: - response_kwargs["status"] = 200 + # We also need to drill in the Accept header, so that it eventually + # gets sent to core.feed.opds.BaseOPDSFeed.entry_as_response + response_kwargs = { + "status": 201 if is_new else 200, + "mime_types": flask.request.accept_mimetypes, + } return OPDSAcquisitionFeed.single_entry_loans_feed( self.circulation, loan_or_hold, **response_kwargs ) diff --git a/core/feed/opds.py b/core/feed/opds.py index 5daf0c931..97accc03a 100644 --- a/core/feed/opds.py +++ b/core/feed/opds.py @@ -84,7 +84,9 @@ def entry_as_response( logging.getLogger().error(f"Entry data has not been generated for {entry}") raise ValueError(f"Entry data has not been generated") response = OPDSEntryResponse( - response=serializer.serialize_work_entry(entry.computed), + response=serializer.to_string( + serializer.serialize_work_entry(entry.computed) + ), **response_kwargs, ) if isinstance(serializer, OPDS2Serializer): diff --git a/tests/api/controller/test_loan.py b/tests/api/controller/test_loan.py index e71395326..4c2891569 100644 --- a/tests/api/controller/test_loan.py +++ b/tests/api/controller/test_loan.py @@ -182,7 +182,21 @@ def test_patron_circulation_retrieval(self, loan_fixture: LoanFixture): ) assert (hold, other_pool) == result - def test_borrow_success(self, loan_fixture: LoanFixture): + @pytest.mark.parametrize( + "accept_header,expected_content_type_prefix", + [ + (None, "application/atom+xml"), + ("default-foo-bar", "application/atom+xml"), + ("application/atom+xml", "application/atom+xml"), + ("application/opds+json", "application/opds+json"), + ], + ) + def test_borrow_success( + self, + loan_fixture: LoanFixture, + accept_header: str | None, + expected_content_type_prefix, + ): # Create a loanable LicensePool. work = loan_fixture.db.work( with_license_pool=True, with_open_access_download=False @@ -199,9 +213,36 @@ def test_borrow_success(self, loan_fixture: LoanFixture): utc_now() + datetime.timedelta(seconds=3600), ), ) - with loan_fixture.request_context_with_library( - "/", headers=dict(Authorization=loan_fixture.valid_auth) - ): + + # Setup headers for the request. + headers = {"Authorization": loan_fixture.valid_auth} | ( + {"Accept": accept_header} if accept_header else {} + ) + + # Create a new loan. + with loan_fixture.request_context_with_library("/", headers=headers): + loan_fixture.manager.loans.authenticated_patron_from_request() + response = loan_fixture.manager.loans.borrow( + loan_fixture.identifier.type, loan_fixture.identifier.identifier + ) + loan = get_one( + loan_fixture.db.session, Loan, license_pool=loan_fixture.pool + ) + + # A new loan should return a 201 status. + assert 201 == response.status_code + + # A loan has been created for this license pool. + assert loan is not None + # The loan has yet to be fulfilled. + assert loan.fulfillment is None + + # We've been given an OPDS feed with one entry, which tells us how + # to fulfill the license. + new_feed_content = response.get_data() + + # Borrow again with an existing loan. + with loan_fixture.request_context_with_library("/", headers=headers): loan_fixture.manager.loans.authenticated_patron_from_request() response = loan_fixture.manager.loans.borrow( loan_fixture.identifier.type, loan_fixture.identifier.identifier @@ -211,15 +252,28 @@ def test_borrow_success(self, loan_fixture: LoanFixture): loan = get_one( loan_fixture.db.session, Loan, license_pool=loan_fixture.pool ) + # An existing loan should return a 200 status. + assert 200 == response.status_code + + # There is still a loan that has not yet been fulfilled. assert loan is not None - # The loan has yet to be fulfilled. - assert None == loan.fulfillment + assert loan.fulfillment is None # We've been given an OPDS feed with one entry, which tells us how # to fulfill the license. - assert 201 == response.status_code - feed = feedparser.parse(response.get_data()) - [entry] = feed["entries"] + existing_feed_content = response.get_data() + + # The new loan feed should look the same as the existing loan feed. + assert new_feed_content == existing_feed_content + + if expected_content_type_prefix == "application/atom+xml": + assert response.content_type.startswith("application/atom+xml") + feed = feedparser.parse(response.get_data()) + [entry] = feed["entries"] + elif expected_content_type_prefix == "application/opds+json": + assert "application/opds+json" == response.content_type + entry = response.get_json() + fulfillment_links = [ x["href"] for x in entry["links"] From bcdad0813b0cd81c44b40090c8a76b79f74deaf5 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Wed, 24 Jan 2024 20:56:49 -0500 Subject: [PATCH 11/33] Correct label for patron auth ID restriction type. (#1624) --- api/authentication/basic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/authentication/basic.py b/api/authentication/basic.py index b4075d468..c24add7c0 100644 --- a/api/authentication/basic.py +++ b/api/authentication/basic.py @@ -191,13 +191,13 @@ class BasicAuthProviderLibrarySettings(AuthProviderLibrarySettings): library_identifier_restriction_type: LibraryIdentifierRestriction = FormField( LibraryIdentifierRestriction.NONE, form=ConfigurationFormItem( - label="Library Identifier Restriction", + label="Library Identifier Restriction Type", type=ConfigurationFormItemType.SELECT, description="When multiple libraries share an ILS, a person may be able to " - "authenticate with the ILS but not be considered a patron of " + "authenticate with the ILS, but not be considered a patron of " "this library. This setting contains the rule for determining " "whether an identifier is valid for this specific library.

" - "If this setting it set to 'No Restriction' then the values for " + "If this setting is set to 'No Restriction', then the values for " "Library Identifier Field and Library Identifier " "Restriction will not be used.", options={ From 5f03f4efa4c65346467ecfc6aa05a4c869331cfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:01:44 +0000 Subject: [PATCH 12/33] Bump types-pillow from 10.2.0.20240111 to 10.2.0.20240125 (#1629) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index fc886a5c0..3f35b15e9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4150,13 +4150,13 @@ referencing = "*" [[package]] name = "types-pillow" -version = "10.2.0.20240111" +version = "10.2.0.20240125" description = "Typing stubs for Pillow" optional = false python-versions = ">=3.8" files = [ - {file = "types-Pillow-10.2.0.20240111.tar.gz", hash = "sha256:e8d359bfdc5a149a3c90a7e153cb2d0750ddf7fc3508a20dfadabd8a9435e354"}, - {file = "types_Pillow-10.2.0.20240111-py3-none-any.whl", hash = "sha256:1f4243b30c143b56b0646626f052e4269123e550f9096cdfb5fbd999daee7dbb"}, + {file = "types-Pillow-10.2.0.20240125.tar.gz", hash = "sha256:c449b2c43b9fdbe0494a7b950e6b39a4e50516091213fec24ef3f33c1d017717"}, + {file = "types_Pillow-10.2.0.20240125-py3-none-any.whl", hash = "sha256:322dbae32b4b7918da5e8a47c50ac0f24b0aa72a804a23857620f2722b03c858"}, ] [[package]] From a3dd1d48c26a1b7bbb483bd104b0901cbb52001c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:02:02 +0000 Subject: [PATCH 13/33] Bump dorny/paths-filter from 2 to 3 (#1628) --- .github/workflows/test-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 20ce270cf..52f4f6777 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -172,7 +172,7 @@ jobs: # using these changes throughout the rest of the build. If the base image # build wasn't changed, we don't use it and just rely on scheduled build. - name: Check if base image was changed by this branch - uses: dorny/paths-filter@v2 + uses: dorny/paths-filter@v3 id: changes with: filters: | From 59e4d246c253bfd178d98a35d326575c53a74cb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:02:18 +0000 Subject: [PATCH 14/33] Bump firebase-admin from 6.3.0 to 6.4.0 (#1625) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3f35b15e9..d4c454ed1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1134,13 +1134,13 @@ typing = ["typing-extensions (>=4.8)"] [[package]] name = "firebase-admin" -version = "6.3.0" +version = "6.4.0" description = "Firebase Admin Python SDK" optional = false python-versions = ">=3.7" files = [ - {file = "firebase_admin-6.3.0-py3-none-any.whl", hash = "sha256:fcada47664f38b6da67fd924108b98029370554c9f762895d3f83e912cac5ab9"}, - {file = "firebase_admin-6.3.0.tar.gz", hash = "sha256:f040625b8cd3a15f99f84a797fe288ad5993c4034c355b7df3c37a99d39400e6"}, + {file = "firebase_admin-6.4.0-py3-none-any.whl", hash = "sha256:aa06f19f0aa8b9b929dbe5cd13677c9ba05fe7ff819564f420aae02c645c6322"}, + {file = "firebase_admin-6.4.0.tar.gz", hash = "sha256:4ac83ee00abe68498b9f08d701b550a77b3a59efba610a9e2fb3d7b1515166c6"}, ] [package.dependencies] From b595a045d7c25b716cffddf4497a97f3c34270c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:02:37 +0000 Subject: [PATCH 15/33] Bump pyopenssl from 23.3.0 to 24.0.0 (#1622) --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index d4c454ed1..d0ae8ea01 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3118,17 +3118,17 @@ files = [ [[package]] name = "pyopenssl" -version = "23.3.0" +version = "24.0.0" description = "Python wrapper module around the OpenSSL library" optional = false python-versions = ">=3.7" files = [ - {file = "pyOpenSSL-23.3.0-py3-none-any.whl", hash = "sha256:6756834481d9ed5470f4a9393455154bc92fe7a64b7bc6ee2c804e78c52099b2"}, - {file = "pyOpenSSL-23.3.0.tar.gz", hash = "sha256:6b2cba5cc46e822750ec3e5a81ee12819850b11303630d575e98108a079c2b12"}, + {file = "pyOpenSSL-24.0.0-py3-none-any.whl", hash = "sha256:ba07553fb6fd6a7a2259adb9b84e12302a9a8a75c44046e8bb5d3e5ee887e3c3"}, + {file = "pyOpenSSL-24.0.0.tar.gz", hash = "sha256:6aa33039a93fffa4563e655b61d11364d01264be8ccb49906101e02a334530bf"}, ] [package.dependencies] -cryptography = ">=41.0.5,<42" +cryptography = ">=41.0.5,<43" [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] @@ -4500,4 +4500,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "0fbd61c3f50c72f1b95ca308be52d3aff554b6fc69c0b76b9d93b8e81e9ebee1" +content-hash = "03834f6200fc9dc975c6f182f50ad09ceb01c9d4a1dabed5478a59201e308d64" diff --git a/pyproject.toml b/pyproject.toml index 330cff277..bb1c2e48b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -222,7 +222,7 @@ pyinstrument = "^4.6" PyJWT = "^2.8" PyLD = "2.0.3" pymarc = "5.1.1" -pyOpenSSL = "^23.1.0" +pyOpenSSL = "^24.0.0" pyparsing = "3.1.1" pyspellchecker = "0.8.0" python = ">=3.10,<4" From 14faf10960ccb7bccc036165a3d1d58896d30795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:03:20 +0000 Subject: [PATCH 16/33] Bump pydantic from 1.10.13 to 1.10.14 (#1620) --- poetry.lock | 74 ++++++++++++++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/poetry.lock b/poetry.lock index d0ae8ea01..b23f4b6df 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2925,47 +2925,47 @@ files = [ [[package]] name = "pydantic" -version = "1.10.13" +version = "1.10.14" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737"}, - {file = "pydantic-1.10.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01"}, - {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548"}, - {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8"}, - {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69"}, - {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17"}, - {file = "pydantic-1.10.13-cp310-cp310-win_amd64.whl", hash = "sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f"}, - {file = "pydantic-1.10.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653"}, - {file = "pydantic-1.10.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe"}, - {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9"}, - {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80"}, - {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580"}, - {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0"}, - {file = "pydantic-1.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0"}, - {file = "pydantic-1.10.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132"}, - {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5"}, - {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8"}, - {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87"}, - {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f"}, - {file = "pydantic-1.10.13-cp37-cp37m-win_amd64.whl", hash = "sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33"}, - {file = "pydantic-1.10.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261"}, - {file = "pydantic-1.10.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599"}, - {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127"}, - {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f"}, - {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78"}, - {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953"}, - {file = "pydantic-1.10.13-cp38-cp38-win_amd64.whl", hash = "sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f"}, - {file = "pydantic-1.10.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6"}, - {file = "pydantic-1.10.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691"}, - {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd"}, - {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1"}, - {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96"}, - {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d"}, - {file = "pydantic-1.10.13-cp39-cp39-win_amd64.whl", hash = "sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d"}, - {file = "pydantic-1.10.13-py3-none-any.whl", hash = "sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687"}, - {file = "pydantic-1.10.13.tar.gz", hash = "sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340"}, + {file = "pydantic-1.10.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4fcec873f90537c382840f330b90f4715eebc2bc9925f04cb92de593eae054"}, + {file = "pydantic-1.10.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e3a76f571970fcd3c43ad982daf936ae39b3e90b8a2e96c04113a369869dc87"}, + {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d886bd3c3fbeaa963692ef6b643159ccb4b4cefaf7ff1617720cbead04fd1d"}, + {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:798a3d05ee3b71967844a1164fd5bdb8c22c6d674f26274e78b9f29d81770c4e"}, + {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:23d47a4b57a38e8652bcab15a658fdb13c785b9ce217cc3a729504ab4e1d6bc9"}, + {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f9f674b5c3bebc2eba401de64f29948ae1e646ba2735f884d1594c5f675d6f2a"}, + {file = "pydantic-1.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:24a7679fab2e0eeedb5a8924fc4a694b3bcaac7d305aeeac72dd7d4e05ecbebf"}, + {file = "pydantic-1.10.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d578ac4bf7fdf10ce14caba6f734c178379bd35c486c6deb6f49006e1ba78a7"}, + {file = "pydantic-1.10.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa7790e94c60f809c95602a26d906eba01a0abee9cc24150e4ce2189352deb1b"}, + {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad4e10efa5474ed1a611b6d7f0d130f4aafadceb73c11d9e72823e8f508e663"}, + {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245f4f61f467cb3dfeced2b119afef3db386aec3d24a22a1de08c65038b255f"}, + {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:21efacc678a11114c765eb52ec0db62edffa89e9a562a94cbf8fa10b5db5c046"}, + {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:412ab4a3f6dbd2bf18aefa9f79c7cca23744846b31f1d6555c2ee2b05a2e14ca"}, + {file = "pydantic-1.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:e897c9f35281f7889873a3e6d6b69aa1447ceb024e8495a5f0d02ecd17742a7f"}, + {file = "pydantic-1.10.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d604be0f0b44d473e54fdcb12302495fe0467c56509a2f80483476f3ba92b33c"}, + {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42c7d17706911199798d4c464b352e640cab4351efe69c2267823d619a937e5"}, + {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:596f12a1085e38dbda5cbb874d0973303e34227b400b6414782bf205cc14940c"}, + {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bfb113860e9288d0886e3b9e49d9cf4a9d48b441f52ded7d96db7819028514cc"}, + {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bc3ed06ab13660b565eed80887fcfbc0070f0aa0691fbb351657041d3e874efe"}, + {file = "pydantic-1.10.14-cp37-cp37m-win_amd64.whl", hash = "sha256:ad8c2bc677ae5f6dbd3cf92f2c7dc613507eafe8f71719727cbc0a7dec9a8c01"}, + {file = "pydantic-1.10.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c37c28449752bb1f47975d22ef2882d70513c546f8f37201e0fec3a97b816eee"}, + {file = "pydantic-1.10.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49a46a0994dd551ec051986806122767cf144b9702e31d47f6d493c336462597"}, + {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e3819bd20a42470d6dd0fe7fc1c121c92247bca104ce608e609b59bc7a77ee"}, + {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbb503bbbbab0c588ed3cd21975a1d0d4163b87e360fec17a792f7d8c4ff29f"}, + {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:336709883c15c050b9c55a63d6c7ff09be883dbc17805d2b063395dd9d9d0022"}, + {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4ae57b4d8e3312d486e2498d42aed3ece7b51848336964e43abbf9671584e67f"}, + {file = "pydantic-1.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:dba49d52500c35cfec0b28aa8b3ea5c37c9df183ffc7210b10ff2a415c125c4a"}, + {file = "pydantic-1.10.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c66609e138c31cba607d8e2a7b6a5dc38979a06c900815495b2d90ce6ded35b4"}, + {file = "pydantic-1.10.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d986e115e0b39604b9eee3507987368ff8148222da213cd38c359f6f57b3b347"}, + {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:646b2b12df4295b4c3148850c85bff29ef6d0d9621a8d091e98094871a62e5c7"}, + {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282613a5969c47c83a8710cc8bfd1e70c9223feb76566f74683af889faadc0ea"}, + {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:466669501d08ad8eb3c4fecd991c5e793c4e0bbd62299d05111d4f827cded64f"}, + {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:13e86a19dca96373dcf3190fcb8797d40a6f12f154a244a8d1e8e03b8f280593"}, + {file = "pydantic-1.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:08b6ec0917c30861e3fe71a93be1648a2aa4f62f866142ba21670b24444d7fd8"}, + {file = "pydantic-1.10.14-py3-none-any.whl", hash = "sha256:8ee853cd12ac2ddbf0ecbac1c289f95882b2d4482258048079d13be700aa114c"}, + {file = "pydantic-1.10.14.tar.gz", hash = "sha256:46f17b832fe27de7850896f3afee50ea682220dd218f7e9c88d436788419dca6"}, ] [package.dependencies] From 56ac3afb054711095e94754adca157870e50eb9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jan 2024 02:20:09 +0000 Subject: [PATCH 17/33] Bump pyspellchecker from 0.8.0 to 0.8.1 (#1621) --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index b23f4b6df..3ddbfebbc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3224,13 +3224,13 @@ files = [ [[package]] name = "pyspellchecker" -version = "0.8.0" +version = "0.8.1" description = "Pure python spell checker based on work by Peter Norvig" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pyspellchecker-0.8.0-py3-none-any.whl", hash = "sha256:6a06129c38ff23ae2e250d4a3e7a7cebb990496a3c0fe60b28cc4e8c09312167"}, - {file = "pyspellchecker-0.8.0.tar.gz", hash = "sha256:0c13f129a18fb13dd028d1da9f3197f838cb6ec68b67a89092fe8406b2ec3170"}, + {file = "pyspellchecker-0.8.1-py3-none-any.whl", hash = "sha256:d91e9e1064793ae1ee8e71b06ca40eeb8e5923437c54291a8e041b447792b640"}, + {file = "pyspellchecker-0.8.1.tar.gz", hash = "sha256:3478ca8484d1c2db0c93d12b3c986cd17958c69f47b3ed7ef4d3f4201e591776"}, ] [[package]] @@ -4500,4 +4500,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "03834f6200fc9dc975c6f182f50ad09ceb01c9d4a1dabed5478a59201e308d64" +content-hash = "6ee1fee2f1e8df6c286e1c02eed6b54cc93da373bb5459e98944e519dc166cf7" diff --git a/pyproject.toml b/pyproject.toml index bb1c2e48b..0b3fa3119 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,7 +224,7 @@ PyLD = "2.0.3" pymarc = "5.1.1" pyOpenSSL = "^24.0.0" pyparsing = "3.1.1" -pyspellchecker = "0.8.0" +pyspellchecker = "0.8.1" python = ">=3.10,<4" python-dateutil = "2.8.2" python3-saml = "^1.16" # python-saml is required for SAML authentication From 92f669b816a99df005bd8cca7969b73e4894bf0f Mon Sep 17 00:00:00 2001 From: dbernstein Date: Fri, 26 Jan 2024 10:05:59 -0800 Subject: [PATCH 18/33] Fix bug in custom list sharing log line. (#1631) Partially resolves: https://ebce-lyrasis.atlassian.net/browse/PP-708 --- core/query/customlist.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/core/query/customlist.py b/core/query/customlist.py index fb3a4ab87..1098cbecb 100644 --- a/core/query/customlist.py +++ b/core/query/customlist.py @@ -36,7 +36,7 @@ def share_locally_with_library( for collection in customlist.collections: if collection not in library.collections: log.info( - f"Unable to share: Collection '{collection.name}' is missing from the library." + f"Unable to share customlist: Collection '{collection.name}' is missing from the library." ) return CUSTOMLIST_SOURCE_COLLECTION_MISSING @@ -53,12 +53,20 @@ def share_locally_with_library( .first() ) if valid_license is None: - log.info(f"Unable to share: No license for work '{entry.work.title}'.") + if entry.work: + log.info( + f"Unable to share customlist: No license for work '{entry.work.title}'." + ) + else: + log.info( + f"Unable to share customlist: No work associated with custom list entry where entry.id = {entry.id}" + ) + return CUSTOMLIST_ENTRY_NOT_VALID_FOR_LIBRARY customlist.shared_locally_with_libraries.append(library) log.info( - f"Successfully shared '{customlist.name}' with library '{library.name}'." + f"Successfully shared customlist '{customlist.name}' with library '{library.name}'." ) return True From f6397f4a678541f6a1229358ee600d73da3c760e Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 26 Jan 2024 14:30:56 -0400 Subject: [PATCH 19/33] Refactor tests for several of the admin controllers (PP-497) (#1632) * Refactor our controller tests. --- .../controller/test_collection_self_tests.py | 19 +- .../api/admin/controller/test_collections.py | 330 +++++----- .../test_metadata_service_self_tests.py | 150 +++-- .../controller/test_metadata_services.py | 489 ++++++++------ .../api/admin/controller/test_patron_auth.py | 595 +++++++++--------- .../controller/test_patron_auth_self_tests.py | 65 +- tests/fixtures/flask.py | 67 +- 7 files changed, 970 insertions(+), 745 deletions(-) diff --git a/tests/api/admin/controller/test_collection_self_tests.py b/tests/api/admin/controller/test_collection_self_tests.py index f48a3c12a..d2c8b8fb4 100644 --- a/tests/api/admin/controller/test_collection_self_tests.py +++ b/tests/api/admin/controller/test_collection_self_tests.py @@ -16,6 +16,7 @@ from core.util.problem_detail import ProblemDetail, ProblemError from tests.api.mockapi.axis import MockAxis360API from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture @pytest.fixture @@ -50,16 +51,18 @@ def test_collection_self_tests_with_unknown_protocol( assert excinfo.value.problem_detail == UNKNOWN_PROTOCOL def test_collection_self_tests_with_unsupported_protocol( - self, db: DatabaseTransactionFixture, controller: CollectionSelfTestsController + self, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture ): registry = LicenseProvidersRegistry() registry.register(object, canonical="mock_api") # type: ignore[arg-type] collection = db.collection(protocol="mock_api") controller = CollectionSelfTestsController(db.session, registry) assert collection.integration_configuration.id is not None - result = controller.self_tests_process_get( - collection.integration_configuration.id - ) + + with flask_app_fixture.test_request_context_system_admin("/"): + result = controller.self_tests_process_get( + collection.integration_configuration.id + ) assert result.status_code == 200 assert isinstance(result.json, dict) @@ -72,6 +75,7 @@ def test_collection_self_tests_test_get( self, db: DatabaseTransactionFixture, controller: CollectionSelfTestsController, + flask_app_fixture: FlaskAppFixture, monkeypatch: MonkeyPatch, ): collection = MockAxis360API.mock_collection( @@ -93,9 +97,10 @@ def test_collection_self_tests_test_get( # Make sure that HasSelfTest.prior_test_results() was called and that # it is in the response's collection object. assert collection.integration_configuration.id is not None - response = controller.self_tests_process_get( - collection.integration_configuration.id - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.self_tests_process_get( + collection.integration_configuration.id + ) data = response.json assert isinstance(data, dict) diff --git a/tests/api/admin/controller/test_collections.py b/tests/api/admin/controller/test_collections.py index 6685cbdb8..0d877b572 100644 --- a/tests/api/admin/controller/test_collections.py +++ b/tests/api/admin/controller/test_collections.py @@ -1,10 +1,12 @@ import json +from unittest.mock import MagicMock, create_autospec import flask import pytest from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, @@ -20,47 +22,79 @@ UNKNOWN_PROTOCOL, ) from api.integration.registry.license_providers import LicenseProvidersRegistry -from core.model import ( - Admin, - AdminRole, - Collection, - ExternalIntegration, - create, - get_one, -) +from core.model import AdminRole, Collection, ExternalIntegration, get_one from core.util.problem_detail import ProblemDetail -from tests.fixtures.api_admin import AdminControllerFixture from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> CollectionSettingsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return CollectionSettingsController(mock_manager) class TestCollectionSettings: + def test_process_collections( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + ): + # Make sure when we call process_collections with a get request that + # we call process_get and when we call it with a post request that + # we call process_post. + + mock_process_get = create_autospec( + controller.process_get, return_value="get_response" + ) + controller.process_get = mock_process_get + + mock_process_post = create_autospec( + controller.process_post, return_value="post_response" + ) + controller.process_post = mock_process_post + + with flask_app_fixture.test_request_context("/"): + response = controller.process_collections() + assert response == "get_response" + + assert mock_process_get.call_count == 1 + assert mock_process_post.call_count == 0 + + mock_process_get.reset_mock() + mock_process_post.reset_mock() + + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_collections() + assert response == "post_response" + + assert mock_process_get.call_count == 0 + assert mock_process_post.call_count == 1 + def test_collections_get_with_no_collections( - self, admin_ctrl_fixture: AdminControllerFixture + self, controller: CollectionSettingsController, db: DatabaseTransactionFixture ) -> None: - db = admin_ctrl_fixture.ctrl.db # Delete any existing collections created by the test setup. db.session.delete(db.default_collection()) - with admin_ctrl_fixture.request_context_with_admin("/"): - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - assert data.get("collections") == [] + response = controller.process_get() + assert isinstance(response, Response) + assert response.status_code == 200 + data = response.json + assert isinstance(data, dict) + assert data.get("collections") == [] - names = {p.get("name") for p in data.get("protocols", {})} - expected_names = {k for k, v in LicenseProvidersRegistry()} - assert names == expected_names + names = {p.get("name") for p in data.get("protocols", {})} + expected_names = {k for k, v in LicenseProvidersRegistry()} + assert names == expected_names def test_collections_get_collections_with_multiple_collections( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ) -> None: - session = admin_ctrl_fixture.ctrl.db.session - db = admin_ctrl_fixture.ctrl.db - [c1] = db.default_library().collections c2 = db.collection( @@ -87,76 +121,71 @@ def test_collections_get_collections_with_multiple_collections( l1_config = c3.integration_configuration.for_library(l1.id) assert l1_config is not None DatabaseTransactionFixture.set_settings(l1_config, ebook_loan_duration="14") - # Commit the config changes - session.commit() - l1_librarian, ignore = create(session, Admin, email="admin@l1.org") - l1_librarian.add_role(AdminRole.LIBRARIAN, l1) - - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + admin = flask_app_fixture.admin_user() + l1_librarian = flask_app_fixture.admin_user( + email="admin@l1.org", role=AdminRole.LIBRARIAN, library=l1 + ) - with admin_ctrl_fixture.request_context_with_admin("/"): - controller = admin_ctrl_fixture.manager.admin_collection_settings_controller - response = controller.process_collections() - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - # The system admin can see all collections. - coll2, coll3, coll1 = sorted( - data.get("collections", []), key=lambda c: c.get("name", "") - ) - assert c1.integration_configuration.id == coll1.get("id") - assert c2.integration_configuration.id == coll2.get("id") - assert c3.integration_configuration.id == coll3.get("id") + with flask_app_fixture.test_request_context("/", admin=admin): + response1 = controller.process_get() + assert isinstance(response1, Response) + assert response1.status_code == 200 + data = response1.json + assert isinstance(data, dict) + # The system admin can see all collections. + coll2, coll3, coll1 = sorted( + data.get("collections", []), key=lambda c: c.get("name", "") + ) + assert c1.integration_configuration.id == coll1.get("id") + assert c2.integration_configuration.id == coll2.get("id") + assert c3.integration_configuration.id == coll3.get("id") - assert c1.name == coll1.get("name") - assert c2.name == coll2.get("name") - assert c3.name == coll3.get("name") + assert c1.name == coll1.get("name") + assert c2.name == coll2.get("name") + assert c3.name == coll3.get("name") - assert c1.protocol == coll1.get("protocol") - assert c2.protocol == coll2.get("protocol") - assert c3.protocol == coll3.get("protocol") + assert c1.protocol == coll1.get("protocol") + assert c2.protocol == coll2.get("protocol") + assert c3.protocol == coll3.get("protocol") - settings1 = coll1.get("settings", {}) - settings2 = coll2.get("settings", {}) - settings3 = coll3.get("settings", {}) + settings1 = coll1.get("settings", {}) + settings2 = coll2.get("settings", {}) + settings3 = coll3.get("settings", {}) - assert ( - settings1.get("external_account_id") == "http://opds.example.com/feed" - ) - assert settings2.get("external_account_id") == "1234" - assert settings3.get("external_account_id") == "5678" + assert settings1.get("external_account_id") == "http://opds.example.com/feed" + assert settings2.get("external_account_id") == "1234" + assert settings3.get("external_account_id") == "5678" - assert c2.integration_configuration.settings_dict[ - "overdrive_client_secret" - ] == settings2.get("overdrive_client_secret") + assert c2.integration_configuration.settings_dict[ + "overdrive_client_secret" + ] == settings2.get("overdrive_client_secret") - assert c2.integration_configuration.id == coll3.get("parent_id") + assert c2.integration_configuration.id == coll3.get("parent_id") - coll3_libraries = coll3.get("libraries") - assert 2 == len(coll3_libraries) - coll3_l1, coll3_default = sorted( - coll3_libraries, key=lambda x: x.get("short_name") - ) - assert "L1" == coll3_l1.get("short_name") - assert "14" == coll3_l1.get("ebook_loan_duration") - assert db.default_library().short_name == coll3_default.get("short_name") + coll3_libraries = coll3.get("libraries") + assert 2 == len(coll3_libraries) + coll3_l1, coll3_default = sorted( + coll3_libraries, key=lambda x: x.get("short_name") + ) + assert "L1" == coll3_l1.get("short_name") + assert "14" == coll3_l1.get("ebook_loan_duration") + assert db.default_library().short_name == coll3_default.get("short_name") - with admin_ctrl_fixture.request_context_with_admin("/", admin=l1_librarian): + with flask_app_fixture.test_request_context("/", admin=l1_librarian): # A librarian only sees collections associated with their library. - response = controller.process_collections() - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - [coll3] = data.get("collections", []) - assert c3.integration_configuration.id == coll3.get("id") - - coll3_libraries = coll3.get("libraries") - assert 1 == len(coll3_libraries) - assert "L1" == coll3_libraries[0].get("short_name") - assert "14" == coll3_libraries[0].get("ebook_loan_duration") + response2 = controller.process_collections() + assert isinstance(response2, Response) + assert response2.status_code == 200 + data = response2.json + assert isinstance(data, dict) + [coll3] = data.get("collections", []) + assert c3.integration_configuration.id == coll3.get("id") + + coll3_libraries = coll3.get("libraries") + assert 1 == len(coll3_libraries) + assert "L1" == coll3_libraries[0].get("short_name") + assert "14" == coll3_libraries[0].get("ebook_loan_duration") @pytest.mark.parametrize( "post_data,expected,detailed", @@ -269,25 +298,23 @@ def test_collections_get_collections_with_multiple_collections( ) def test_collections_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, post_data: dict[str, str], expected: ProblemDetail, detailed: bool, ): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - - collection = admin_ctrl_fixture.ctrl.db.collection( + collection = db.collection( name="Collection 1", protocol=ExternalIntegration.OVERDRIVE ) if "id" in post_data and post_data["id"] == "": post_data["id"] = str(collection.integration_configuration.id) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict(post_data) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() if detailed: assert isinstance(response, ProblemDetail) @@ -297,9 +324,11 @@ def test_collections_post_errors( assert response == expected def test_collections_post_errors_no_permissions( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Collection 1"), @@ -308,12 +337,15 @@ def test_collections_post_errors_no_permissions( ) pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections, + controller.process_collections, ) - def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixture): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db + def test_collections_post_create( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): l1 = db.library( name="Library 1", short_name="L1", @@ -327,7 +359,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur short_name="L3", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "New Collection"), @@ -347,9 +379,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur ("overdrive_website_id", "1234"), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert isinstance(response, Response) assert response.status_code == 201 @@ -397,7 +427,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur assert "l2_ils" == l2_settings.settings_dict["ils_name"] # This collection will be a child of the first collection. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Child Collection"), @@ -410,9 +440,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur ("external_account_id", "child-acctid"), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert isinstance(response, Response) assert response.status_code == 201 @@ -438,10 +466,13 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur assert l3_settings is not None assert "l3_ils" == l3_settings.settings_dict["ils_name"] - def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture): + def test_collections_post_edit( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): # The collection exists. - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db collection = db.collection( name="Collection 1", protocol=ExternalIntegration.OVERDRIVE ) @@ -451,7 +482,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) short_name="L1", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -468,9 +499,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -487,7 +516,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ) # A library now has access to the collection. - assert [collection] == l1.collections + assert collection.libraries == [l1] # Additional settings were set on the collection. assert "1234" == collection.integration_configuration.settings_dict.get( @@ -498,7 +527,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) assert l1_settings is not None assert "the_ils" == l1_settings.settings_dict.get("ils_name") - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -511,9 +540,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -526,7 +553,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) assert ExternalIntegration.OVERDRIVE == collection.protocol # But the library has been removed. - assert [] == l1.collections + assert collection.libraries == [] # All ConfigurationSettings for that library and collection # have been deleted. @@ -534,7 +561,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) parent = db.collection(name="Parent", protocol=ExternalIntegration.OVERDRIVE) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -545,9 +572,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -560,7 +585,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) collection2 = db.collection( name="Collection 2", protocol=ExternalIntegration.ODL ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection2.integration_configuration.id)), @@ -585,13 +610,10 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) - admin_ctrl_fixture.ctrl.db.session.refresh(collection2) assert len(collection2.integration_configuration.library_configurations) == 1 # The library configuration value was correctly coerced to int assert ( @@ -602,11 +624,12 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ) def test_collections_post_edit_library_specific_configuration( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): # The collection exists. - db = admin_ctrl_fixture.ctrl.db - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) collection = db.collection( name="Collection 1", protocol=ExternalIntegration.AXIS_360 ) @@ -616,7 +639,7 @@ def test_collections_post_edit_library_specific_configuration( short_name="L1", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -632,9 +655,7 @@ def test_collections_post_edit_library_specific_configuration( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 # Additional settings were set on the collection+library. @@ -644,7 +665,7 @@ def test_collections_post_edit_library_specific_configuration( assert "14" == l1_settings.settings_dict.get("ebook_loan_duration") # Remove the connection between collection and library. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -657,9 +678,7 @@ def test_collections_post_edit_library_specific_configuration( ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -671,21 +690,25 @@ def test_collections_post_edit_library_specific_configuration( assert collection.integration_configuration.for_library(l1.id) is None assert [] == collection.libraries - def test_collection_delete(self, admin_ctrl_fixture: AdminControllerFixture): - db = admin_ctrl_fixture.ctrl.db + def test_collection_delete( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): collection = db.collection() assert collection.marked_for_deletion is False - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete, + controller.process_delete, collection.integration_configuration.id, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert collection.integration_configuration.id is not None - response = admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete( + response = controller.process_delete( collection.integration_configuration.id ) assert response.status_code == 200 @@ -699,17 +722,16 @@ def test_collection_delete(self, admin_ctrl_fixture: AdminControllerFixture): assert fetched_collection.marked_for_deletion is True def test_collection_delete_cant_delete_parent( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db parent = db.collection(protocol=ExternalIntegration.OVERDRIVE) child = db.collection(protocol=ExternalIntegration.OVERDRIVE) child.parent = parent - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert parent.integration_configuration.id is not None - response = admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete( - parent.integration_configuration.id - ) + response = controller.process_delete(parent.integration_configuration.id) assert response == CANNOT_DELETE_COLLECTION_WITH_CHILDREN diff --git a/tests/api/admin/controller/test_metadata_service_self_tests.py b/tests/api/admin/controller/test_metadata_service_self_tests.py index d1f056035..d1ed875ed 100644 --- a/tests/api/admin/controller/test_metadata_service_self_tests.py +++ b/tests/api/admin/controller/test_metadata_service_self_tests.py @@ -1,47 +1,86 @@ +from unittest.mock import MagicMock, create_autospec + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from flask import Response + +from api.admin.controller.metadata_service_self_tests import ( + MetadataServiceSelfTestsController, +) from api.admin.problem_details import * from api.nyt import NYTBestSellerAPI -from core.model import ExternalIntegration, create -from core.selftest import HasSelfTests +from core.util.problem_detail import ProblemDetail +from tests.api.admin.controller.test_metadata_services import MetadataServicesFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +class MetadataServiceSelfTestsFixture(MetadataServicesFixture): + def __init__(self, db: DatabaseTransactionFixture): + super().__init__(db) + manager = MagicMock() + manager._db = db.session + self.controller: MetadataServiceSelfTestsController = ( + MetadataServiceSelfTestsController(manager) + ) + self.db = db + + +@pytest.fixture +def metadata_services_fixture( + db: DatabaseTransactionFixture, +) -> MetadataServiceSelfTestsFixture: + return MetadataServiceSelfTestsFixture(db) class TestMetadataServiceSelfTests: def test_metadata_service_self_tests_with_no_identifier( - self, settings_ctrl_fixture + self, metadata_services_fixture: MetadataServiceSelfTestsFixture ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( + response = ( + metadata_services_fixture.controller.process_metadata_service_self_tests( None ) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 + ) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 def test_metadata_service_self_tests_with_no_metadata_service_found( - self, settings_ctrl_fixture + self, + metadata_services_fixture: MetadataServiceSelfTestsFixture, + flask_app_fixture: FlaskAppFixture, ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( -1 ) - assert response == MISSING_SERVICE - assert response.status_code == 404 + assert response == MISSING_SERVICE + assert response.status_code == 404 - def test_metadata_service_self_tests_test_get(self, settings_ctrl_fixture): - old_prior_test_results = HasSelfTests.prior_test_results - HasSelfTests.prior_test_results = settings_ctrl_fixture.mock_prior_test_results - metadata_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, + def test_metadata_service_self_tests_test_get( + self, + metadata_services_fixture: MetadataServiceSelfTestsFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + mock_prior_test_results = create_autospec( + NYTBestSellerAPI.prior_test_results, return_value={"test": "results"} ) + monkeypatch.setattr( + NYTBestSellerAPI, "prior_test_results", mock_prior_test_results + ) + # Make sure that HasSelfTest.prior_test_results() was called and that # it is in the response's self tests object. - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( + with flask_app_fixture.test_request_context("/"): + response_data = metadata_services_fixture.controller.process_metadata_service_self_tests( metadata_service.id ) - response_metadata_service = response.get("self_test_results") + assert isinstance(response_data, dict) + response_metadata_service = response_data.get("self_test_results", {}) assert response_metadata_service.get("id") == metadata_service.id assert response_metadata_service.get("name") == metadata_service.name @@ -50,45 +89,32 @@ def test_metadata_service_self_tests_test_get(self, settings_ctrl_fixture): == NYTBestSellerAPI.NAME ) assert response_metadata_service.get("goal") == metadata_service.goal - assert ( - response_metadata_service.get("self_test_results") - == HasSelfTests.prior_test_results() - ) - HasSelfTests.prior_test_results = old_prior_test_results - - def test_metadata_service_self_tests_post(self, settings_ctrl_fixture): - old_run_self_tests = HasSelfTests.run_self_tests - HasSelfTests.run_self_tests = settings_ctrl_fixture.mock_run_self_tests + assert response_metadata_service.get("self_test_results") == { + "test": "results" + } - metadata_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, - ) - m = ( - settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.self_tests_process_post + def test_metadata_service_self_tests_post( + self, + metadata_services_fixture: MetadataServiceSelfTestsFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + db: DatabaseTransactionFixture, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + mock_run_self_tests = create_autospec( + NYTBestSellerAPI.run_self_tests, return_value=(dict(test="results"), None) ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - response = m(metadata_service.id) - assert response._status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) + monkeypatch.setattr(NYTBestSellerAPI, "run_self_tests", mock_run_self_tests) - positional, keyword = settings_ctrl_fixture.run_self_tests_called_with - # run_self_tests was called with positional arguments: - # * The database connection - # * The method to call to instantiate a HasSelfTests implementation - # (NYTBestSellerAPI.from_config) - # * The database connection again (to be passed into - # NYTBestSellerAPI.from_config). - assert ( - settings_ctrl_fixture.ctrl.db.session, - NYTBestSellerAPI.from_config, - settings_ctrl_fixture.ctrl.db.session, - ) == positional - - # run_self_tests was not called with any keyword arguments. - assert {} == keyword + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, Response) + assert response.status_code == 200 + assert "Successfully ran new self tests" == response.get_data(as_text=True) - # Undo the mock. - HasSelfTests.run_self_tests = old_run_self_tests + mock_run_self_tests.assert_called_once_with( + db.session, NYTBestSellerAPI.from_config, db.session + ) diff --git a/tests/api/admin/controller/test_metadata_services.py b/tests/api/admin/controller/test_metadata_services.py index ba62edcf4..2afd3c14f 100644 --- a/tests/api/admin/controller/test_metadata_services.py +++ b/tests/api/admin/controller/test_metadata_services.py @@ -1,13 +1,16 @@ import json +from unittest.mock import MagicMock import flask import pytest -from werkzeug.datastructures import MultiDict +from flask import Response +from werkzeug.datastructures import ImmutableMultiDict from api.admin.controller.metadata_services import MetadataServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + DUPLICATE_INTEGRATION, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, MISSING_SERVICE, @@ -15,101 +18,137 @@ NO_SUCH_LIBRARY, UNKNOWN_PROTOCOL, ) -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI -from core.model import AdminRole, ExternalIntegration, create, get_one - - -class TestMetadataServices: - def create_service(self, name, db_session): - return create( - db_session, - ExternalIntegration, - protocol=ExternalIntegration.__dict__.get(name) or "fake", +from core.model import ExternalIntegration, IntegrationConfiguration, create, get_one +from core.util.problem_detail import ProblemDetail +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +class MetadataServicesFixture: + def __init__(self, db: DatabaseTransactionFixture): + novelist_protocol = ExternalIntegration.NOVELIST + assert novelist_protocol is not None + self.novelist_protocol = novelist_protocol + + nyt_protocol = ExternalIntegration.NYT + assert nyt_protocol is not None + self.nyt_protocol = nyt_protocol + + manager = MagicMock() + manager._db = db.session + self.controller = MetadataServicesController(manager) + self.db = db + + def create_novelist_integration( + self, + username: str = "user", + password: str = "pass", + ) -> ExternalIntegration: + integration = self.db.external_integration( + protocol=self.novelist_protocol, goal=ExternalIntegration.METADATA_GOAL, - )[0] - - def test_process_metadata_services_dispatches_by_request_method( - self, settings_ctrl_fixture - ): - class Mock(MetadataServicesController): - def process_get(self): - return "GET" + ) + integration.username = username + integration.password = password + return integration + + def create_nyt_integration( + self, + api_key: str = "xyz", + ) -> ExternalIntegration: + integration = self.db.external_integration( + protocol=self.nyt_protocol, + goal=ExternalIntegration.METADATA_GOAL, + ) + integration.password = api_key + return integration - def process_post(self): - return "POST" - controller = Mock(settings_ctrl_fixture.manager) - with settings_ctrl_fixture.request_context_with_admin("/"): - assert "GET" == controller.process_metadata_services() +@pytest.fixture +def metadata_services_fixture( + db: DatabaseTransactionFixture, +) -> MetadataServicesFixture: + return MetadataServicesFixture(db) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - assert "POST" == controller.process_metadata_services() - # This is also where permissions are checked. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() +class TestMetadataServices: + def test_process_metadata_services_dispatches_by_request_method( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = metadata_services_fixture.controller - with settings_ctrl_fixture.request_context_with_admin("/"): + # Make sure permissions are checked. + with flask_app_fixture.test_request_context("/"): pytest.raises(AdminNotAuthorized, controller.process_metadata_services) - def test_process_get_with_no_services(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.process_get() - ) - assert response.get("metadata_services") == [] - protocols = response.get("protocols") - assert NoveListAPI.NAME in [p.get("label") for p in protocols] - assert "settings" in protocols[0] - - def test_process_get_with_one_service(self, settings_ctrl_fixture): - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session - ) - novelist_service.username = "user" - novelist_service.password = "pass" - - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = controller.process_get() - [service] = response.get("metadata_services") - - assert novelist_service.id == service.get("id") - assert ExternalIntegration.NOVELIST == service.get("protocol") - assert "user" == service.get("settings").get(ExternalIntegration.USERNAME) - assert "pass" == service.get("settings").get(ExternalIntegration.PASSWORD) - - novelist_service.libraries += [settings_ctrl_fixture.ctrl.db.default_library()] - with settings_ctrl_fixture.request_context_with_admin("/"): - response = controller.process_get() - [service] = response.get("metadata_services") - - assert "user" == service.get("settings").get(ExternalIntegration.USERNAME) - [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) - - def test_find_protocol_class(self, settings_ctrl_fixture): - [nyt, novelist, fake] = [ - self.create_service(x, settings_ctrl_fixture.ctrl.db.session) - for x in ["NYT", "NOVELIST", "FAKE"] - ] - m = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.find_protocol_class - ) - - assert m(nyt)[0] == NYTBestSellerAPI - assert m(novelist)[0] == NoveListAPI - pytest.raises(NotImplementedError, m, fake) - - def test_metadata_services_post_errors(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + # Mock out the process_get and process_post methods so we can + # verify that they're called. + controller.process_get = MagicMock() + controller.process_post = MagicMock() + + with flask_app_fixture.test_request_context_system_admin("/"): + controller.process_metadata_services() + controller.process_get.assert_called_once() + controller.process_post.assert_not_called() + + controller.process_get = MagicMock() + controller.process_post = MagicMock() + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + controller.process_metadata_services() + controller.process_get.assert_not_called() + controller.process_post.assert_called_once() + + def test_process_get_with_no_services( + self, metadata_services_fixture: MetadataServicesFixture + ): + response_content = metadata_services_fixture.controller.process_get() + assert isinstance(response_content, dict) + assert response_content.get("metadata_services") == [] + [nyt, novelist] = response_content.get("protocols", []) + + assert novelist.get("name") == metadata_services_fixture.novelist_protocol + assert "settings" in novelist + assert novelist.get("sitewide") is False + + assert nyt.get("name") == metadata_services_fixture.nyt_protocol + assert "settings" in nyt + assert nyt.get("sitewide") is True + + def test_process_get_with_one_service( + self, + metadata_services_fixture: MetadataServicesFixture, + db: DatabaseTransactionFixture, + ): + novelist_service = metadata_services_fixture.create_novelist_integration() + controller = metadata_services_fixture.controller + + response_data = controller.process_get() + assert isinstance(response_data, dict) + [service] = response_data.get("metadata_services", []) + + assert service.get("id") == novelist_service.id + assert service.get("protocol") == metadata_services_fixture.novelist_protocol + assert service.get("settings").get("username") == "user" + assert service.get("settings").get("password") == "pass" + + novelist_service.libraries += [db.default_library()] + response_data = controller.process_get() + assert isinstance(response_data, dict) + [service] = response_data.get("metadata_services", []) + + [library] = service.get("libraries") + assert library.get("short_name") == db.default_library().short_name + + def test_metadata_services_post_errors( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ("protocol", "Unknown"), @@ -118,204 +157,300 @@ def test_metadata_services_post_errors(self, settings_ctrl_fixture): response = controller.process_post() assert response == UNKNOWN_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([]) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict([]) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == INCOMPLETE_CONFIGURATION - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == NO_PROTOCOL_FOR_NEW_SERVICE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ("id", "123"), - ("protocol", ExternalIntegration.NYT), + ("protocol", metadata_services_fixture.novelist_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == MISSING_SERVICE - service = self.create_service("NOVELIST", settings_ctrl_fixture.ctrl.db.session) + service = metadata_services_fixture.create_novelist_integration() service.name = "name" - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ - ("name", service.name), - ("protocol", ExternalIntegration.NYT), + ("name", str(service.name)), + ("protocol", metadata_services_fixture.nyt_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == INTEGRATION_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.NYT), + ("id", str(service.id)), + ("protocol", metadata_services_fixture.nyt_protocol), ] ) response = controller.process_post() assert response == CANNOT_CHANGE_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ - ("id", service.id), - ("protocol", ExternalIntegration.NOVELIST), + ("id", str(service.id)), + ("protocol", metadata_services_fixture.novelist_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("id", str(service.id)), + ("protocol", str(service.protocol)), + ("username", "user"), + ("password", "pass"), ("libraries", json.dumps([{"short_name": "not-a-library"}])), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response.uri == NO_SUCH_LIBRARY.uri - def test_metadata_services_post_create(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - library = settings_ctrl_fixture.ctrl.db.library( + def test_metadata_services_post_create( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + controller = metadata_services_fixture.controller + library = db.library( name="Library", short_name="L", ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("protocol", metadata_services_fixture.novelist_protocol), + ("username", "user"), + ("password", "pass"), ("libraries", json.dumps([{"short_name": "L"}])), ] ) response = controller.process_post() + assert isinstance(response, Response) assert response.status_code == 201 - # A new ExternalIntegration has been created based on the submitted + # A new IntegrationConfiguration has been created based on the submitted # information. service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, ExternalIntegration, goal=ExternalIntegration.METADATA_GOAL, ) - assert service.id == int(response.response[0]) - assert ExternalIntegration.NOVELIST == service.protocol - assert "user" == service.username - assert "pass" == service.password - assert [library] == service.libraries - - def test_metadata_services_post_edit(self, settings_ctrl_fixture): - l1 = settings_ctrl_fixture.ctrl.db.library( + assert service is not None + assert service.id == int(response.get_data(as_text=True)) + assert service.protocol == metadata_services_fixture.novelist_protocol + assert service.username == "user" + assert service.password == "pass" + assert service.libraries == [library] + + def test_metadata_services_post_create_multiple( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = metadata_services_fixture.controller + metadata_services_fixture.create_novelist_integration() + metadata_services_fixture.create_nyt_integration() + + # If we try to create a second NYT service, we'll get an error. + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "Name"), + ("protocol", metadata_services_fixture.nyt_protocol), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, ProblemDetail) + assert response == DUPLICATE_INTEGRATION + + # However we can create a second NoveList service. + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "Name"), + ("protocol", metadata_services_fixture.novelist_protocol), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 201 + + def test_metadata_services_post_edit( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + l1 = db.library( name="Library 1", short_name="L1", ) - l2 = settings_ctrl_fixture.ctrl.db.library( + l2 = db.library( name="Library 2", short_name="L2", ) - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session + novelist_service = metadata_services_fixture.create_novelist_integration( + username="olduser", password="oldpass" ) - novelist_service.username = "olduser" - novelist_service.password = "oldpass" novelist_service.libraries = [l1] - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", novelist_service.id), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("id", str(novelist_service.id)), + ("protocol", str(novelist_service.protocol)), + ("username", "newuser"), + ("password", "newpass"), ("libraries", json.dumps([{"short_name": "L2"}])), ] ) response = controller.process_post() assert response.status_code == 200 - def test_check_name_unique(self, settings_ctrl_fixture): - kwargs = dict( - protocol=ExternalIntegration.NYT, goal=ExternalIntegration.METADATA_GOAL - ) - + # The existing integration has been updated based on the submitted + # information. + assert novelist_service.username == "newuser" + assert novelist_service.password == "newpass" + assert novelist_service.libraries == [l2] + + def test_check_name_unique( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): existing_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, + db.session, ExternalIntegration, name="existing service", - **kwargs + protocol=ExternalIntegration.NYT, + goal=ExternalIntegration.METADATA_GOAL, ) new_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, + db.session, ExternalIntegration, name="new service", - **kwargs - ) - - m = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.check_name_unique + protocol=ExternalIntegration.NYT, + goal=ExternalIntegration.METADATA_GOAL, ) # Try to change new service so that it has the same name as existing service # -- this is not allowed. - result = m(new_service, existing_service.name) - assert result == INTEGRATION_NAME_ALREADY_IN_USE + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(existing_service.name)), + ("id", str(new_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, ProblemDetail) + assert response == INTEGRATION_NAME_ALREADY_IN_USE # Try to edit existing service without changing its name -- this is fine. - assert None == m(existing_service, existing_service.name) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(existing_service.name)), + ("id", str(existing_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 200 # Changing the existing service's name is also fine. - assert None == m(existing_service, "new name") + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "New Name"), + ("id", str(existing_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 200 - def test_metadata_service_delete(self, settings_ctrl_fixture): - l1 = settings_ctrl_fixture.ctrl.db.library( + def test_metadata_service_delete( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + l1 = db.library( name="Library 1", short_name="L1", ) - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session + novelist_service = metadata_services_fixture.create_novelist_integration( + username="olduser", password="oldpass" ) - novelist_service.username = "olduser" - novelist_service.password = "oldpass" novelist_service.libraries = [l1] - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_metadata_services_controller.process_delete, + controller.process_delete, novelist_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_metadata_services_controller.process_delete( - novelist_service.id - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + service_id = novelist_service.id + assert isinstance(service_id, int) + response = controller.process_delete(service_id) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, + db.session, + IntegrationConfiguration, id=novelist_service.id, ) - assert None == service + assert service is None diff --git a/tests/api/admin/controller/test_patron_auth.py b/tests/api/admin/controller/test_patron_auth.py index 26a8416f8..5fd50e8eb 100644 --- a/tests/api/admin/controller/test_patron_auth.py +++ b/tests/api/admin/controller/test_patron_auth.py @@ -1,8 +1,7 @@ from __future__ import annotations import json -from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from unittest.mock import MagicMock import flask @@ -11,6 +10,7 @@ from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, @@ -35,54 +35,20 @@ from api.simple_authentication import SimpleAuthenticationProvider from api.sip import SIP2AuthenticationProvider from core.integration.goals import Goals -from core.model import AdminRole, Library, get_one +from core.model import Library, get_one from core.model.integration import IntegrationConfiguration from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail +from tests.fixtures.flask import FlaskAppFixture if TYPE_CHECKING: - from tests.fixtures.api_admin import SettingsControllerFixture from tests.fixtures.authenticator import ( MilleniumAuthIntegrationFixture, SamlAuthIntegrationFixture, SimpleAuthIntegrationFixture, Sip2AuthIntegrationFixture, ) - from tests.fixtures.database import ( - DatabaseTransactionFixture, - IntegrationLibraryConfigurationFixture, - ) - - -@pytest.fixture -def get_response( - settings_ctrl_fixture: SettingsControllerFixture, -) -> Callable[[], dict[str, Any] | ProblemDetail]: - def get() -> dict[str, Any] | ProblemDetail: - with settings_ctrl_fixture.request_context_with_admin("/"): - response_obj = ( - settings_ctrl_fixture.manager.admin_patron_auth_services_controller.process_patron_auth_services() - ) - if isinstance(response_obj, ProblemDetail): - return response_obj - return json.loads(response_obj.response[0]) # type: ignore[index] - - return get - - -@pytest.fixture -def post_response( - settings_ctrl_fixture: SettingsControllerFixture, -) -> Callable[..., Response | ProblemDetail]: - def post(form: ImmutableMultiDict[str, str]) -> Response | ProblemDetail: - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = form - response = ( - settings_ctrl_fixture.manager.admin_patron_auth_services_controller.process_patron_auth_services() - ) - return response - - return post + from tests.fixtures.database import DatabaseTransactionFixture @pytest.fixture @@ -96,43 +62,56 @@ def common_args() -> list[tuple[str, str]]: ] +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> PatronAuthServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return PatronAuthServicesController(mock_manager) + + class TestPatronAuth: def test_patron_auth_services_get_with_no_services( self, - settings_ctrl_fixture: SettingsControllerFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - response = get_response() - assert isinstance(response, dict) - assert response.get("patron_auth_services") == [] - protocols = response.get("protocols") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + assert response_data.get("patron_auth_services") == [] + protocols = response_data.get("protocols") assert isinstance(protocols, list) assert 7 == len(protocols) assert "settings" in protocols[0] assert "library_settings" in protocols[0] - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() - pytest.raises( - AdminNotAuthorized, - get_response, - ) + # Test request without admin set + with flask_app_fixture.test_request_context("/"): + pytest.raises( + AdminNotAuthorized, + controller.process_patron_auth_services, + ) def test_patron_auth_services_get_with_simple_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, - create_integration_library_configuration: IntegrationLibraryConfigurationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_simple_auth_integration( test_identifier="user", test_password="pass" ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert auth_service.name == service.get("name") @@ -141,34 +120,25 @@ def test_patron_auth_services_get_with_simple_auth_service( assert "pass" == service.get("settings").get("test_password") assert [] == service.get("libraries") - create_integration_library_configuration(db.default_library(), auth_service) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + auth_service.libraries += [db.default_library()] + + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert "user" == service.get("settings").get("test_identifier") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) - - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) - - [library] = service.get("libraries", []) - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_millenium_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_millenium_auth_integration: MilleniumAuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_millenium_auth_integration( db.default_library(), @@ -178,9 +148,12 @@ def test_patron_auth_services_get_with_millenium_auth_service( password_regular_expression="p*", ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert MilleniumPatronAPI.__module__ == service.get("protocol") @@ -189,17 +162,14 @@ def test_patron_auth_services_get_with_millenium_auth_service( assert "u*" == service.get("settings").get("identifier_regular_expression") assert "p*" == service.get("settings").get("password_regular_expression") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_sip2_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_sip2_auth_integration: Sip2AuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_sip2_auth_integration( db.default_library(), @@ -211,9 +181,12 @@ def test_patron_auth_services_get_with_sip2_auth_service( field_separator=",", ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert SIP2AuthenticationProvider.__module__ == service.get("protocol") @@ -224,290 +197,314 @@ def test_patron_auth_services_get_with_sip2_auth_service( assert "5" == service.get("settings").get("location_code") assert "," == service.get("settings").get("field_separator") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_saml_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_saml_auth_integration: SamlAuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_saml_auth_integration( db.default_library(), ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert SAMLWebSSOAuthenticationProvider.__module__ == service.get("protocol") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_post_unknown_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form = ImmutableMultiDict( - [ - ("protocol", "Unknown"), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", "Unknown"), + ] + ) + response = controller.process_patron_auth_services() assert response == UNKNOWN_PROTOCOL def test_patron_auth_services_post_no_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form: ImmutableMultiDict[str, str] = ImmutableMultiDict([]) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict([]) + response = controller.process_patron_auth_services() assert response == NO_PROTOCOL_FOR_NEW_SERVICE def test_patron_auth_services_post_missing_service( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ("id", "123"), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ("id", "123"), + ] + ) + response = controller.process_patron_auth_services() assert response == MISSING_SERVICE def test_patron_auth_services_post_cannot_change_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SIP2AuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SIP2AuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert response == CANNOT_CHANGE_PROTOCOL def test_patron_auth_services_post_name_in_use( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("name", auth_service.name), - ("protocol", SIP2AuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(auth_service.name)), + ("protocol", SIP2AuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE def test_patron_auth_services_post_invalid_configuration( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_millenium_auth_integration: MilleniumAuthIntegrationFixture, common_args: list[tuple[str, str]], ): auth_service, _ = create_millenium_auth_integration() - form = ImmutableMultiDict( - [ - ("name", "some auth name"), - ("id", str(auth_service.id)), - ("protocol", MilleniumPatronAPI.__module__), - ("url", "http://url"), - ("authentication_mode", "Invalid mode"), - ("verify_certificate", "true"), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "some auth name"), + ("id", str(auth_service.id)), + ("protocol", MilleniumPatronAPI.__module__), + ("url", "http://url"), + ("authentication_mode", "Invalid mode"), + ("verify_certificate", "true"), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INVALID_CONFIGURATION_OPTION.uri def test_patron_auth_services_post_incomplete_configuration( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, common_args: list[tuple[str, str]], ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SimpleAuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SimpleAuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri def test_patron_auth_services_post_missing_patron_auth_name( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response == MISSING_SERVICE_NAME def test_patron_auth_services_post_no_such_library( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ("libraries", json.dumps([{"short_name": "not-a-library"}])), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ("libraries", json.dumps([{"short_name": "not-a-library"}])), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == NO_SUCH_LIBRARY.uri def test_patron_auth_services_post_missing_short_name( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ("libraries", json.dumps([{}])), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ("libraries", json.dumps([{}])), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INVALID_INPUT.uri assert response.detail == "Invalid library settings, missing short_name." def test_patron_auth_services_post_missing_patron_auth_multiple_basic( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, default_library: Library, common_args: list[tuple[str, str]], ): auth_service, _ = create_simple_auth_integration(default_library) - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, - "library_identifier_field": "barcode", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, + "library_identifier_field": "barcode", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == MULTIPLE_BASIC_AUTH_SERVICES.uri def test_patron_auth_services_post_invalid_library_identifier_restriction_regex( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, default_library: Library, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, - "library_identifier_field": "barcode", - "library_identifier_restriction_criteria": "(invalid re", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, + "library_identifier_field": "barcode", + "library_identifier_restriction_criteria": "(invalid re", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response == INVALID_LIBRARY_IDENTIFIER_RESTRICTION_REGULAR_EXPRESSION def test_patron_auth_services_post_not_authorized( self, common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ] - + common_args - ) - pytest.raises(AdminNotAuthorized, post_response, form) + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ] + + common_args + ) + pytest.raises(AdminNotAuthorized, controller.process_patron_auth_services) def test_patron_auth_services_post_create( self, common_args: list[tuple[str, str]], default_library: Library, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, - "library_identifier_field": "barcode", - "library_identifier_restriction_criteria": "^1234", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, + "library_identifier_field": "barcode", + "library_identifier_restriction_criteria": "^1234", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 201 auth_service = get_one( @@ -529,17 +526,19 @@ def test_patron_auth_services_post_create( == "^1234" ) - form = ImmutableMultiDict( - [ - ("name", "testing auth 2 name"), - ("protocol", MilleniumPatronAPI.__module__), - ("url", "https://url.com"), - ("verify_certificate", "false"), - ("authentication_mode", "pin"), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth 2 name"), + ("protocol", MilleniumPatronAPI.__module__), + ("url", "https://url.com"), + ("verify_certificate", "false"), + ("authentication_mode", "pin"), + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 201 auth_service2 = get_one( @@ -562,9 +561,9 @@ def test_patron_auth_services_post_create( def test_patron_auth_services_post_edit( self, - post_response: Callable[..., Response | ProblemDetail], common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, db: DatabaseTransactionFixture, monkeypatch: MonkeyPatch, @@ -584,29 +583,31 @@ def test_patron_auth_services_post_edit( "old_password", ) - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": l2.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, - "library_identifier_field": "barcode", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": l2.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, + "library_identifier_field": "barcode", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 200 - assert auth_service.id == int(response.response[0]) # type: ignore[index] + assert auth_service.id == int(response.get_data(as_text=True)) assert SimpleAuthenticationProvider.__module__ == auth_service.protocol assert isinstance(auth_service.settings_dict, dict) settings = SimpleAuthenticationProvider.settings_load(auth_service) @@ -628,12 +629,11 @@ def test_patron_auth_services_post_edit( def test_patron_auth_service_delete( self, common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, + db: DatabaseTransactionFixture, ): - controller = settings_ctrl_fixture.manager.admin_patron_auth_services_controller - db = settings_ctrl_fixture.ctrl.db - l1 = db.library("Library 1", "L1") auth_service, _ = create_simple_auth_integration( l1, @@ -641,21 +641,20 @@ def test_patron_auth_service_delete( "old_password", ) - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, controller.process_delete, auth_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert auth_service.id is not None response = controller.process_delete(auth_service.id) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, id=auth_service.id, ) diff --git a/tests/api/admin/controller/test_patron_auth_self_tests.py b/tests/api/admin/controller/test_patron_auth_self_tests.py index 12816805f..cc75202e0 100644 --- a/tests/api/admin/controller/test_patron_auth_self_tests.py +++ b/tests/api/admin/controller/test_patron_auth_self_tests.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING from unittest.mock import MagicMock @@ -18,10 +17,10 @@ from core.model import Library from core.selftest import HasSelfTestsIntegrationConfiguration from core.util.problem_detail import ProblemDetail +from tests.fixtures.flask import FlaskAppFixture if TYPE_CHECKING: from _pytest.monkeypatch import MonkeyPatch - from flask.ctx import RequestContext from tests.fixtures.authenticator import SimpleAuthIntegrationFixture from tests.fixtures.database import DatabaseTransactionFixture @@ -45,9 +44,10 @@ def test_patron_auth_self_tests_with_no_identifier( def test_patron_auth_self_tests_with_no_auth_service_found( self, controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, ): - response = controller.process_patron_auth_service_self_tests(-1) + with flask_app_fixture.test_request_context("/"): + response = controller.process_patron_auth_service_self_tests(-1) assert isinstance(response, ProblemDetail) assert response == MISSING_SERVICE assert response.status_code == 404 @@ -55,15 +55,17 @@ def test_patron_auth_self_tests_with_no_auth_service_found( def test_patron_auth_self_tests_get_with_no_libraries( self, controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] + response = response_obj.json + assert isinstance(response, dict) results = response.get("self_test_results", {}).get("self_test_results") assert results.get("disabled") is True assert ( @@ -74,18 +76,20 @@ def test_patron_auth_self_tests_get_with_no_libraries( def test_patron_auth_self_tests_test_get_no_results( self, controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, default_library: Library, ): auth_service, _ = create_simple_auth_integration(library=default_library) # Make sure that we return the correct response when there are no results - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] + response = response_obj.json + assert isinstance(response, dict) response_auth_service = response.get("self_test_results", {}) assert response_auth_service.get("name") == auth_service.name @@ -98,9 +102,8 @@ def test_patron_auth_self_tests_test_get_no_results( def test_patron_auth_self_tests_test_get( self, controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, - monkeypatch: MonkeyPatch, default_library: Library, ): expected_results = dict( @@ -109,19 +112,18 @@ def test_patron_auth_self_tests_test_get( end="2018-08-08T16:05:05Z", results=[], ) - mock = MagicMock(return_value=expected_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock - ) auth_service, _ = create_simple_auth_integration(library=default_library) + auth_service.self_test_results = expected_results # Make sure that HasSelfTest.prior_test_results() was called and that # it is in the response's self tests object. - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] + response = response_obj.json + assert isinstance(response, dict) response_auth_service = response.get("self_test_results", {}) assert response_auth_service.get("name") == auth_service.name @@ -130,16 +132,18 @@ def test_patron_auth_self_tests_test_get( assert auth_service.goal is not None assert response_auth_service.get("goal") == auth_service.goal.value assert response_auth_service.get("self_test_results") == expected_results - mock.assert_called_once_with(auth_service) def test_patron_auth_self_tests_post_with_no_libraries( self, controller: PatronAuthServiceSelfTestsController, - post_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - response = controller.process_patron_auth_service_self_tests(auth_service.id) + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id, + ) assert isinstance(response, ProblemDetail) assert response.title == FAILED_TO_RUN_SELF_TESTS.title assert response.detail is not None @@ -149,7 +153,7 @@ def test_patron_auth_self_tests_post_with_no_libraries( def test_patron_auth_self_tests_test_post( self, controller: PatronAuthServiceSelfTestsController, - post_request_context: RequestContext, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, monkeypatch: MonkeyPatch, db: DatabaseTransactionFixture, @@ -162,7 +166,10 @@ def test_patron_auth_self_tests_test_post( library = db.default_library() auth_service, _ = create_simple_auth_integration(library=library) - response = controller.process_patron_auth_service_self_tests(auth_service.id) + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id + ) assert isinstance(response, Response) assert response.status == "200 OK" assert "Successfully ran new self tests" == response.get_data(as_text=True) diff --git a/tests/fixtures/flask.py b/tests/fixtures/flask.py index 670b7d908..c105472e1 100644 --- a/tests/fixtures/flask.py +++ b/tests/fixtures/flask.py @@ -1,28 +1,59 @@ +from __future__ import annotations + from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +import flask import pytest from flask.ctx import RequestContext from flask_babel import Babel from api.util.flask import PalaceFlask +from core.model import Admin, AdminRole, Library, get_one_or_create +from tests.fixtures.database import DatabaseTransactionFixture + + +class FlaskAppFixture: + def __init__(self, db: DatabaseTransactionFixture) -> None: + self.app = PalaceFlask(__name__) + self.db = db + Babel(self.app) + + def admin_user( + self, + email: str = "admin@admin.org", + role: str = AdminRole.SYSTEM_ADMIN, + library: Library | None = None, + ) -> Admin: + admin, _ = get_one_or_create(self.db.session, Admin, email=email) + admin.add_role(role, library) + return admin + + @contextmanager + def test_request_context( + self, *args: Any, admin: Admin | None = None, **kwargs: Any + ) -> Generator[RequestContext, None, None]: + with self.app.test_request_context(*args, **kwargs) as c: + self.db.session.begin_nested() + flask.request.admin = admin # type: ignore[attr-defined] + yield c + + # Flush any changes that may have occurred during the request, then + # expire all objects to ensure that the next request will see the + # changes. + self.db.session.commit() + self.db.session.expire_all() + + @contextmanager + def test_request_context_system_admin( + self, *args: Any, **kwargs: Any + ) -> Generator[RequestContext, None, None]: + admin = self.admin_user() + with self.test_request_context(*args, **kwargs, admin=admin) as c: + yield c @pytest.fixture -def mock_app() -> PalaceFlask: - app = PalaceFlask(__name__) - Babel(app) - return app - - -@pytest.fixture -def get_request_context(mock_app: PalaceFlask) -> Generator[RequestContext, None, None]: - with mock_app.test_request_context("/") as mock_request_context: - yield mock_request_context - - -@pytest.fixture -def post_request_context( - mock_app: PalaceFlask, -) -> Generator[RequestContext, None, None]: - with mock_app.test_request_context("/", method="POST") as mock_request_context: - yield mock_request_context +def flask_app_fixture(db: DatabaseTransactionFixture) -> FlaskAppFixture: + return FlaskAppFixture(db) From 6b415515470bbb26fb8b36f2f3fb2005c3c85a09 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 26 Jan 2024 14:45:38 -0400 Subject: [PATCH 20/33] Refactor self test controllers (PP-497) (#1630) * Refactor self tests * Add some additional comments --- api/admin/controller/__init__.py | 10 - api/admin/controller/collection_self_tests.py | 41 ---- api/admin/controller/collection_settings.py | 27 ++- api/admin/controller/integration_settings.py | 159 ++++++++++++++- .../patron_auth_service_self_tests.py | 84 -------- api/admin/controller/patron_auth_services.py | 80 +++++++- api/admin/controller/self_tests.py | 125 +----------- api/admin/routes.py | 8 +- api/circulation_manager.py | 6 - core/selftest.py | 23 +-- pyproject.toml | 2 - .../controller/test_collection_self_tests.py | 184 ------------------ .../api/admin/controller/test_collections.py | 172 +++++++++++++++- .../api/admin/controller/test_patron_auth.py | 151 ++++++++++++++ .../controller/test_patron_auth_self_tests.py | 181 ----------------- tests/api/admin/test_routes.py | 18 -- 16 files changed, 586 insertions(+), 685 deletions(-) delete mode 100644 api/admin/controller/collection_self_tests.py delete mode 100644 api/admin/controller/patron_auth_service_self_tests.py delete mode 100644 tests/api/admin/controller/test_collection_self_tests.py delete mode 100644 tests/api/admin/controller/test_patron_auth_self_tests.py diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 02e9438ca..5ba14207b 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -13,7 +13,6 @@ def setup_admin_controllers(manager: CirculationManager): from api.admin.controller.admin_search import AdminSearchController from api.admin.controller.announcement_service import AnnouncementSettings from api.admin.controller.catalog_services import CatalogServicesController - from api.admin.controller.collection_self_tests import CollectionSelfTestsController from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.controller.custom_lists import CustomListsController from api.admin.controller.dashboard import DashboardController @@ -32,9 +31,6 @@ def setup_admin_controllers(manager: CirculationManager): ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController - from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, - ) from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.reset_password import ResetPasswordController from api.admin.controller.self_tests import SelfTestsController @@ -71,13 +67,7 @@ def setup_admin_controllers(manager: CirculationManager): manager ) - manager.admin_patron_auth_service_self_tests_controller = ( - PatronAuthServiceSelfTestsController(manager._db) - ) manager.admin_collection_settings_controller = CollectionSettingsController(manager) - manager.admin_collection_self_tests_controller = CollectionSelfTestsController( - manager._db - ) manager.admin_sitewide_configuration_settings_controller = ( SitewideConfigurationSettingsController(manager) ) diff --git a/api/admin/controller/collection_self_tests.py b/api/admin/controller/collection_self_tests.py deleted file mode 100644 index 8cc53dcb1..000000000 --- a/api/admin/controller/collection_self_tests.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from flask import Response -from sqlalchemy.orm import Session - -from api.admin.controller.self_tests import IntegrationSelfTestsController -from api.circulation import CirculationApiType -from api.integration.registry.license_providers import LicenseProvidersRegistry -from core.integration.registry import IntegrationRegistry -from core.model import IntegrationConfiguration -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail - - -class CollectionSelfTestsController(IntegrationSelfTestsController[CirculationApiType]): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[CirculationApiType] | None = None, - ): - registry = registry or LicenseProvidersRegistry() - super().__init__(db, registry) - - def process_collection_self_tests( - self, identifier: int | None - ) -> Response | ProblemDetail: - return self.process_self_tests(identifier) - - def run_self_tests( - self, integration: IntegrationConfiguration - ) -> dict[str, Any] | None: - protocol_class = self.get_protocol_class(integration) - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): - test_result, _ = protocol_class.run_self_tests( - self.db, protocol_class, self.db, integration.collection - ) - return test_result - - return None diff --git a/api/admin/controller/collection_settings.py b/api/admin/controller/collection_settings.py index ee28ac21d..fdb74b4f4 100644 --- a/api/admin/controller/collection_settings.py +++ b/api/admin/controller/collection_settings.py @@ -1,10 +1,14 @@ +from __future__ import annotations + from typing import Any import flask from flask import Response from api.admin.controller.base import AdminPermissionsControllerMixin -from api.admin.controller.integration_settings import IntegrationSettingsController +from api.admin.controller.integration_settings import ( + IntegrationSettingsSelfTestsController, +) from api.admin.form_data import ProcessFormData from api.admin.problem_details import ( CANNOT_DELETE_COLLECTION_WITH_CHILDREN, @@ -26,11 +30,13 @@ json_serializer, site_configuration_has_changed, ) +from core.selftest import HasSelfTestsIntegrationConfiguration from core.util.problem_detail import ProblemDetail, ProblemError class CollectionSettingsController( - IntegrationSettingsController[CirculationApiType], AdminPermissionsControllerMixin + IntegrationSettingsSelfTestsController[CirculationApiType], + AdminPermissionsControllerMixin, ): def default_registry(self) -> IntegrationRegistry[CirculationApiType]: return LicenseProvidersRegistry() @@ -164,3 +170,20 @@ def process_delete(self, service_id: int) -> Response | ProblemDetail: # Flag the collection to be deleted by script in the background. collection.marked_for_deletion = True return Response("Deleted", 200) + + def process_collection_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) + + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + test_result, _ = protocol_class.run_self_tests( + self._db, protocol_class, self._db, integration.collection + ) + return test_result + + return None diff --git a/api/admin/controller/integration_settings.py b/api/admin/controller/integration_settings.py index c8a93c8df..fa4f53392 100644 --- a/api/admin/controller/integration_settings.py +++ b/api/admin/controller/integration_settings.py @@ -10,7 +10,9 @@ from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + FAILED_TO_RUN_SELF_TESTS, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_SERVICE, MISSING_SERVICE_NAME, NO_PROTOCOL_FOR_NEW_SERVICE, @@ -31,11 +33,13 @@ Library, create, get_one, + json_serializer, ) from core.problem_details import INTERNAL_SERVER_ERROR, INVALID_INPUT +from core.selftest import HasSelfTestsIntegrationConfiguration from core.util.cache import memoize from core.util.log import LoggerMixin -from core.util.problem_detail import ProblemError +from core.util.problem_detail import ProblemDetail, ProblemError T = TypeVar("T", bound=HasIntegrationConfiguration[BaseSettings]) @@ -69,7 +73,7 @@ def default_registry(self) -> IntegrationRegistry[T]: @memoize(ttls=1800) def _cached_protocols(self) -> dict[str, dict[str, Any]]: - """Cached result for integration implementations""" + """Cached result for integration implementations.""" protocols = [] for name, api in self.registry: protocol = { @@ -99,23 +103,43 @@ def protocols(self) -> dict[str, dict[str, Any]]: def configured_service_info( self, service: IntegrationConfiguration ) -> dict[str, Any] | None: + """This is the default implementation for getting details about a configured integration. + It can be overridden by implementations that need to add additional information to the + service info dict that gets returned to the admin UI.""" + + if service.goal is None: + # We should never get here, since we only query for services with a goal, and goal + # is a required field, but for mypy and safety, we check for it anyway. + self.log.warning( + f"IntegrationConfiguration {service.name}({service.id}) has no goal set. Skipping." + ) + return None return { "id": service.id, "name": service.name, "protocol": service.protocol, "settings": service.settings_dict, + "goal": service.goal.value, } def configured_service_library_info( self, library_configuration: IntegrationLibraryConfiguration ) -> dict[str, Any] | None: + """This is the default implementation for getting details about a library integration for + a configured integration. It can be overridden by implementations that need to add + additional information to the `libraries` dict that gets returned to the admin UI. + """ library_info = {"short_name": library_configuration.library.short_name} library_info.update(library_configuration.settings_dict) return library_info @property def configured_services(self) -> list[dict[str, Any]]: - """Return a list of all currently configured services for the controller's goal.""" + """Return a list of all currently configured services for the controller's goal. + + If you need to add additional information to the service info dict that gets returned to the + admin UI, override the configured_service_info method instead of this one. + """ configured_services = [] for service in ( self._db.query(IntegrationConfiguration) @@ -147,7 +171,7 @@ def configured_services(self) -> list[dict[str, Any]]: return configured_services def get_existing_service( - self, service_id: int, name: str | None, protocol: str + self, service_id: int, name: str | None = None, protocol: str | None = None ) -> IntegrationConfiguration: """ Query for an existing service to edit. @@ -165,7 +189,7 @@ def get_existing_service( ) if service is None: raise ProblemError(MISSING_SERVICE) - if service.protocol != protocol: + if protocol is not None and service.protocol != protocol: raise ProblemError(CANNOT_CHANGE_PROTOCOL) if name is not None and service.name != name: service_with_name = get_one(self._db, IntegrationConfiguration, name=name) @@ -203,12 +227,31 @@ def create_new_service(self, name: str, protocol: str) -> IntegrationConfigurati return new_service def get_libraries_data(self, form_data: ImmutableMultiDict[str, str]) -> str | None: + """ + Get the library settings data from the form data sent in the request by the admin ui + and return it as a JSON string. + """ libraries_data = form_data.get("libraries", None, str) return libraries_data + def get_protocol_class(self, protocol: str | None) -> type[T]: + """ + Get the protocol class for the given protocol. Raises a ProblemError if the protocol + is unknown. + """ + if protocol is None or protocol not in self.registry: + self.log.warning(f"Unknown service protocol: {protocol}") + raise ProblemError(UNKNOWN_PROTOCOL) + return self.registry[protocol] + def get_service( self, form_data: ImmutableMultiDict[str, str] ) -> tuple[IntegrationConfiguration, str, int]: + """ + Get a service to edit or create, the protocol, and the response code to return to the + frontend. This method is used by both the process_post and process_delete methods to + get the service being operated on. + """ protocol = form_data.get("protocol", None, str) _id = form_data.get("id", None, int) name = form_data.get("name", None, str) @@ -216,9 +259,13 @@ def get_service( if protocol is None and _id is None: raise ProblemError(NO_PROTOCOL_FOR_NEW_SERVICE) - if protocol is None or protocol not in self.registry: - self.log.warning(f"Unknown service protocol: {protocol}") - raise ProblemError(UNKNOWN_PROTOCOL) + # Lookup the protocol class to make sure it exists + # this will raise a ProblemError if the protocol is unknown + self.get_protocol_class(protocol) + + # This should never happen, due to the call to get_protocol_class but + # mypy doesn't know that, so we make sure that protocol is not None before we use it. + assert protocol is not None if _id is not None: # Find an existing service to edit @@ -250,7 +297,8 @@ def create_library_settings( self, service: IntegrationConfiguration, short_name: str ) -> IntegrationLibraryConfiguration: """ - Create a new IntegrationLibraryConfiguration for the given IntegrationConfiguration and library. + Create a new IntegrationLibraryConfiguration for the given IntegrationConfiguration and library, + based on the library's short name. """ library = self.get_library(short_name) library_settings, _ = create( @@ -402,3 +450,96 @@ def delete_service(self, service_id: int) -> Response: raise ProblemError(problem_detail=MISSING_SERVICE) self._db.delete(integration) return Response("Deleted", 200) + + +class IntegrationSettingsSelfTestsController(IntegrationSettingsController[T], ABC): + @abstractmethod + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + """ + Run self tests for the given integration. Returns a JSON-serializable dictionary + describing the results of the self-test run or None if there was an error running + the self tests. + """ + ... + + def configured_service_info( + self, service: IntegrationConfiguration + ) -> dict[str, Any] | None: + """ + Add the `self_test_results` key to the service info dict that gets returned to the + admin UI. This key contains the results of the last self test run for the service. + """ + service_info = super().configured_service_info(service) + if service_info is None: + return None + service_info["self_test_results"] = self.get_prior_test_results(service) + return service_info + + def get_prior_test_results( + self, integration: IntegrationConfiguration + ) -> dict[str, Any]: + """ + Get the results of the last self test run for the given integration. If the integration + doesn't have any self test results, return a dictionary with the `disabled` key set to + True. + + This method is useful to override if you need to add additional information to the + self test results dict that gets returned to the admin UI. + """ + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + self_test_results = protocol_class.load_self_test_results(integration) # type: ignore[unreachable] + else: + self_test_results = dict( + exception=("Self tests are not supported for this integration."), + disabled=True, + ) + + return self_test_results + + def process_self_tests(self, identifier: int | None) -> Response | ProblemDetail: + """ + Generic request handler for GET and POST requests to the self tests endpoint. + This is often used by implementations that don't need to do any additional + processing of the request data. + """ + if not identifier: + return MISSING_IDENTIFIER + try: + if flask.request.method == "GET": + return self.self_tests_process_get(identifier) + else: + return self.self_tests_process_post(identifier) + except ProblemError as e: + return e.problem_detail + + def self_tests_process_get(self, identifier: int) -> Response: + """ + Return all the details for a given integration along with the self test results + for the integration as a JSON response. + + TODO: It doesn't seem like all the details for an integration should be contained + in the `self_test_results` key. But this is what the admin ui expects, so for now + we'll return everything in that key. + """ + integration = self.get_existing_service(identifier) + info = self.configured_service_info(integration) + return Response( + json_serializer({"self_test_results": info}), + status=200, + mimetype="application/json", + ) + + def self_tests_process_post(self, identifier: int) -> Response: + """ + Attempt to run the self tests for the given integration and return a response + indicating whether we were able to run the self tests or not. + """ + integration = self.get_existing_service(identifier) + results = self.run_self_tests(integration) + if results is not None: + return Response("Successfully ran new self tests", 200) + else: + raise ProblemError(problem_detail=FAILED_TO_RUN_SELF_TESTS) diff --git a/api/admin/controller/patron_auth_service_self_tests.py b/api/admin/controller/patron_auth_service_self_tests.py deleted file mode 100644 index 476456a59..000000000 --- a/api/admin/controller/patron_auth_service_self_tests.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from flask import Response -from sqlalchemy.orm import Session - -from api.admin.controller.self_tests import IntegrationSelfTestsController -from api.admin.problem_details import FAILED_TO_RUN_SELF_TESTS -from api.authentication.base import AuthenticationProviderType -from api.integration.registry.patron_auth import PatronAuthRegistry -from core.integration.registry import IntegrationRegistry -from core.model.integration import IntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError - - -class PatronAuthServiceSelfTestsController( - IntegrationSelfTestsController[AuthenticationProviderType] -): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[AuthenticationProviderType] | None = None, - ): - registry = registry or PatronAuthRegistry() - super().__init__(db, registry) - - def process_patron_auth_service_self_tests( - self, identifier: int | None - ) -> Response | ProblemDetail: - return self.process_self_tests(identifier) - - def get_prior_test_results( - self, - protocol_class: type[AuthenticationProviderType], - integration: IntegrationConfiguration, - ) -> dict[str, Any]: - # Find the first library associated with this service. - library_configuration = self.get_library_configuration(integration) - - if library_configuration is None: - return dict( - exception=( - "You must associate this service with at least one library " - "before you can run self tests for it." - ), - disabled=True, - ) - - return super().get_prior_test_results(protocol_class, integration) - - def run_self_tests(self, integration: IntegrationConfiguration) -> dict[str, Any]: - # If the auth service doesn't have at least one library associated with it, - # we can't run self tests. - library_configuration = self.get_library_configuration(integration) - if library_configuration is None: - raise ProblemError( - problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( - f"Failed to run self tests for {integration.name}, because it is not associated with any libraries." - ) - ) - - if not isinstance(integration.settings_dict, dict) or not isinstance( - library_configuration.settings_dict, dict - ): - raise ProblemError( - problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( - f"Failed to run self tests for {integration.name}, because its settings are not valid." - ) - ) - - protocol_class = self.get_protocol_class(integration) - settings = protocol_class.settings_load(integration) - library_settings = protocol_class.library_settings_load(library_configuration) - - value, _ = protocol_class.run_self_tests( - self.db, - None, - library_configuration.library_id, - integration.id, - settings, - library_settings, - ) - return value diff --git a/api/admin/controller/patron_auth_services.py b/api/admin/controller/patron_auth_services.py index 0e8dd595f..21b43f78f 100644 --- a/api/admin/controller/patron_auth_services.py +++ b/api/admin/controller/patron_auth_services.py @@ -1,9 +1,13 @@ +from __future__ import annotations + +from typing import Any + import flask from flask import Response from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.controller.integration_settings import ( - IntegrationSettingsController, + IntegrationSettingsSelfTestsController, UpdatedLibrarySettingsTuple, ) from api.admin.form_data import ProcessFormData @@ -14,7 +18,12 @@ from core.integration.goals import Goals from core.integration.registry import IntegrationRegistry from core.integration.settings import BaseSettings -from core.model import json_serializer, site_configuration_has_changed +from core.model import ( + IntegrationConfiguration, + IntegrationLibraryConfiguration, + json_serializer, + site_configuration_has_changed, +) from core.model.integration import ( IntegrationConfiguration, IntegrationLibraryConfiguration, @@ -23,7 +32,7 @@ class PatronAuthServicesController( - IntegrationSettingsController[AuthenticationProviderType], + IntegrationSettingsSelfTestsController[AuthenticationProviderType], AdminPermissionsControllerMixin, ): def default_registry(self) -> IntegrationRegistry[AuthenticationProviderType]: @@ -124,3 +133,68 @@ def process_delete(self, service_id: int) -> Response | ProblemDetail: except ProblemError as e: self._db.rollback() return e.problem_detail + + def process_patron_auth_service_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) + + def get_prior_test_results( + self, + integration: IntegrationConfiguration, + ) -> dict[str, Any]: + # Find the first library associated with this service. + library_configuration = self.get_library_configuration(integration) + + if library_configuration is None: + return dict( + exception=( + "You must associate this service with at least one library " + "before you can run self tests for it." + ), + disabled=True, + ) + + return super().get_prior_test_results(integration) + + def run_self_tests(self, integration: IntegrationConfiguration) -> dict[str, Any]: + # If the auth service doesn't have at least one library associated with it, + # we can't run self tests. + library_configuration = self.get_library_configuration(integration) + if library_configuration is None: + raise ProblemError( + problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( + f"Failed to run self tests for {integration.name}, because it is not associated with any libraries." + ) + ) + + if not isinstance(integration.settings_dict, dict) or not isinstance( + library_configuration.settings_dict, dict + ): + raise ProblemError( + problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( + f"Failed to run self tests for {integration.name}, because its settings are not valid." + ) + ) + + protocol_class = self.get_protocol_class(integration.protocol) + settings = protocol_class.settings_load(integration) + library_settings = protocol_class.library_settings_load(library_configuration) + + value, _ = protocol_class.run_self_tests( + self._db, + None, + library_configuration.library_id, + integration.id, + settings, + library_settings, + ) + return value + + @staticmethod + def get_library_configuration( + integration: IntegrationConfiguration, + ) -> IntegrationLibraryConfiguration | None: + if not integration.library_configurations: + return None + return integration.library_configurations[0] diff --git a/api/admin/controller/self_tests.py b/api/admin/controller/self_tests.py index 239ff40ae..b8fd6e45f 100644 --- a/api/admin/controller/self_tests.py +++ b/api/admin/controller/self_tests.py @@ -1,31 +1,12 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Any, Generic, TypeVar - import flask from flask import Response from flask_babel import lazy_gettext as _ -from sqlalchemy.orm import Session from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.integration.base import HasIntegrationConfiguration -from core.integration.registry import IntegrationRegistry -from core.integration.settings import BaseSettings -from core.model import ( - IntegrationConfiguration, - IntegrationLibraryConfiguration, - get_one, - json_serializer, -) -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError +from api.admin.problem_details import FAILED_TO_RUN_SELF_TESTS, MISSING_IDENTIFIER +from core.util.problem_detail import ProblemDetail class SelfTestsController(SettingsController): @@ -92,105 +73,3 @@ def self_tests_process_post(self, identifier): return FAILED_TO_RUN_SELF_TESTS.detailed( _("Failed to run self tests for this %(type)s.", type=self.type) ) - - -T = TypeVar("T", bound=HasIntegrationConfiguration[BaseSettings]) - - -class IntegrationSelfTestsController(Generic[T], ABC): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[T], - ): - self.db = db - self.registry = registry - - @abstractmethod - def run_self_tests( - self, integration: IntegrationConfiguration - ) -> dict[str, Any] | None: - ... - - def get_protocol_class(self, integration: IntegrationConfiguration) -> type[T]: - if not integration.protocol or integration.protocol not in self.registry: - raise ProblemError(problem_detail=UNKNOWN_PROTOCOL) - return self.registry[integration.protocol] - - def look_up_by_id(self, identifier: int) -> IntegrationConfiguration: - service = get_one( - self.db, - IntegrationConfiguration, - id=identifier, - goal=self.registry.goal, - ) - if not service: - raise (ProblemError(problem_detail=MISSING_SERVICE)) - return service - - @staticmethod - def get_info(integration: IntegrationConfiguration) -> dict[str, Any]: - info = dict( - id=integration.id, - name=integration.name, - protocol=integration.protocol, - goal=integration.goal, - settings=integration.settings_dict, - ) - return info - - @staticmethod - def get_library_configuration( - integration: IntegrationConfiguration, - ) -> IntegrationLibraryConfiguration | None: - if not integration.library_configurations: - return None - return integration.library_configurations[0] - - def get_prior_test_results( - self, protocol_class: type[T], integration: IntegrationConfiguration - ) -> dict[str, Any]: - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): - self_test_results = protocol_class.load_self_test_results(integration) # type: ignore[unreachable] - else: - self_test_results = dict( - exception=("Self tests are not supported for this integration."), - disabled=True, - ) - - return self_test_results - - def process_self_tests(self, identifier: int | None) -> Response | ProblemDetail: - if not identifier: - return MISSING_IDENTIFIER - try: - if flask.request.method == "GET": - return self.self_tests_process_get(identifier) - else: - return self.self_tests_process_post(identifier) - except ProblemError as e: - return e.problem_detail - - def self_tests_process_get(self, identifier: int) -> Response: - integration = self.look_up_by_id(identifier) - info = self.get_info(integration) - protocol_class = self.get_protocol_class(integration) - - self_test_results = self.get_prior_test_results(protocol_class, integration) - - info["self_test_results"] = ( - self_test_results if self_test_results else "No results yet" - ) - return Response( - json_serializer({"self_test_results": info}), - status=200, - mimetype="application/json", - ) - - def self_tests_process_post(self, identifier: int) -> Response: - integration = self.look_up_by_id(identifier) - results = self.run_self_tests(integration) - if results is not None: - return Response("Successfully ran new self tests", 200) - else: - raise ProblemError(problem_detail=FAILED_TO_RUN_SELF_TESTS) diff --git a/api/admin/routes.py b/api/admin/routes.py index 8b37d5441..565a2e239 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -386,8 +386,10 @@ def collection(collection_id): @requires_admin @requires_csrf_token def collection_self_tests(identifier): - return app.manager.admin_collection_self_tests_controller.process_collection_self_tests( - identifier + return ( + app.manager.admin_collection_settings_controller.process_collection_self_tests( + identifier + ) ) @@ -435,7 +437,7 @@ def patron_auth_service(service_id): @requires_admin @requires_csrf_token def patron_auth_self_tests(identifier): - return app.manager.admin_patron_auth_service_self_tests_controller.process_patron_auth_service_self_tests( + return app.manager.admin_patron_auth_services_controller.process_patron_auth_service_self_tests( identifier ) diff --git a/api/circulation_manager.py b/api/circulation_manager.py index ddf36d173..1e51c53e5 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -46,7 +46,6 @@ from api.admin.controller.admin_search import AdminSearchController from api.admin.controller.announcement_service import AnnouncementSettings from api.admin.controller.catalog_services import CatalogServicesController - from api.admin.controller.collection_self_tests import CollectionSelfTestsController from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.controller.custom_lists import CustomListsController from api.admin.controller.dashboard import DashboardController @@ -65,9 +64,6 @@ ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController - from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, - ) from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController @@ -116,9 +112,7 @@ class CirculationManager(LoggerMixin): admin_metadata_services_controller: MetadataServicesController admin_metadata_service_self_tests_controller: MetadataServiceSelfTestsController admin_patron_auth_services_controller: PatronAuthServicesController - admin_patron_auth_service_self_tests_controller: PatronAuthServiceSelfTestsController admin_collection_settings_controller: CollectionSettingsController - admin_collection_self_tests_controller: CollectionSelfTestsController admin_sitewide_configuration_settings_controller: SitewideConfigurationSettingsController admin_library_settings_controller: LibrarySettingsController admin_individual_admin_settings_controller: IndividualAdminSettingsController diff --git a/core/selftest.py b/core/selftest.py index 2bf4924c0..246cb1152 100644 --- a/core/selftest.py +++ b/core/selftest.py @@ -357,7 +357,7 @@ def store_self_test_results( @classmethod def load_self_test_results( cls, integration: IntegrationConfiguration | None - ) -> dict[str, Any] | None: + ) -> dict[str, Any] | str | None: if integration is None: cls.logger().error( "No IntegrationConfiguration was found. Self-test results could not be loaded." @@ -370,24 +370,11 @@ def load_self_test_results( ) return None - return integration.self_test_results - - @classmethod - def prior_test_results( - cls: type[Self], - _db: Session, - constructor_method: Callable[..., Self] | None = None, - *args: Any, - **kwargs: Any, - ) -> dict[str, Any] | None | str: - """Retrieve the last set of test results from the database. + if integration.self_test_results == {}: + # No self-test results have been stored yet. + return "No results yet" - The arguments here are the same as the arguments to run_self_tests. - """ - constructor_method = constructor_method or cls - instance = constructor_method(*args, **kwargs) - integration: IntegrationConfiguration | None = instance.integration(_db) - return cls.load_self_test_results(integration) or "No results yet" + return integration.self_test_results @abstractmethod def integration(self, _db: Session) -> IntegrationConfiguration | None: diff --git a/pyproject.toml b/pyproject.toml index 0b3fa3119..ec39fc822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,13 +68,11 @@ module = [ "api.admin.announcement_list_validator", "api.admin.config", "api.admin.controller.catalog_services", - "api.admin.controller.collection_self_tests", "api.admin.controller.collection_settings", "api.admin.controller.discovery_service_library_registrations", "api.admin.controller.discovery_services", "api.admin.controller.integration_settings", "api.admin.controller.library_settings", - "api.admin.controller.patron_auth_service_self_tests", "api.admin.controller.patron_auth_services", "api.admin.dashboard_stats", "api.admin.form_data", diff --git a/tests/api/admin/controller/test_collection_self_tests.py b/tests/api/admin/controller/test_collection_self_tests.py deleted file mode 100644 index d2c8b8fb4..000000000 --- a/tests/api/admin/controller/test_collection_self_tests.py +++ /dev/null @@ -1,184 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from _pytest.monkeypatch import MonkeyPatch - -from api.admin.controller.collection_self_tests import CollectionSelfTestsController -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, - UNKNOWN_PROTOCOL, -) -from api.integration.registry.license_providers import LicenseProvidersRegistry -from api.selftest import HasCollectionSelfTests -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError -from tests.api.mockapi.axis import MockAxis360API -from tests.fixtures.database import DatabaseTransactionFixture -from tests.fixtures.flask import FlaskAppFixture - - -@pytest.fixture -def controller(db: DatabaseTransactionFixture) -> CollectionSelfTestsController: - return CollectionSelfTestsController(db.session) - - -class TestCollectionSelfTests: - def test_collection_self_tests_with_no_identifier( - self, controller: CollectionSelfTestsController - ): - response = controller.process_collection_self_tests(None) - assert isinstance(response, ProblemDetail) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_collection_self_tests_with_no_collection_found( - self, controller: CollectionSelfTestsController - ): - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_get(-1) - assert excinfo.value.problem_detail == MISSING_SERVICE - - def test_collection_self_tests_with_unknown_protocol( - self, db: DatabaseTransactionFixture, controller: CollectionSelfTestsController - ): - collection = db.collection(protocol="test") - assert collection.integration_configuration.id is not None - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_get(collection.integration_configuration.id) - assert excinfo.value.problem_detail == UNKNOWN_PROTOCOL - - def test_collection_self_tests_with_unsupported_protocol( - self, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture - ): - registry = LicenseProvidersRegistry() - registry.register(object, canonical="mock_api") # type: ignore[arg-type] - collection = db.collection(protocol="mock_api") - controller = CollectionSelfTestsController(db.session, registry) - assert collection.integration_configuration.id is not None - - with flask_app_fixture.test_request_context_system_admin("/"): - result = controller.self_tests_process_get( - collection.integration_configuration.id - ) - - assert result.status_code == 200 - assert isinstance(result.json, dict) - assert result.json["self_test_results"]["self_test_results"] == { - "disabled": True, - "exception": "Self tests are not supported for this integration.", - } - - def test_collection_self_tests_test_get( - self, - db: DatabaseTransactionFixture, - controller: CollectionSelfTestsController, - flask_app_fixture: FlaskAppFixture, - monkeypatch: MonkeyPatch, - ): - collection = MockAxis360API.mock_collection( - db.session, - db.default_library(), - ) - - self_test_results = dict( - duration=0.9, - start="2018-08-08T16:04:05Z", - end="2018-08-08T16:05:05Z", - results=[], - ) - mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock - ) - - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's collection object. - assert collection.integration_configuration.id is not None - with flask_app_fixture.test_request_context_system_admin("/"): - response = controller.self_tests_process_get( - collection.integration_configuration.id - ) - - data = response.json - assert isinstance(data, dict) - test_results = data.get("self_test_results") - assert isinstance(test_results, dict) - - assert test_results.get("id") == collection.integration_configuration.id - assert test_results.get("name") == collection.name - assert test_results.get("protocol") == collection.protocol - assert test_results.get("self_test_results") == self_test_results - assert mock.call_count == 1 - - def test_collection_self_tests_failed_post( - self, - db: DatabaseTransactionFixture, - controller: CollectionSelfTestsController, - monkeypatch: MonkeyPatch, - ): - collection = MockAxis360API.mock_collection( - db.session, - db.default_library(), - ) - - # This makes HasSelfTests.run_self_tests return no values - self_test_results = (None, None) - mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) - - # Failed to run self tests - assert collection.integration_configuration.id is not None - - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_post(collection.integration_configuration.id) - - assert excinfo.value.problem_detail == FAILED_TO_RUN_SELF_TESTS - - def test_collection_self_tests_run_self_tests_unsupported_collection( - self, - db: DatabaseTransactionFixture, - ): - registry = LicenseProvidersRegistry() - registry.register(object, canonical="mock_api") # type: ignore[arg-type] - collection = db.collection(protocol="mock_api") - controller = CollectionSelfTestsController(db.session, registry) - response = controller.run_self_tests(collection.integration_configuration) - assert response is None - - def test_collection_self_tests_post( - self, - db: DatabaseTransactionFixture, - ): - mock = MagicMock() - - class MockApi(HasCollectionSelfTests): - def __new__(cls, *args, **kwargs): - nonlocal mock - return mock(*args, **kwargs) - - @property - def collection(self) -> None: - return None - - registry = LicenseProvidersRegistry() - registry.register(MockApi, canonical="Foo") # type: ignore[arg-type] - - collection = db.collection(protocol="Foo") - controller = CollectionSelfTestsController(db.session, registry) - - assert collection.integration_configuration.id is not None - response = controller.self_tests_process_post( - collection.integration_configuration.id - ) - - assert response.get_data(as_text=True) == "Successfully ran new self tests" - assert response.status_code == 200 - - mock.assert_called_once_with(db.session, collection) - mock()._run_self_tests.assert_called_once_with(db.session) - assert mock().store_self_test_results.call_count == 1 diff --git a/tests/api/admin/controller/test_collections.py b/tests/api/admin/controller/test_collections.py index 0d877b572..08bc43e84 100644 --- a/tests/api/admin/controller/test_collections.py +++ b/tests/api/admin/controller/test_collections.py @@ -3,6 +3,7 @@ import flask import pytest +from _pytest.monkeypatch import MonkeyPatch from flask import Response from werkzeug.datastructures import ImmutableMultiDict @@ -11,8 +12,10 @@ from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, CANNOT_DELETE_COLLECTION_WITH_CHILDREN, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_PARENT, MISSING_SERVICE, MISSING_SERVICE_NAME, @@ -22,8 +25,11 @@ UNKNOWN_PROTOCOL, ) from api.integration.registry.license_providers import LicenseProvidersRegistry +from api.selftest import HasCollectionSelfTests from core.model import AdminRole, Collection, ExternalIntegration, get_one -from core.util.problem_detail import ProblemDetail +from core.selftest import HasSelfTestsIntegrationConfiguration +from core.util.problem_detail import ProblemDetail, ProblemError +from tests.api.mockapi.axis import MockAxis360API from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.flask import FlaskAppFixture @@ -735,3 +741,167 @@ def test_collection_delete_cant_delete_parent( assert parent.integration_configuration.id is not None response = controller.process_delete(parent.integration_configuration.id) assert response == CANNOT_DELETE_COLLECTION_WITH_CHILDREN + + def test_collection_self_tests_with_no_identifier( + self, controller: CollectionSettingsController + ): + response = controller.process_collection_self_tests(None) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_collection_self_tests_with_no_collection_found( + self, controller: CollectionSettingsController + ): + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_get(-1) + assert excinfo.value.problem_detail == MISSING_SERVICE + + def test_collection_self_tests_with_unknown_protocol( + self, db: DatabaseTransactionFixture, controller: CollectionSettingsController + ): + collection = db.collection(protocol="test") + assert collection.integration_configuration.id is not None + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_get(collection.integration_configuration.id) + assert excinfo.value.problem_detail == UNKNOWN_PROTOCOL + + def test_collection_self_tests_with_unsupported_protocol( + self, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture + ): + registry = LicenseProvidersRegistry() + registry.register(object, canonical="mock_api") # type: ignore[arg-type] + collection = db.collection(protocol="mock_api") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + assert collection.integration_configuration.id is not None + + with flask_app_fixture.test_request_context_system_admin("/"): + result = controller.self_tests_process_get( + collection.integration_configuration.id + ) + + assert result.status_code == 200 + assert isinstance(result.json, dict) + assert result.json["self_test_results"]["self_test_results"] == { + "disabled": True, + "exception": "Self tests are not supported for this integration.", + } + + def test_collection_self_tests_test_get( + self, + db: DatabaseTransactionFixture, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + ): + collection = MockAxis360API.mock_collection( + db.session, + db.default_library(), + ) + + self_test_results = dict( + duration=0.9, + start="2018-08-08T16:04:05Z", + end="2018-08-08T16:05:05Z", + results=[], + ) + mock = MagicMock(return_value=self_test_results) + monkeypatch.setattr( + HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock + ) + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's collection object. + assert collection.integration_configuration.id is not None + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.self_tests_process_get( + collection.integration_configuration.id + ) + + data = response.json + assert isinstance(data, dict) + test_results = data.get("self_test_results") + assert isinstance(test_results, dict) + + assert test_results.get("id") == collection.integration_configuration.id + assert test_results.get("name") == collection.name + assert test_results.get("protocol") == collection.protocol + assert test_results.get("self_test_results") == self_test_results + assert mock.call_count == 1 + + def test_collection_self_tests_failed_post( + self, + db: DatabaseTransactionFixture, + controller: CollectionSettingsController, + monkeypatch: MonkeyPatch, + ): + collection = MockAxis360API.mock_collection( + db.session, + db.default_library(), + ) + + # This makes HasSelfTests.run_self_tests return no values + self_test_results = (None, None) + mock = MagicMock(return_value=self_test_results) + monkeypatch.setattr( + HasSelfTestsIntegrationConfiguration, "run_self_tests", mock + ) + + # Failed to run self tests + assert collection.integration_configuration.id is not None + + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_post(collection.integration_configuration.id) + + assert excinfo.value.problem_detail == FAILED_TO_RUN_SELF_TESTS + + def test_collection_self_tests_run_self_tests_unsupported_collection( + self, + db: DatabaseTransactionFixture, + ): + registry = LicenseProvidersRegistry() + registry.register(object, canonical="mock_api") # type: ignore[arg-type] + collection = db.collection(protocol="mock_api") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + response = controller.run_self_tests(collection.integration_configuration) + assert response is None + + def test_collection_self_tests_post( + self, + db: DatabaseTransactionFixture, + ): + mock = MagicMock() + + class MockApi(HasCollectionSelfTests): + def __new__(cls, *args, **kwargs): + nonlocal mock + return mock(*args, **kwargs) + + @property + def collection(self) -> None: + return None + + registry = LicenseProvidersRegistry() + registry.register(MockApi, canonical="Foo") # type: ignore[arg-type] + + collection = db.collection(protocol="Foo") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + + assert collection.integration_configuration.id is not None + response = controller.self_tests_process_post( + collection.integration_configuration.id + ) + + assert response.get_data(as_text=True) == "Successfully ran new self tests" + assert response.status_code == 200 + + mock.assert_called_once_with(db.session, collection) + mock()._run_self_tests.assert_called_once_with(db.session) + assert mock().store_self_test_results.call_count == 1 diff --git a/tests/api/admin/controller/test_patron_auth.py b/tests/api/admin/controller/test_patron_auth.py index 5fd50e8eb..05edc7ab5 100644 --- a/tests/api/admin/controller/test_patron_auth.py +++ b/tests/api/admin/controller/test_patron_auth.py @@ -14,10 +14,12 @@ from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, INVALID_CONFIGURATION_OPTION, INVALID_LIBRARY_IDENTIFIER_RESTRICTION_REGULAR_EXPRESSION, + MISSING_IDENTIFIER, MISSING_SERVICE, MISSING_SERVICE_NAME, MULTIPLE_BASIC_AUTH_SERVICES, @@ -38,6 +40,7 @@ from core.model import Library, get_one from core.model.integration import IntegrationConfiguration from core.problem_details import INVALID_INPUT +from core.selftest import HasSelfTestsIntegrationConfiguration from core.util.problem_detail import ProblemDetail from tests.fixtures.flask import FlaskAppFixture @@ -659,3 +662,151 @@ def test_patron_auth_service_delete( id=auth_service.id, ) assert service is None + + def test_patron_auth_self_tests_with_no_identifier( + self, controller: PatronAuthServicesController + ): + response = controller.process_patron_auth_service_self_tests(None) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_patron_auth_self_tests_with_no_auth_service_found( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/"): + response = controller.process_patron_auth_service_self_tests(-1) + assert isinstance(response, ProblemDetail) + assert response == MISSING_SERVICE + assert response.status_code == 404 + + def test_patron_auth_self_tests_get_with_no_libraries( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + ): + auth_service, _ = create_simple_auth_integration() + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + results = response.get("self_test_results", {}).get("self_test_results") + assert results.get("disabled") is True + assert ( + results.get("exception") + == "You must associate this service with at least one library before you can run self tests for it." + ) + + def test_patron_auth_self_tests_test_get_no_results( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + default_library: Library, + ): + auth_service, _ = create_simple_auth_integration(library=default_library) + + # Make sure that we return the correct response when there are no results + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + response_auth_service = response.get("self_test_results", {}) + + assert response_auth_service.get("name") == auth_service.name + assert response_auth_service.get("protocol") == auth_service.protocol + assert response_auth_service.get("id") == auth_service.id + assert auth_service.goal is not None + assert response_auth_service.get("goal") == auth_service.goal.value + assert response_auth_service.get("self_test_results") == "No results yet" + + def test_patron_auth_self_tests_test_get( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + default_library: Library, + ): + expected_results = dict( + duration=0.9, + start="2018-08-08T16:04:05Z", + end="2018-08-08T16:05:05Z", + results=[], + ) + auth_service, _ = create_simple_auth_integration(library=default_library) + auth_service.self_test_results = expected_results + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's self tests object. + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + response_auth_service = response.get("self_test_results", {}) + + assert response_auth_service.get("name") == auth_service.name + assert response_auth_service.get("protocol") == auth_service.protocol + assert response_auth_service.get("id") == auth_service.id + assert auth_service.goal is not None + assert response_auth_service.get("goal") == auth_service.goal.value + assert response_auth_service.get("self_test_results") == expected_results + + def test_patron_auth_self_tests_post_with_no_libraries( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + ): + auth_service, _ = create_simple_auth_integration() + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id, + ) + assert isinstance(response, ProblemDetail) + assert response.title == FAILED_TO_RUN_SELF_TESTS.title + assert response.detail is not None + assert "Failed to run self tests" in response.detail + assert response.status_code == 400 + + def test_patron_auth_self_tests_test_post( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + monkeypatch: MonkeyPatch, + db: DatabaseTransactionFixture, + ): + expected_results = ("value", "results") + mock = MagicMock(return_value=expected_results) + monkeypatch.setattr( + HasSelfTestsIntegrationConfiguration, "run_self_tests", mock + ) + library = db.default_library() + auth_service, _ = create_simple_auth_integration(library=library) + + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response, Response) + assert response.status == "200 OK" + assert "Successfully ran new self tests" == response.get_data(as_text=True) + + assert mock.call_count == 1 + assert mock.call_args.args[0] == db.session + assert mock.call_args.args[1] is None + assert mock.call_args.args[2] == library.id + assert mock.call_args.args[3] == auth_service.id diff --git a/tests/api/admin/controller/test_patron_auth_self_tests.py b/tests/api/admin/controller/test_patron_auth_self_tests.py deleted file mode 100644 index cc75202e0..000000000 --- a/tests/api/admin/controller/test_patron_auth_self_tests.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import pytest -from flask import Response - -from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, -) -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, -) -from core.model import Library -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail -from tests.fixtures.flask import FlaskAppFixture - -if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch - - from tests.fixtures.authenticator import SimpleAuthIntegrationFixture - from tests.fixtures.database import DatabaseTransactionFixture - - -@pytest.fixture -def controller(db: DatabaseTransactionFixture) -> PatronAuthServiceSelfTestsController: - return PatronAuthServiceSelfTestsController(db.session) - - -class TestPatronAuthSelfTests: - def test_patron_auth_self_tests_with_no_identifier( - self, controller: PatronAuthServiceSelfTestsController - ): - response = controller.process_patron_auth_service_self_tests(None) - assert isinstance(response, ProblemDetail) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_patron_auth_self_tests_with_no_auth_service_found( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - ): - with flask_app_fixture.test_request_context("/"): - response = controller.process_patron_auth_service_self_tests(-1) - assert isinstance(response, ProblemDetail) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_patron_auth_self_tests_get_with_no_libraries( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - ): - auth_service, _ = create_simple_auth_integration() - with flask_app_fixture.test_request_context("/"): - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = response_obj.json - assert isinstance(response, dict) - results = response.get("self_test_results", {}).get("self_test_results") - assert results.get("disabled") is True - assert ( - results.get("exception") - == "You must associate this service with at least one library before you can run self tests for it." - ) - - def test_patron_auth_self_tests_test_get_no_results( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - default_library: Library, - ): - auth_service, _ = create_simple_auth_integration(library=default_library) - - # Make sure that we return the correct response when there are no results - with flask_app_fixture.test_request_context("/"): - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = response_obj.json - assert isinstance(response, dict) - response_auth_service = response.get("self_test_results", {}) - - assert response_auth_service.get("name") == auth_service.name - assert response_auth_service.get("protocol") == auth_service.protocol - assert response_auth_service.get("id") == auth_service.id - assert auth_service.goal is not None - assert response_auth_service.get("goal") == auth_service.goal.value - assert response_auth_service.get("self_test_results") == "No results yet" - - def test_patron_auth_self_tests_test_get( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - default_library: Library, - ): - expected_results = dict( - duration=0.9, - start="2018-08-08T16:04:05Z", - end="2018-08-08T16:05:05Z", - results=[], - ) - auth_service, _ = create_simple_auth_integration(library=default_library) - auth_service.self_test_results = expected_results - - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - with flask_app_fixture.test_request_context("/"): - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = response_obj.json - assert isinstance(response, dict) - response_auth_service = response.get("self_test_results", {}) - - assert response_auth_service.get("name") == auth_service.name - assert response_auth_service.get("protocol") == auth_service.protocol - assert response_auth_service.get("id") == auth_service.id - assert auth_service.goal is not None - assert response_auth_service.get("goal") == auth_service.goal.value - assert response_auth_service.get("self_test_results") == expected_results - - def test_patron_auth_self_tests_post_with_no_libraries( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - ): - auth_service, _ = create_simple_auth_integration() - with flask_app_fixture.test_request_context("/", method="POST"): - response = controller.process_patron_auth_service_self_tests( - auth_service.id, - ) - assert isinstance(response, ProblemDetail) - assert response.title == FAILED_TO_RUN_SELF_TESTS.title - assert response.detail is not None - assert "Failed to run self tests" in response.detail - assert response.status_code == 400 - - def test_patron_auth_self_tests_test_post( - self, - controller: PatronAuthServiceSelfTestsController, - flask_app_fixture: FlaskAppFixture, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - monkeypatch: MonkeyPatch, - db: DatabaseTransactionFixture, - ): - expected_results = ("value", "results") - mock = MagicMock(return_value=expected_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) - library = db.default_library() - auth_service, _ = create_simple_auth_integration(library=library) - - with flask_app_fixture.test_request_context("/", method="POST"): - response = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response, Response) - assert response.status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - assert mock.call_count == 1 - assert mock.call_args.args[0] == db.session - assert mock.call_args.args[1] is None - assert mock.call_args.args[2] == library.id - assert mock.call_args.args[3] == auth_service.id diff --git a/tests/api/admin/test_routes.py b/tests/api/admin/test_routes.py index 6ffeb047d..44e5a289e 100644 --- a/tests/api/admin/test_routes.py +++ b/tests/api/admin/test_routes.py @@ -495,15 +495,6 @@ def test_process_post(self, fixture: AdminRouteFixture): ) fixture.assert_supported_methods(url, "DELETE") - -class TestAdminCollectionSelfTests: - CONTROLLER_NAME = "admin_collection_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - def test_process_collection_self_tests(self, fixture: AdminRouteFixture): url = "/admin/collection_self_tests/" fixture.assert_authenticated_request_calls( @@ -556,15 +547,6 @@ def test_process_delete(self, fixture: AdminRouteFixture): ) fixture.assert_supported_methods(url, "DELETE") - -class TestAdminPatronAuthServicesSelfTests: - CONTROLLER_NAME = "admin_patron_auth_service_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - def test_process_patron_auth_service_self_tests(self, fixture: AdminRouteFixture): url = "/admin/patron_auth_service_self_tests/" fixture.assert_authenticated_request_calls( From 5e5a17c0d7042b0bb13b9d5a10e7c87d1411f283 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 26 Jan 2024 15:38:47 -0400 Subject: [PATCH 21/33] Convert metadata services to use integration settings. (#1627) --- ..._993729d4bf97_migrate_metadata_services.py | 94 +++++ api/admin/controller/__init__.py | 8 - .../controller/metadata_service_self_tests.py | 22 -- api/admin/controller/metadata_services.py | 205 +++++----- api/admin/controller/self_tests.py | 75 ---- api/admin/routes.py | 2 +- api/circulation_manager.py | 6 - api/integration/registry/metadata.py | 13 + api/lanes.py | 27 +- api/metadata/__init__.py | 0 api/metadata/base.py | 50 +++ api/{ => metadata}/novelist.py | 261 ++++++++----- api/{ => metadata}/nyt.py | 162 +++++--- core/feed/annotator/circulation.py | 2 +- core/integration/goals.py | 1 + core/model/configuration.py | 13 - core/model/datasource.py | 2 +- core/model/identifier.py | 3 + pyproject.toml | 2 + scripts.py | 4 +- .../test_metadata_service_self_tests.py | 120 ------ .../controller/test_metadata_services.py | 211 ++++++++-- tests/api/controller/test_work.py | 18 +- tests/api/feed/test_library_annotator.py | 19 +- tests/api/metadata/__init__.py | 0 tests/api/{ => metadata}/test_novelist.py | 362 ++++++++---------- tests/api/{ => metadata}/test_nyt.py | 48 ++- tests/api/test_lanes.py | 42 +- tests/api/test_scripts.py | 2 +- 29 files changed, 930 insertions(+), 844 deletions(-) create mode 100644 alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py delete mode 100644 api/admin/controller/metadata_service_self_tests.py delete mode 100644 api/admin/controller/self_tests.py create mode 100644 api/integration/registry/metadata.py create mode 100644 api/metadata/__init__.py create mode 100644 api/metadata/base.py rename api/{ => metadata}/novelist.py (79%) rename api/{ => metadata}/nyt.py (73%) delete mode 100644 tests/api/admin/controller/test_metadata_service_self_tests.py create mode 100644 tests/api/metadata/__init__.py rename tests/api/{ => metadata}/test_novelist.py (70%) rename tests/api/{ => metadata}/test_nyt.py (90%) diff --git a/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py b/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py new file mode 100644 index 000000000..a2890b7bd --- /dev/null +++ b/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py @@ -0,0 +1,94 @@ +"""migrate metadata services + +Revision ID: 993729d4bf97 +Revises: 735bf6ced8b9 +Create Date: 2024-01-24 23:51:13.464107+00:00 + +""" +from alembic import op +from api.integration.registry.metadata import MetadataRegistry +from core.integration.base import HasLibraryIntegrationConfiguration +from core.migration.migrate_external_integration import ( + _migrate_external_integration, + _migrate_library_settings, + get_configuration_settings, + get_integrations, + get_library_for_integration, +) +from core.migration.util import pg_update_enum + +# revision identifiers, used by Alembic. +revision = "993729d4bf97" +down_revision = "735bf6ced8b9" +branch_labels = None +depends_on = None + +METADATA_GOAL = "METADATA_GOAL" +old_goals_enum = ["PATRON_AUTH_GOAL", "LICENSE_GOAL", "DISCOVERY_GOAL", "CATALOG_GOAL"] +new_goals_enum = old_goals_enum + [METADATA_GOAL] + + +def upgrade() -> None: + # Add the new enum value to our goals enum + pg_update_enum( + op, + "integration_configurations", + "goal", + "goals", + old_goals_enum, + new_goals_enum, + ) + + # Migrate the existing metadata services to integration configurations + connection = op.get_bind() + registry = MetadataRegistry() + integrations = get_integrations(connection, "metadata") + for integration in integrations: + _id, protocol, name = integration + protocol_class = registry[protocol] + + ( + settings_dict, + libraries_settings, + self_test_result, + ) = get_configuration_settings(connection, integration) + + updated_protocol = registry.get_protocol(protocol_class) + if updated_protocol is None: + raise RuntimeError(f"Unknown metadata service '{protocol}'") + integration_configuration_id = _migrate_external_integration( + connection, + integration.name, + updated_protocol, + protocol_class, + METADATA_GOAL, + settings_dict, + self_test_result, + ) + + integration_libraries = get_library_for_integration(connection, _id) + for library in integration_libraries: + if issubclass(protocol_class, HasLibraryIntegrationConfiguration): + _migrate_library_settings( + connection, + integration_configuration_id, + library.library_id, + libraries_settings[library.library_id], + protocol_class, + ) + else: + raise RuntimeError( + f"Protocol not expected to have library settings '{protocol}'" + ) + + +def downgrade() -> None: + # Remove the new enum value from our goals enum. + pg_update_enum( + op, + "integration_configurations", + "goal", + "goals", + new_goals_enum, + old_goals_enum, + ) diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 5ba14207b..822c133a5 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -26,14 +26,10 @@ def setup_admin_controllers(manager: CirculationManager): ) from api.admin.controller.lanes import LanesController from api.admin.controller.library_settings import LibrarySettingsController - from api.admin.controller.metadata_service_self_tests import ( - MetadataServiceSelfTestsController, - ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.self_tests import SelfTestsController from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController from api.admin.controller.sitewide_settings import ( @@ -54,15 +50,11 @@ def setup_admin_controllers(manager: CirculationManager): manager.admin_dashboard_controller = DashboardController(manager) manager.admin_settings_controller = SettingsController(manager) manager.admin_patron_controller = PatronController(manager) - manager.admin_self_tests_controller = SelfTestsController(manager) manager.admin_discovery_services_controller = DiscoveryServicesController(manager) manager.admin_discovery_service_library_registrations_controller = ( DiscoveryServiceLibraryRegistrationsController(manager) ) manager.admin_metadata_services_controller = MetadataServicesController(manager) - manager.admin_metadata_service_self_tests_controller = ( - MetadataServiceSelfTestsController(manager) - ) manager.admin_patron_auth_services_controller = PatronAuthServicesController( manager ) diff --git a/api/admin/controller/metadata_service_self_tests.py b/api/admin/controller/metadata_service_self_tests.py deleted file mode 100644 index eefda5496..000000000 --- a/api/admin/controller/metadata_service_self_tests.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Self-tests for metadata integrations.""" -from flask_babel import lazy_gettext as _ - -from api.admin.controller.metadata_services import MetadataServicesController -from api.admin.controller.self_tests import SelfTestsController -from core.model import ExternalIntegration - - -class MetadataServiceSelfTestsController( - MetadataServicesController, SelfTestsController -): - def __init__(self, manager): - super().__init__(manager) - self.type = _("metadata service") - - def process_metadata_service_self_tests(self, identifier): - return self._manage_self_tests(identifier) - - def look_up_by_id(self, id): - return self.look_up_service_by_id( - id, protocol=None, goal=ExternalIntegration.METADATA_GOAL - ) diff --git a/api/admin/controller/metadata_services.py b/api/admin/controller/metadata_services.py index 24e0003cf..19e6f43dd 100644 --- a/api/admin/controller/metadata_services.py +++ b/api/admin/controller/metadata_services.py @@ -1,126 +1,115 @@ +from typing import Any + import flask from flask import Response -from flask_babel import lazy_gettext as _ -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - NO_PROTOCOL_FOR_NEW_SERVICE, +from api.admin.controller.base import AdminPermissionsControllerMixin +from api.admin.controller.integration_settings import ( + IntegrationSettingsSelfTestsController, ) -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI -from core.model import ExternalIntegration, get_one -from core.util.problem_detail import ProblemDetail - - -class MetadataServicesController(SettingsController): - def __init__(self, manager): - super().__init__(manager) - self.provider_apis = [ - NYTBestSellerAPI, - NoveListAPI, - ] - - self.protocols = self._get_integration_protocols( - self.provider_apis, protocol_name_attr="PROTOCOL" - ) - self.goal = ExternalIntegration.METADATA_GOAL - self.type = _("metadata service") +from api.admin.form_data import ProcessFormData +from api.admin.problem_details import DUPLICATE_INTEGRATION +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.base import MetadataServiceType +from core.integration.base import HasLibraryIntegrationConfiguration +from core.integration.registry import IntegrationRegistry +from core.model import ( + IntegrationConfiguration, + get_one, + json_serializer, + site_configuration_has_changed, +) +from core.selftest import HasSelfTestsIntegrationConfiguration +from core.util.problem_detail import ProblemDetail, ProblemError + + +class MetadataServicesController( + IntegrationSettingsSelfTestsController[MetadataServiceType], + AdminPermissionsControllerMixin, +): + def create_new_service(self, name: str, protocol: str) -> IntegrationConfiguration: + impl_cls = self.registry[protocol] + if not impl_cls.multiple_services_allowed(): + # If the service doesn't allow multiple instances, check if one already exists + existing_service = get_one( + self._db, + IntegrationConfiguration, + goal=self.registry.goal, + protocol=protocol, + ) + if existing_service is not None: + raise ProblemError(DUPLICATE_INTEGRATION) + return super().create_new_service(name, protocol) - def process_metadata_services(self): + def default_registry(self) -> IntegrationRegistry[MetadataServiceType]: + return MetadataRegistry() + + def process_metadata_services(self) -> Response | ProblemDetail: self.require_system_admin() if flask.request.method == "GET": return self.process_get() else: return self.process_post() - def process_get(self): - metadata_services = self._get_integration_info(self.goal, self.protocols) - for service in metadata_services: - service_object = get_one( - self._db, - ExternalIntegration, - id=service.get("id"), - goal=ExternalIntegration.METADATA_GOAL, - ) - protocol_class, tuple = self.find_protocol_class(service_object) - service["self_test_results"] = self._get_prior_test_results( - service_object, protocol_class, *tuple - ) - - return dict( - metadata_services=metadata_services, - protocols=self.protocols, + def process_get(self) -> Response: + return Response( + json_serializer( + { + "metadata_services": self.configured_services, + "protocols": list(self.protocols.values()), + } + ), + status=200, + mimetype="application/json", ) - def find_protocol_class(self, integration): - if integration.protocol == ExternalIntegration.NYT: - return (NYTBestSellerAPI, (NYTBestSellerAPI.from_config, self._db)) - elif integration.protocol == ExternalIntegration.NOVELIST: - return (NoveListAPI, (NoveListAPI.from_config, self._db)) - raise NotImplementedError( - "No metadata self-test class for protocol %s" % integration.protocol - ) - - def process_post(self): - name = flask.request.form.get("name") - protocol = flask.request.form.get("protocol") - url = flask.request.form.get("url") - fields = {"name": name, "protocol": protocol, "url": url} - form_field_error = self.validate_form_fields(**fields) - if form_field_error: - return form_field_error - - id = flask.request.form.get("id") - is_new = False - if id: - # Find an existing service in order to edit it - service = self.look_up_service_by_id(id, protocol) - else: - service, is_new = self._create_integration( - self.protocols, protocol, self.goal - ) - - if isinstance(service, ProblemDetail): + def process_post(self) -> Response | ProblemDetail: + try: + form_data = flask.request.form + libraries_data = self.get_libraries_data(form_data) + metadata_service, protocol, response_code = self.get_service(form_data) + + # Update settings + impl_cls = self.registry[protocol] + settings_class = impl_cls.settings_class() + validated_settings = ProcessFormData.get_settings(settings_class, form_data) + metadata_service.settings_dict = validated_settings.dict() + + # Update library settings + if libraries_data and issubclass( + impl_cls, HasLibraryIntegrationConfiguration + ): + self.process_libraries( + metadata_service, libraries_data, impl_cls.library_settings_class() + ) + + # Trigger a site configuration change + site_configuration_has_changed(self._db) + + except ProblemError as e: self._db.rollback() - return service + return e.problem_detail - name_error = self.check_name_unique(service, name) - if name_error: - self._db.rollback() - return name_error + return Response(str(metadata_service.id), response_code) - protocol_error = self.set_protocols(service, protocol) - if protocol_error: - self._db.rollback() - return protocol_error + def process_delete(self, service_id: int) -> Response: + self.require_system_admin() + return self.delete_service(service_id) + + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + settings = protocol_class.settings_load(integration) + test_result, _ = protocol_class.run_self_tests( + self._db, protocol_class, self._db, settings + ) + return test_result - service.name = name + return None - if is_new: - return Response(str(service.id), 201) - else: - return Response(str(service.id), 200) - - def validate_form_fields(self, **fields): - """The 'name' and 'protocol' fields cannot be blank, and the protocol must - be selected from the list of recognized protocols. The URL must be valid.""" - name = fields.get("name") - protocol = fields.get("protocol") - url = fields.get("url") - - if not name: - return INCOMPLETE_CONFIGURATION - if not protocol: - return NO_PROTOCOL_FOR_NEW_SERVICE - - error = self.validate_protocol() - if error: - return error - - wrong_format = self.validate_formats() - if wrong_format: - return wrong_format - - def process_delete(self, service_id): - return self._delete_integration(service_id, self.goal) + def process_metadata_service_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) diff --git a/api/admin/controller/self_tests.py b/api/admin/controller/self_tests.py deleted file mode 100644 index b8fd6e45f..000000000 --- a/api/admin/controller/self_tests.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import flask -from flask import Response -from flask_babel import lazy_gettext as _ - -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import FAILED_TO_RUN_SELF_TESTS, MISSING_IDENTIFIER -from core.util.problem_detail import ProblemDetail - - -class SelfTestsController(SettingsController): - def _manage_self_tests(self, identifier): - """Generic request-processing method.""" - if not identifier: - return MISSING_IDENTIFIER - if flask.request.method == "GET": - return self.self_tests_process_get(identifier) - else: - return self.self_tests_process_post(identifier) - - def find_protocol_class(self, integration): - """Given an ExternalIntegration, find the class on which run_tests() - or prior_test_results() should be called, and any extra - arguments that should be passed into the call. - """ - if not hasattr(self, "_find_protocol_class"): - raise NotImplementedError() - protocol_class = self._find_protocol_class(integration) - if isinstance(protocol_class, tuple): - protocol_class, extra_arguments = protocol_class - else: - extra_arguments = () - return protocol_class, extra_arguments - - def get_info(self, integration): - protocol_class, ignore = self.find_protocol_class(integration) - [protocol] = self._get_integration_protocols([protocol_class]) - return dict( - id=integration.id, - name=integration.name, - protocol=protocol, - settings=protocol.get("settings"), - goal=integration.goal, - ) - - def run_tests(self, integration): - protocol_class, extra_arguments = self.find_protocol_class(integration) - value, results = protocol_class.run_self_tests(self._db, *extra_arguments) - return value - - def self_tests_process_get(self, identifier): - integration = self.look_up_by_id(identifier) - if isinstance(integration, ProblemDetail): - return integration - info = self.get_info(integration) - protocol_class, extra_arguments = self.find_protocol_class(integration) - info["self_test_results"] = self._get_prior_test_results( - integration, protocol_class, *extra_arguments - ) - return dict(self_test_results=info) - - def self_tests_process_post(self, identifier): - integration = self.look_up_by_id(identifier) - if isinstance(integration, ProblemDetail): - return integration - value = self.run_tests(integration) - if value and isinstance(value, ProblemDetail): - return value - elif value: - return Response(_("Successfully ran new self tests"), 200) - - return FAILED_TO_RUN_SELF_TESTS.detailed( - _("Failed to run self tests for this %(type)s.", type=self.type) - ) diff --git a/api/admin/routes.py b/api/admin/routes.py index 565a2e239..ca98eff30 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -481,7 +481,7 @@ def metadata_service(service_id): @requires_admin @requires_csrf_token def metadata_service_self_tests(identifier): - return app.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( + return app.manager.admin_metadata_services_controller.process_metadata_service_self_tests( identifier ) diff --git a/api/circulation_manager.py b/api/circulation_manager.py index 1e51c53e5..e66f5f530 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -59,15 +59,11 @@ ) from api.admin.controller.lanes import LanesController from api.admin.controller.library_settings import LibrarySettingsController - from api.admin.controller.metadata_service_self_tests import ( - MetadataServiceSelfTestsController, - ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.self_tests import SelfTestsController from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController from api.admin.controller.sitewide_settings import ( @@ -106,11 +102,9 @@ class CirculationManager(LoggerMixin): admin_dashboard_controller: DashboardController admin_settings_controller: SettingsController admin_patron_controller: PatronController - admin_self_tests_controller: SelfTestsController admin_discovery_services_controller: DiscoveryServicesController admin_discovery_service_library_registrations_controller: DiscoveryServiceLibraryRegistrationsController admin_metadata_services_controller: MetadataServicesController - admin_metadata_service_self_tests_controller: MetadataServiceSelfTestsController admin_patron_auth_services_controller: PatronAuthServicesController admin_collection_settings_controller: CollectionSettingsController admin_sitewide_configuration_settings_controller: SitewideConfigurationSettingsController diff --git a/api/integration/registry/metadata.py b/api/integration/registry/metadata.py new file mode 100644 index 000000000..f0a2e6398 --- /dev/null +++ b/api/integration/registry/metadata.py @@ -0,0 +1,13 @@ +from api.metadata.base import MetadataServiceType +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI +from core.integration.goals import Goals +from core.integration.registry import IntegrationRegistry + + +class MetadataRegistry(IntegrationRegistry[MetadataServiceType]): + def __init__(self) -> None: + super().__init__(Goals.METADATA_GOAL) + + self.register(NYTBestSellerAPI, canonical="New York Times") + self.register(NoveListAPI, canonical="NoveList Select") diff --git a/api/lanes.py b/api/lanes.py index 13f269bde..e0fab4abb 100644 --- a/api/lanes.py +++ b/api/lanes.py @@ -2,7 +2,8 @@ import core.classifier as genres from api.config import CannotLoadConfiguration, Configuration -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from core import classifier from core.classifier import Classifier, GenreData, fiction_genres, nonfiction_genres from core.lane import ( @@ -12,16 +13,7 @@ Lane, WorkList, ) -from core.model import ( - Contributor, - DataSource, - Edition, - ExternalIntegration, - Library, - Session, - create, - get_one, -) +from core.model import Contributor, DataSource, Edition, Library, Session, create from core.util import LanguageCodes @@ -275,16 +267,13 @@ def create_lanes_for_large_collection(_db, library, languages, priority=0): adult_common_args = dict(common_args) adult_common_args["audiences"] = ADULT - include_best_sellers = False nyt_data_source = DataSource.lookup(_db, DataSource.NYT) - nyt_integration = get_one( - _db, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, - protocol=ExternalIntegration.NYT, - ) - if nyt_integration: + try: + NYTBestSellerAPI.from_config(_db) include_best_sellers = True + except CannotLoadConfiguration: + # No NYT Best Seller integration is configured. + include_best_sellers = False sublanes = [] if include_best_sellers: diff --git a/api/metadata/__init__.py b/api/metadata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/metadata/base.py b/api/metadata/base.py new file mode 100644 index 000000000..be15944cd --- /dev/null +++ b/api/metadata/base.py @@ -0,0 +1,50 @@ +import functools +from abc import ABC, abstractmethod +from typing import Any, TypeVar + +from sqlalchemy.orm import Session + +from core.integration.base import ( + HasIntegrationConfiguration, + HasLibraryIntegrationConfiguration, +) +from core.integration.settings import BaseSettings + + +class MetadataServiceSettings(BaseSettings): + ... + + +SettingsType = TypeVar("SettingsType", bound=MetadataServiceSettings, covariant=True) + + +class MetadataService( + HasIntegrationConfiguration[SettingsType], + ABC, +): + @classmethod + def protocol_details(cls, db: Session) -> dict[str, Any]: + details = super().protocol_details(db) + details["sitewide"] = not issubclass(cls, HasLibraryIntegrationConfiguration) + return details + + @classmethod + @functools.cache + def protocols(cls) -> list[str]: + from api.integration.registry.metadata import MetadataRegistry + + registry = MetadataRegistry() + protocols = registry.get_protocols(cls) + + if not protocols: + raise RuntimeError(f"No protocols found for {cls.__name__}") + + return protocols + + @classmethod + @abstractmethod + def multiple_services_allowed(cls) -> bool: + ... + + +MetadataServiceType = MetadataService[MetadataServiceSettings] diff --git a/api/novelist.py b/api/metadata/novelist.py similarity index 79% rename from api/novelist.py rename to api/metadata/novelist.py index c910fa45c..a8197036d 100644 --- a/api/novelist.py +++ b/api/metadata/novelist.py @@ -1,15 +1,24 @@ +import datetime import json import logging +import sys import urllib.error import urllib.parse import urllib.request from collections import Counter +from collections.abc import Mapping +from typing import Any -from flask_babel import lazy_gettext as _ +from requests import Response +from sqlalchemy.engine import Row from sqlalchemy.orm import aliased from sqlalchemy.sql import and_, join, or_, select +from api.metadata.base import MetadataService, MetadataServiceSettings from core.config import CannotLoadConfiguration +from core.integration.base import HasLibraryIntegrationConfiguration +from core.integration.goals import Goals +from core.integration.settings import BaseSettings, ConfigurationFormItem, FormField from core.metadata_layer import ( ContributorData, IdentifierData, @@ -24,9 +33,10 @@ DataSource, Edition, Equivalency, - ExternalIntegration, Hyperlink, Identifier, + IntegrationConfiguration, + Library, LicensePool, Measurement, Representation, @@ -35,32 +45,48 @@ ) from core.util import TitleProcessor from core.util.http import HTTP +from core.util.log import LoggerMixin +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self -class NoveListAPI: - PROTOCOL = ExternalIntegration.NOVELIST - NAME = _("Novelist API") +class NoveListApiSettings(MetadataServiceSettings): + """Settings for the NoveList API""" + + username: str = FormField( + ..., + form=ConfigurationFormItem( + label="Profile", + ), + ) + password: str = FormField( + ..., + form=ConfigurationFormItem( + label="Password", + ), + ) + + +class NoveListApiLibrarySettings(BaseSettings): + ... + + +class NoveListAPI( + MetadataService[NoveListApiSettings], + HasLibraryIntegrationConfiguration[NoveListApiSettings, NoveListApiLibrarySettings], + LoggerMixin, +): # Hardcoded authentication key used as a Header for calling the NoveList # Collections API. It identifies the client, and lets NoveList know that # SimplyE is making the requests. + # TODO: This is leftover from before the fork with SimplyE. We should probably + # get a new API key for Palace and use that instead. AUTHORIZED_IDENTIFIER = "62521fa1-bdbb-4939-84aa-aee2a52c8d59" - SETTINGS = [ - {"key": ExternalIntegration.USERNAME, "label": _("Profile"), "required": True}, - {"key": ExternalIntegration.PASSWORD, "label": _("Password"), "required": True}, - ] - - # Different libraries may have different NoveList integrations - # on the same circulation manager. - SITEWIDE = False - - IS_CONFIGURED = None - _configuration_library_id = None - - log = logging.getLogger("NoveList API") version = "2.2" - NO_ISBN_EQUIVALENCY = "No clear ISBN equivalency: %r" # While the NoveList API doesn't require parameters to be passed via URL, @@ -74,66 +100,81 @@ class NoveListAPI: AUTH_PARAMS = "&profile=%(profile)s&password=%(password)s" MAX_REPRESENTATION_AGE = 7 * 24 * 60 * 60 # one week - currentQueryIdentifier = None - medium_to_book_format_type_values = { Edition.BOOK_MEDIUM: "EBook", Edition.AUDIO_MEDIUM: "Audiobook", } @classmethod - def from_config(cls, library): - profile, password = cls.values(library) - if not (profile and password): + def from_config(cls, library: Library) -> Self: + settings = cls.values(library) + if not settings: raise CannotLoadConfiguration( "No NoveList integration configured for library (%s)." % library.short_name ) _db = Session.object_session(library) - return cls(_db, profile, password) + return cls(_db, settings) @classmethod - def values(cls, library): + def integration(cls, library: Library) -> IntegrationConfiguration | None: _db = Session.object_session(library) - - integration = ExternalIntegration.lookup( - _db, - ExternalIntegration.NOVELIST, - ExternalIntegration.METADATA_GOAL, - library=library, + query = select(IntegrationConfiguration).where( + IntegrationConfiguration.goal == Goals.METADATA_GOAL, + IntegrationConfiguration.libraries.contains(library), + IntegrationConfiguration.protocol.in_(cls.protocols()), ) + return _db.execute(query).scalar_one_or_none() + + @classmethod + def values(cls, library: Library) -> NoveListApiSettings | None: + integration = cls.integration(library) if not integration: - return (None, None) + return None + + return cls.settings_load(integration) - profile = integration.username - password = integration.password - return (profile, password) + @classmethod + def is_configured(cls, library: Library) -> bool: + integration = cls.integration(library) + return integration is not None + + @classmethod + def label(cls) -> str: + return "Novelist API" + + @classmethod + def description(cls) -> str: + return "" @classmethod - def is_configured(cls, library): - if cls.IS_CONFIGURED is None or library.id != cls._configuration_library_id: - profile, password = cls.values(library) - cls.IS_CONFIGURED = bool(profile and password) - cls._configuration_library_id = library.id - return cls.IS_CONFIGURED - - def __init__(self, _db, profile, password): + def settings_class(cls) -> type[NoveListApiSettings]: + return NoveListApiSettings + + @classmethod + def library_settings_class(cls) -> type[NoveListApiLibrarySettings]: + return NoveListApiLibrarySettings + + @classmethod + def multiple_services_allowed(cls) -> bool: + return True + + def __init__(self, _db: Session, settings: NoveListApiSettings) -> None: self._db = _db - self.profile = profile - self.password = password + self.profile = settings.username + self.password = settings.password @property - def source(self): - return DataSource.lookup(self._db, DataSource.NOVELIST) + def source(self) -> DataSource: + return DataSource.lookup(self._db, DataSource.NOVELIST) # type: ignore[no-any-return] - def lookup_equivalent_isbns(self, identifier): + def lookup_equivalent_isbns(self, identifier: Identifier) -> Metadata | None: """Finds NoveList data for all ISBNs equivalent to an identifier. :return: Metadata object or None """ - lookup_metadata = [] license_sources = DataSource.license_sources_for(self._db, identifier) # Find strong ISBN equivalents. @@ -179,14 +220,17 @@ def lookup_equivalent_isbns(self, identifier): best_metadata, confidence = self.choose_best_metadata( lookup_metadata, identifier ) - if best_metadata: - if round(confidence, 2) < 0.5: - self.log.warning(self.NO_ISBN_EQUIVALENCY, identifier) - return None - return metadata + if best_metadata is None or confidence is None: + return None + + if round(confidence, 2) < 0.5: + self.log.warning(self.NO_ISBN_EQUIVALENCY, identifier) + return None + + return best_metadata @classmethod - def _confirm_same_identifier(self, metadata_objects): + def _confirm_same_identifier(self, metadata_objects: list[Metadata]) -> bool: """Ensures that all metadata objects have the same NoveList ID""" novelist_ids = { @@ -194,7 +238,9 @@ def _confirm_same_identifier(self, metadata_objects): } return len(novelist_ids) == 1 - def choose_best_metadata(self, metadata_objects, identifier): + def choose_best_metadata( + self, metadata_objects: list[Metadata], identifier: Identifier + ) -> tuple[Metadata, float] | tuple[None, None]: """Chooses the most likely book metadata from a list of Metadata objects Given several Metadata objects with different NoveList IDs, this @@ -208,7 +254,7 @@ def choose_best_metadata(self, metadata_objects, identifier): # One or more of the equivalents did not return the same NoveList work self.log.warning("%r has inaccurate ISBN equivalents", identifier) - counter = Counter() + counter: Counter[Identifier] = Counter() for metadata in metadata_objects: counter[metadata.primary_identifier] += 1 @@ -225,7 +271,7 @@ def choose_best_metadata(self, metadata_objects, identifier): ] return target_metadata[0], confidence - def lookup(self, identifier, **kwargs): + def lookup(self, identifier: Identifier, **kwargs: Any) -> Metadata | None: """Requests NoveList metadata for a particular identifier :param kwargs: Keyword arguments passed into Representation.post(). @@ -236,9 +282,13 @@ def lookup(self, identifier, **kwargs): if identifier.type != Identifier.ISBN: return self.lookup_equivalent_isbns(identifier) + isbn = identifier.identifier + if isbn is None: + return None + params = dict( ClientIdentifier=client_identifier, - ISBN=identifier.identifier, + ISBN=isbn, version=self.version, profile=self.profile, password=self.password, @@ -251,7 +301,7 @@ def lookup(self, identifier, **kwargs): # We want to make an HTTP request for `url` but cache the # result under `scrubbed_url`. Define a 'URL normalization' # function that always returns `scrubbed_url`. - def normalized_url(original): + def normalized_url(original: str) -> str: return scrubbed_url representation, from_cache = Representation.post( @@ -272,22 +322,21 @@ def normalized_url(original): return self.lookup_info_to_metadata(representation) @classmethod - def review_response(cls, response): + def review_response(cls, response: tuple[int, dict[str, str], bytes]) -> None: """Performs NoveList-specific error review of the request response""" status_code, headers, content = response if status_code == 403: raise Exception("Invalid NoveList credentials") if content.startswith(b'"Missing'): raise Exception("Invalid NoveList parameters: %s" % content.decode("utf-8")) - return response @classmethod - def scrubbed_url(cls, params): + def scrubbed_url(cls, params: Mapping[str, str]) -> str: """Removes authentication details from cached Representation.url""" return cls.build_query_url(params, include_auth=False) @classmethod - def _scrub_subtitle(cls, subtitle): + def _scrub_subtitle(cls, subtitle: str | None) -> str | None: """Removes common NoveList subtitle annoyances""" if subtitle: subtitle = subtitle.replace("[electronic resource]", "") @@ -296,7 +345,9 @@ def _scrub_subtitle(cls, subtitle): return subtitle @classmethod - def build_query_url(cls, params, include_auth=True): + def build_query_url( + cls, params: Mapping[str, str], include_auth: bool = True + ) -> str: """Builds a unique and url-encoded query endpoint""" url = cls.QUERY_ENDPOINT if include_auth: @@ -307,7 +358,9 @@ def build_query_url(cls, params, include_auth=True): urlencoded_params[name] = urllib.parse.quote(value) return url % urlencoded_params - def lookup_info_to_metadata(self, lookup_representation): + def lookup_info_to_metadata( + self, lookup_representation: Response + ) -> Metadata | None: """Transforms a NoveList JSON representation into a Metadata object""" if not lookup_representation.content: @@ -415,10 +468,15 @@ def lookup_info_to_metadata(self, lookup_representation): or metadata.subtitle or metadata.recommendations ): - metadata = None + return None return metadata - def get_series_information(self, metadata, series_info, book_info): + def get_series_information( + self, + metadata: Metadata, + series_info: Mapping[str, Any] | None, + book_info: Mapping[str, Any], + ) -> tuple[Metadata, str]: """Returns metadata object with series info and optimal title key""" title_key = "main_title" @@ -456,10 +514,10 @@ def get_series_information(self, metadata, series_info, book_info): return metadata, title_key - def _extract_isbns(self, book_info): + def _extract_isbns(self, book_info: Mapping[str, Any]) -> list[IdentifierData]: isbns = [] - synonymous_ids = book_info.get("manifestations") + synonymous_ids = book_info.get("manifestations", []) for synonymous_id in synonymous_ids: isbn = synonymous_id.get("ISBN") if isbn: @@ -468,18 +526,20 @@ def _extract_isbns(self, book_info): return isbns - def get_recommendations(self, metadata, recommendations_info): + def get_recommendations( + self, metadata: Metadata, recommendations_info: Mapping[str, Any] | None + ) -> Metadata: if not recommendations_info: return metadata - related_books = recommendations_info.get("titles") + related_books = recommendations_info.get("titles", []) related_books = [b for b in related_books if b.get("is_held_locally")] if related_books: for book_info in related_books: metadata.recommendations += self._extract_isbns(book_info) return metadata - def get_items_from_query(self, library): + def get_items_from_query(self, library: Library) -> list[dict[str, str]]: """Gets identifiers and its related title, medium, and authors from the database. Keeps track of the current 'ISBN' identifier and current item object that @@ -515,18 +575,18 @@ def get_items_from_query(self, library): ) .select_from( join(LicensePool, i1, i1.id == LicensePool.identifier_id) - .join(Equivalency, i1.id == Equivalency.input_id, LEFT_OUTER_JOIN) + .join(Equivalency, i1.id == Equivalency.input_id, LEFT_OUTER_JOIN) # type: ignore[arg-type] .join(i2, Equivalency.output_id == i2.id, LEFT_OUTER_JOIN) .join( - Edition, + Edition, # type: ignore[arg-type] or_( Edition.primary_identifier_id == i1.id, Edition.primary_identifier_id == i2.id, ), ) - .join(Contribution, Edition.id == Contribution.edition_id) - .join(Contributor, Contribution.contributor_id == Contributor.id) - .join(DataSource, DataSource.id == LicensePool.data_source_id) + .join(Contribution, Edition.id == Contribution.edition_id) # type: ignore[arg-type] + .join(Contributor, Contribution.contributor_id == Contributor.id) # type: ignore[arg-type] + .join(DataSource, DataSource.id == LicensePool.data_source_id) # type: ignore[arg-type] ) .where( and_( @@ -541,9 +601,9 @@ def get_items_from_query(self, library): result = self._db.execute(isbnQuery) items = [] - newItem = None - existingItem = None - currentIdentifier = None + newItem: dict[str, str] | None = None + existingItem: dict[str, str] | None = None + currentIdentifier: str | None = None # Loop through the query result. There's a need to keep track of the # previously processed object and the currently processed object because @@ -571,7 +631,14 @@ def get_items_from_query(self, library): return items - def create_item_object(self, object, currentIdentifier, existingItem): + def create_item_object( + self, + object: Row + | tuple[str, str, str, str, str, datetime.date, str, str, str] + | None, + currentIdentifier: str | None, + existingItem: dict[str, str] | None, + ) -> tuple[str | None, dict[str, str] | None, dict[str, str] | None, bool]: """Returns a new item if the current identifier that was processed is not the same as the new object's ISBN being processed. If the new object's ISBN matches the current identifier, the previous object's @@ -654,10 +721,10 @@ def create_item_object(self, object, currentIdentifier, existingItem): return (isbn, existingItem, newItem, addItem) - def put_items_novelist(self, library): + def put_items_novelist(self, library: Library) -> dict[str, Any] | None: items = self.get_items_from_query(library) - content = None + content: dict[str, Any] | None = None if items: data = json.dumps(self.make_novelist_data_object(items)) response = self.put( @@ -679,34 +746,16 @@ def put_items_novelist(self, library): return content - def make_novelist_data_object(self, items): + def make_novelist_data_object(self, items: list[dict[str, str]]) -> dict[str, Any]: return { "customer": f"{self.profile}:{self.password}", "records": items, } - def put(self, url, headers, **kwargs): - data = kwargs.get("data") - if "data" in kwargs: - del kwargs["data"] + def put(self, url: str, headers: Mapping[str, str], **kwargs: Any) -> Response: + data = kwargs.pop("data", None) # This might take a very long time -- disable the normal # timeout. kwargs["timeout"] = None response = HTTP.put_with_timeout(url, data, headers=headers, **kwargs) return response - - -class MockNoveListAPI(NoveListAPI): - def __init__(self, _db, *args, **kwargs): - self._db = _db - self.responses = [] - - def setup_method(self, *args): - self.responses = self.responses + list(args) - - def lookup(self, identifier): - if not self.responses: - return [] - response = self.responses[0] - self.responses = self.responses[1:] - return response diff --git a/api/nyt.py b/api/metadata/nyt.py similarity index 73% rename from api/nyt.py rename to api/metadata/nyt.py index 3fb22e39a..ad74cd5f1 100644 --- a/api/nyt.py +++ b/api/metadata/nyt.py @@ -1,25 +1,48 @@ +from __future__ import annotations + +from core.selftest import HasSelfTestsIntegrationConfiguration, SelfTestResult + """Interface to the New York Times APIs.""" import json -import logging -from datetime import datetime, timedelta +import sys +from collections.abc import Generator +from datetime import date, datetime, timedelta +from typing import Any from dateutil import tz -from flask_babel import lazy_gettext as _ +from sqlalchemy import select from sqlalchemy.orm.session import Session from api.config import CannotLoadConfiguration, IntegrationException +from api.metadata.base import MetadataService, MetadataServiceSettings from core.external_list import TitleFromExternalList +from core.integration.goals import Goals +from core.integration.settings import ConfigurationFormItem, FormField from core.metadata_layer import ContributorData, IdentifierData, Metadata from core.model import ( CustomList, DataSource, Edition, - ExternalIntegration, Identifier, + IntegrationConfiguration, Representation, get_one_or_create, ) -from core.selftest import HasSelfTests +from core.util.log import LoggerMixin + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +class NytBestSellerApiSettings(MetadataServiceSettings): + password: str = FormField( + ..., + form=ConfigurationFormItem( + label="API key", + ), + ) class NYTAPI: @@ -36,7 +59,7 @@ class NYTAPI: TIME_ZONE = tz.gettz("America/New York") @classmethod - def parse_datetime(cls, d): + def parse_datetime(cls, d: str) -> datetime: """Used to parse the publication date of a NYT best-seller list. We take midnight Eastern time to be the publication time. @@ -44,7 +67,7 @@ def parse_datetime(cls, d): return datetime.strptime(d, cls.DATE_FORMAT).replace(tzinfo=cls.TIME_ZONE) @classmethod - def parse_date(cls, d): + def parse_date(cls, d: str) -> date: """Used to parse the publication date of a book. We don't know the timezone here, so the date will end up being @@ -53,23 +76,16 @@ def parse_date(cls, d): return cls.parse_datetime(d).date() @classmethod - def date_string(cls, d): + def date_string(cls, d: date) -> str: return d.strftime(cls.DATE_FORMAT) -class NYTBestSellerAPI(NYTAPI, HasSelfTests): - PROTOCOL = ExternalIntegration.NYT - GOAL = ExternalIntegration.METADATA_GOAL - NAME = _("NYT Best Seller API") - CARDINALITY = 1 - - SETTINGS = [ - {"key": ExternalIntegration.PASSWORD, "label": _("API key"), "required": True}, - ] - - # An NYT integration is shared by all libraries in a circulation manager. - SITEWIDE = True - +class NYTBestSellerAPI( + NYTAPI, + MetadataService[NytBestSellerApiSettings], + HasSelfTestsIntegrationConfiguration, + LoggerMixin, +): BASE_URL = "http://api.nytimes.com/svc/books/v3/lists" LIST_NAMES_URL = BASE_URL + "/names.json" @@ -80,37 +96,54 @@ class NYTBestSellerAPI(NYTAPI, HasSelfTests): HISTORICAL_LIST_MAX_AGE = timedelta(days=365) @classmethod - def from_config(cls, _db, **kwargs): - integration = cls.external_integration(_db) + def label(cls) -> str: + return "NYT Best Seller API" + + @classmethod + def description(cls) -> str: + return "" + + @classmethod + def settings_class(cls) -> type[NytBestSellerApiSettings]: + return NytBestSellerApiSettings + + @classmethod + def integration(cls, _db: Session) -> IntegrationConfiguration | None: + query = select(IntegrationConfiguration).where( + IntegrationConfiguration.goal == Goals.METADATA_GOAL, + IntegrationConfiguration.protocol.in_(cls.protocols()), + ) + return _db.execute(query).scalar_one_or_none() + + @classmethod + def from_config(cls, _db: Session) -> Self: + integration = cls.integration(_db) if not integration: - message = "No ExternalIntegration found for the NYT." + message = "No Integration found for the NYT." raise CannotLoadConfiguration(message) - return cls(_db, api_key=integration.password, **kwargs) + settings = cls.settings_load(integration) + return cls(_db, settings=settings) - def __init__(self, _db, api_key=None, do_get=None): - self.log = logging.getLogger("NYT API") + def __init__(self, _db: Session, settings: NytBestSellerApiSettings) -> None: self._db = _db - if not api_key: - raise CannotLoadConfiguration("No NYT API key is specified") - self.api_key = api_key - self.do_get = do_get or Representation.simple_http_get + self.api_key = settings.password @classmethod - def external_integration(cls, _db): - return ExternalIntegration.lookup( - _db, ExternalIntegration.NYT, ExternalIntegration.METADATA_GOAL - ) + def do_get( + cls, url: str, headers: dict[str, str], **kwargs: Any + ) -> tuple[int, dict[str, str], bytes]: + return Representation.simple_http_get(url, headers, **kwargs) - def _run_self_tests(self, _db): - yield self.run_test("Getting list of best-seller lists", self.list_of_lists) + @classmethod + def multiple_services_allowed(cls) -> bool: + return False - @property - def source(self): - return DataSource.lookup(_db, DataSource.NYT) + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: + yield self.run_test("Getting list of best-seller lists", self.list_of_lists) - def request(self, path, identifier=None, max_age=LIST_MAX_AGE): + def request(self, path: str, max_age: timedelta = LIST_MAX_AGE) -> dict[str, Any]: if not path.startswith(self.BASE_URL): if not path.startswith("/"): path = "/" + path @@ -133,7 +166,7 @@ def request(self, path, identifier=None, max_age=LIST_MAX_AGE): if status == 200: # Everything's fine. content = json.loads(representation.content) - return content + return content # type: ignore[no-any-return] diagnostic = "Response from {} was: {!r}".format( url, @@ -150,25 +183,30 @@ def request(self, path, identifier=None, max_age=LIST_MAX_AGE): "Unknown API error (status %s)" % status, diagnostic ) - def list_of_lists(self, max_age=LIST_OF_LISTS_MAX_AGE): + def list_of_lists(self, max_age: timedelta = LIST_MAX_AGE) -> dict[str, Any]: return self.request(self.LIST_NAMES_URL, max_age=max_age) - def list_info(self, list_name): + def list_info(self, list_name: str) -> dict[str, Any]: list_of_lists = self.list_of_lists() list_info = [ x for x in list_of_lists["results"] if x["list_name_encoded"] == list_name ] if not list_info: raise ValueError("No such list: %s" % list_name) - return list_info[0] + return list_info[0] # type: ignore[no-any-return] - def best_seller_list(self, list_info, date=None): + def best_seller_list(self, list_info: str | dict[str, Any]) -> NYTBestSellerList: """Create (but don't update) a NYTBestSellerList object.""" if isinstance(list_info, str): list_info = self.list_info(list_info) return NYTBestSellerList(list_info) - def update(self, list, date=None, max_age=LIST_MAX_AGE): + def update( + self, + list: NYTBestSellerList, + date: date | None = None, + max_age: timedelta = LIST_MAX_AGE, + ) -> None: """Update the given list with data from the given date.""" name = list.foreign_identifier url = self.LIST_URL % name @@ -178,15 +216,15 @@ def update(self, list, date=None, max_age=LIST_MAX_AGE): data = self.request(url, max_age=max_age) list.update(data) - def fill_in_history(self, list): + def fill_in_history(self, list: NYTBestSellerList) -> None: """Update the given list with current and historical data.""" for date in list.all_dates: self.update(list, date, self.HISTORICAL_LIST_MAX_AGE) self._db.commit() -class NYTBestSellerList(list): - def __init__(self, list_info): +class NYTBestSellerList(list["NYTBestSellerListTitle"], LoggerMixin): + def __init__(self, list_info: dict[str, Any]) -> None: self.name = list_info["display_name"] self.created = NYTAPI.parse_datetime(list_info["oldest_published_date"]) self.updated = NYTAPI.parse_datetime(list_info["newest_published_date"]) @@ -196,11 +234,10 @@ def __init__(self, list_info): elif list_info["updated"] == "MONTHLY": frequency = 30 self.frequency = timedelta(frequency) - self.items_by_isbn = dict() - self.log = logging.getLogger("NYT Best-seller list %s" % self.name) + self.items_by_isbn: dict[str, NYTBestSellerListTitle] = dict() @property - def medium(self): + def medium(self) -> str | None: """What medium are the books on this list? Lists like "Audio Fiction" contain audiobooks; all others @@ -216,7 +253,7 @@ def medium(self): return Edition.BOOK_MEDIUM @property - def all_dates(self): + def all_dates(self) -> Generator[datetime, None, None]: """Yield a list of estimated dates when new editions of this list were probably published. """ @@ -230,7 +267,7 @@ def all_dates(self): # We overshot the end date. yield end - def update(self, json_data): + def update(self, json_data: dict[str, Any]) -> None: """Update the list with information from the given JSON structure.""" for li_data in json_data.get("results", []): try: @@ -244,11 +281,13 @@ def update(self, json_data): self.items_by_isbn[key] = item self.append(item) # self.log.debug("Newly seen ISBN: %r, %s", key, len(self)) - except ValueError as e: + except ValueError: # Should only happen when the book has no identifier, which... # should never happen. self.log.error("No identifier for %r", li_data) item = None + + if item is None: continue # This is the date the *best-seller list* was published, @@ -262,7 +301,7 @@ def update(self, json_data): ): item.most_recent_appearance = list_date - def to_customlist(self, _db): + def to_customlist(self, _db: Session) -> CustomList: """Turn this NYTBestSeller list into a CustomList object.""" data_source = DataSource.lookup(_db, DataSource.NYT) l, was_new = get_one_or_create( @@ -279,7 +318,7 @@ def to_customlist(self, _db): self.update_custom_list(l) return l - def update_custom_list(self, custom_list): + def update_custom_list(self, custom_list: CustomList) -> None: """Make sure the given CustomList's CustomListEntries reflect the current state of the NYTBestSeller list. """ @@ -293,10 +332,9 @@ def update_custom_list(self, custom_list): class NYTBestSellerListTitle(TitleFromExternalList): - def __init__(self, data, medium): - data = data + def __init__(self, data: dict[str, Any], medium: str | None) -> None: try: - bestsellers_date = NYTAPI.parse_datetime(data.get("bestsellers_date")) + bestsellers_date = NYTAPI.parse_datetime(data.get("bestsellers_date")) # type: ignore[arg-type] first_appearance = bestsellers_date most_recent_appearance = bestsellers_date except ValueError as e: @@ -306,7 +344,7 @@ def __init__(self, data, medium): try: # This is the date the _book_ was published, not the date # the _bestseller list_ was published. - published_date = NYTAPI.parse_date(data.get("published_date")) + published_date = NYTAPI.parse_date(data.get("published_date")) # type: ignore[arg-type] except ValueError as e: published_date = None diff --git a/core/feed/annotator/circulation.py b/core/feed/annotator/circulation.py index 1e870d322..896a68c82 100644 --- a/core/feed/annotator/circulation.py +++ b/core/feed/annotator/circulation.py @@ -18,7 +18,7 @@ from api.circulation import BaseCirculationAPI, CirculationAPI from api.config import Configuration from api.lanes import DynamicLane -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI from core.analytics import Analytics from core.classifier import Classifier from core.config import CannotLoadConfiguration diff --git a/core/integration/goals.py b/core/integration/goals.py index 99db3d2d6..e69f675ed 100644 --- a/core/integration/goals.py +++ b/core/integration/goals.py @@ -10,3 +10,4 @@ class Goals(Enum): LICENSE_GOAL = "licenses" DISCOVERY_GOAL = "discovery" CATALOG_GOAL = "catalog" + METADATA_GOAL = "metadata" diff --git a/core/model/configuration.py b/core/model/configuration.py index fe27a01cd..d96ca0e8c 100644 --- a/core/model/configuration.py +++ b/core/model/configuration.py @@ -41,11 +41,6 @@ class ExternalIntegration(Base): # to this are defined in the circulation manager. PATRON_AUTH_GOAL = "patron_auth" - # These integrations are associated with external services such as - # the metadata wrangler, which provide information about books, - # but not the books themselves. - METADATA_GOAL = "metadata" - # These integrations are associated with external services such as # Opensearch that provide indexed search. SEARCH_GOAL = "search" @@ -102,14 +97,6 @@ class ExternalIntegration(Base): FEEDBOOKS: DataSourceConstants.FEEDBOOKS, } - # Integrations with METADATA_GOAL - BIBBLIO = "Bibblio" - CONTENT_CAFE = "Content Cafe" - NOVELIST = "NoveList Select" - NYPL_SHADOWCAT = "Shadowcat" - NYT = "New York Times" - CONTENT_SERVER = "Content Server" - # Integrations with SEARCH_GOAL OPENSEARCH = "Opensearch" diff --git a/core/model/datasource.py b/core/model/datasource.py index 085c18c46..955404809 100644 --- a/core/model/datasource.py +++ b/core/model/datasource.py @@ -56,7 +56,7 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): # One DataSource can generate many IDEquivalencies. id_equivalencies: Mapped[list[Equivalency]] = relationship( - "Equivalency", backref="data_source" + "Equivalency", back_populates="data_source" ) # One DataSource can grant access to many LicensePools. diff --git a/core/model/identifier.py b/core/model/identifier.py index 7a687255c..8804574cd 100644 --- a/core/model/identifier.py +++ b/core/model/identifier.py @@ -1118,6 +1118,9 @@ class Equivalency(Base): # Who says? data_source_id = Column(Integer, ForeignKey("datasources.id"), index=True) + data_source: Mapped[DataSource] = relationship( + "DataSource", back_populates="id_equivalencies" + ) # How many distinct votes went into this assertion? This will let # us scale the change to the strength when additional votes come diff --git a/pyproject.toml b/pyproject.toml index ec39fc822..8424ff89e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ module = [ "api.admin.controller.discovery_services", "api.admin.controller.integration_settings", "api.admin.controller.library_settings", + "api.admin.controller.metadata_services", "api.admin.controller.patron_auth_services", "api.admin.dashboard_stats", "api.admin.form_data", @@ -86,6 +87,7 @@ module = [ "api.integration.*", "api.lcp.hash", "api.marc", + "api.metadata.*", "api.odl", "api.odl2", "api.opds_for_distributors", diff --git a/scripts.py b/scripts.py index 25515015c..77265cf92 100644 --- a/scripts.py +++ b/scripts.py @@ -23,8 +23,8 @@ from api.config import CannotLoadConfiguration, Configuration from api.lanes import create_default_lanes from api.local_analytics_exporter import LocalAnalyticsExporter -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from api.opds_for_distributors import ( OPDSForDistributorsImporter, OPDSForDistributorsImportMonitor, diff --git a/tests/api/admin/controller/test_metadata_service_self_tests.py b/tests/api/admin/controller/test_metadata_service_self_tests.py deleted file mode 100644 index d1ed875ed..000000000 --- a/tests/api/admin/controller/test_metadata_service_self_tests.py +++ /dev/null @@ -1,120 +0,0 @@ -from unittest.mock import MagicMock, create_autospec - -import pytest -from _pytest.monkeypatch import MonkeyPatch -from flask import Response - -from api.admin.controller.metadata_service_self_tests import ( - MetadataServiceSelfTestsController, -) -from api.admin.problem_details import * -from api.nyt import NYTBestSellerAPI -from core.util.problem_detail import ProblemDetail -from tests.api.admin.controller.test_metadata_services import MetadataServicesFixture -from tests.fixtures.database import DatabaseTransactionFixture -from tests.fixtures.flask import FlaskAppFixture - - -class MetadataServiceSelfTestsFixture(MetadataServicesFixture): - def __init__(self, db: DatabaseTransactionFixture): - super().__init__(db) - manager = MagicMock() - manager._db = db.session - self.controller: MetadataServiceSelfTestsController = ( - MetadataServiceSelfTestsController(manager) - ) - self.db = db - - -@pytest.fixture -def metadata_services_fixture( - db: DatabaseTransactionFixture, -) -> MetadataServiceSelfTestsFixture: - return MetadataServiceSelfTestsFixture(db) - - -class TestMetadataServiceSelfTests: - def test_metadata_service_self_tests_with_no_identifier( - self, metadata_services_fixture: MetadataServiceSelfTestsFixture - ): - response = ( - metadata_services_fixture.controller.process_metadata_service_self_tests( - None - ) - ) - assert isinstance(response, ProblemDetail) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_metadata_service_self_tests_with_no_metadata_service_found( - self, - metadata_services_fixture: MetadataServiceSelfTestsFixture, - flask_app_fixture: FlaskAppFixture, - ): - with flask_app_fixture.test_request_context("/"): - response = metadata_services_fixture.controller.process_metadata_service_self_tests( - -1 - ) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_metadata_service_self_tests_test_get( - self, - metadata_services_fixture: MetadataServiceSelfTestsFixture, - flask_app_fixture: FlaskAppFixture, - monkeypatch: MonkeyPatch, - ): - metadata_service = metadata_services_fixture.create_nyt_integration() - mock_prior_test_results = create_autospec( - NYTBestSellerAPI.prior_test_results, return_value={"test": "results"} - ) - monkeypatch.setattr( - NYTBestSellerAPI, "prior_test_results", mock_prior_test_results - ) - - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - with flask_app_fixture.test_request_context("/"): - response_data = metadata_services_fixture.controller.process_metadata_service_self_tests( - metadata_service.id - ) - assert isinstance(response_data, dict) - response_metadata_service = response_data.get("self_test_results", {}) - - assert response_metadata_service.get("id") == metadata_service.id - assert response_metadata_service.get("name") == metadata_service.name - assert ( - response_metadata_service.get("protocol").get("label") - == NYTBestSellerAPI.NAME - ) - assert response_metadata_service.get("goal") == metadata_service.goal - assert response_metadata_service.get("self_test_results") == { - "test": "results" - } - - def test_metadata_service_self_tests_post( - self, - metadata_services_fixture: MetadataServiceSelfTestsFixture, - flask_app_fixture: FlaskAppFixture, - monkeypatch: MonkeyPatch, - db: DatabaseTransactionFixture, - ): - metadata_service = metadata_services_fixture.create_nyt_integration() - mock_run_self_tests = create_autospec( - NYTBestSellerAPI.run_self_tests, return_value=(dict(test="results"), None) - ) - monkeypatch.setattr(NYTBestSellerAPI, "run_self_tests", mock_run_self_tests) - - controller = metadata_services_fixture.controller - with flask_app_fixture.test_request_context("/", method="POST"): - response = controller.process_metadata_service_self_tests( - metadata_service.id - ) - assert isinstance(response, Response) - assert response.status_code == 200 - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - mock_run_self_tests.assert_called_once_with( - db.session, NYTBestSellerAPI.from_config, db.session - ) diff --git a/tests/api/admin/controller/test_metadata_services.py b/tests/api/admin/controller/test_metadata_services.py index 2afd3c14f..54fa37fb2 100644 --- a/tests/api/admin/controller/test_metadata_services.py +++ b/tests/api/admin/controller/test_metadata_services.py @@ -1,8 +1,9 @@ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import flask import pytest +from _pytest.monkeypatch import MonkeyPatch from flask import Response from werkzeug.datastructures import ImmutableMultiDict @@ -11,14 +12,21 @@ from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, DUPLICATE_INTEGRATION, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_SERVICE, + MISSING_SERVICE_NAME, NO_PROTOCOL_FOR_NEW_SERVICE, NO_SUCH_LIBRARY, UNKNOWN_PROTOCOL, ) -from core.model import ExternalIntegration, IntegrationConfiguration, create, get_one +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.novelist import NoveListAPI, NoveListApiSettings +from api.metadata.nyt import NYTBestSellerAPI, NytBestSellerApiSettings +from core.integration.goals import Goals +from core.model import IntegrationConfiguration, get_one from core.util.problem_detail import ProblemDetail from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.flask import FlaskAppFixture @@ -26,41 +34,44 @@ class MetadataServicesFixture: def __init__(self, db: DatabaseTransactionFixture): - novelist_protocol = ExternalIntegration.NOVELIST + self.registry = MetadataRegistry() + + novelist_protocol = self.registry.get_protocol(NoveListAPI) assert novelist_protocol is not None self.novelist_protocol = novelist_protocol - nyt_protocol = ExternalIntegration.NYT + nyt_protocol = self.registry.get_protocol(NYTBestSellerAPI) assert nyt_protocol is not None self.nyt_protocol = nyt_protocol manager = MagicMock() manager._db = db.session - self.controller = MetadataServicesController(manager) + self.controller = MetadataServicesController(manager, self.registry) self.db = db def create_novelist_integration( self, username: str = "user", password: str = "pass", - ) -> ExternalIntegration: - integration = self.db.external_integration( + ) -> IntegrationConfiguration: + integration = self.db.integration_configuration( protocol=self.novelist_protocol, - goal=ExternalIntegration.METADATA_GOAL, + goal=Goals.METADATA_GOAL, ) - integration.username = username - integration.password = password + settings = NoveListApiSettings(username=username, password=password) + NoveListAPI.settings_update(integration, settings) return integration def create_nyt_integration( self, api_key: str = "xyz", - ) -> ExternalIntegration: - integration = self.db.external_integration( + ) -> IntegrationConfiguration: + integration = self.db.integration_configuration( protocol=self.nyt_protocol, - goal=ExternalIntegration.METADATA_GOAL, + goal=Goals.METADATA_GOAL, ) - integration.password = api_key + settings = NytBestSellerApiSettings(password=api_key) + NYTBestSellerAPI.settings_update(integration, settings) return integration @@ -103,7 +114,8 @@ def test_process_metadata_services_dispatches_by_request_method( def test_process_get_with_no_services( self, metadata_services_fixture: MetadataServicesFixture ): - response_content = metadata_services_fixture.controller.process_get() + response = metadata_services_fixture.controller.process_get() + response_content = response.json assert isinstance(response_content, dict) assert response_content.get("metadata_services") == [] [nyt, novelist] = response_content.get("protocols", []) @@ -124,7 +136,8 @@ def test_process_get_with_one_service( novelist_service = metadata_services_fixture.create_novelist_integration() controller = metadata_services_fixture.controller - response_data = controller.process_get() + response = controller.process_get() + response_data = response.json assert isinstance(response_data, dict) [service] = response_data.get("metadata_services", []) @@ -134,7 +147,8 @@ def test_process_get_with_one_service( assert service.get("settings").get("password") == "pass" novelist_service.libraries += [db.default_library()] - response_data = controller.process_get() + response = controller.process_get() + response_data = response.json assert isinstance(response_data, dict) [service] = response_data.get("metadata_services", []) @@ -158,10 +172,14 @@ def test_metadata_services_post_errors( assert response == UNKNOWN_PROTOCOL with flask_app_fixture.test_request_context_system_admin("/", method="POST"): - flask.request.form = ImmutableMultiDict([]) + flask.request.form = ImmutableMultiDict( + [ + ("protocol", metadata_services_fixture.novelist_protocol), + ] + ) response = controller.process_post() assert isinstance(response, ProblemDetail) - assert response == INCOMPLETE_CONFIGURATION + assert response == MISSING_SERVICE_NAME with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( @@ -265,14 +283,15 @@ def test_metadata_services_post_create( # information. service = get_one( db.session, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, + IntegrationConfiguration, + goal=Goals.METADATA_GOAL, ) assert service is not None assert service.id == int(response.get_data(as_text=True)) assert service.protocol == metadata_services_fixture.novelist_protocol - assert service.username == "user" - assert service.password == "pass" + settings = NoveListAPI.settings_load(service) + assert settings.username == "user" + assert settings.password == "pass" assert service.libraries == [library] def test_metadata_services_post_create_multiple( @@ -345,10 +364,11 @@ def test_metadata_services_post_edit( response = controller.process_post() assert response.status_code == 200 - # The existing integration has been updated based on the submitted + # The existing IntegrationConfiguration has been updated based on the submitted # information. - assert novelist_service.username == "newuser" - assert novelist_service.password == "newpass" + settings = NoveListAPI.settings_load(novelist_service) + assert settings.username == "newuser" + assert settings.password == "newpass" assert novelist_service.libraries == [l2] def test_check_name_unique( @@ -357,19 +377,15 @@ def test_check_name_unique( flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, ): - existing_service, ignore = create( - db.session, - ExternalIntegration, + existing_service = db.integration_configuration( + protocol=metadata_services_fixture.novelist_protocol, + goal=Goals.METADATA_GOAL, name="existing service", - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, ) - new_service, ignore = create( - db.session, - ExternalIntegration, + new_service = db.integration_configuration( + protocol=metadata_services_fixture.novelist_protocol, + goal=Goals.METADATA_GOAL, name="new service", - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, ) # Try to change new service so that it has the same name as existing service @@ -454,3 +470,126 @@ def test_metadata_service_delete( id=novelist_service.id, ) assert service is None + + def test_metadata_service_self_tests_with_no_identifier( + self, metadata_services_fixture: MetadataServicesFixture + ): + response = ( + metadata_services_fixture.controller.process_metadata_service_self_tests( + None + ) + ) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_metadata_service_self_tests_with_no_metadata_service_found( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + -1 + ) + assert response == MISSING_SERVICE + assert response.status_code == 404 + + def test_metadata_service_self_tests_test_get( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + metadata_service.self_test_results = {"test": "results"} + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's self tests object. + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + response_metadata_service = response_data.get("self_test_results", {}) + + assert response_metadata_service.get("id") == metadata_service.id + assert response_metadata_service.get("name") == metadata_service.name + assert ( + response_metadata_service.get("protocol") + == metadata_services_fixture.nyt_protocol + ) + assert metadata_service.goal is not None + assert response_metadata_service.get("goal") == metadata_service.goal.value + assert response_metadata_service.get("self_test_results") == { + "test": "results" + } + + def test_metadata_service_self_tests_test_get_not_supported( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + metadata_service = metadata_services_fixture.create_novelist_integration() + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + metadata_service.id + ) + + assert isinstance(response, Response) + assert response.status_code == 200 + response_data = response.json + assert isinstance(response_data, dict) + response_metadata_service = response_data.get("self_test_results", {}) + assert response_metadata_service.get("id") == metadata_service.id + assert response_metadata_service.get("name") == metadata_service.name + assert response_metadata_service.get("protocol") == metadata_service.protocol + assert metadata_service.goal is not None + assert response_metadata_service.get("goal") == metadata_service.goal.value + assert response_metadata_service.get("self_test_results") == { + "exception": "Self tests are not supported for this integration.", + "disabled": True, + } + + def test_metadata_service_self_tests_post( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + db: DatabaseTransactionFixture, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + mock_run_self_tests = create_autospec( + NYTBestSellerAPI.run_self_tests, return_value=(dict(test="results"), None) + ) + monkeypatch.setattr(NYTBestSellerAPI, "run_self_tests", mock_run_self_tests) + + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, Response) + assert response.status_code == 200 + assert "Successfully ran new self tests" == response.get_data(as_text=True) + + mock_run_self_tests.assert_called_once_with( + db.session, NYTBestSellerAPI, db.session, {"password": "xyz"} + ) + + def test_metadata_service_self_tests_post_not_supported( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + ): + metadata_service = metadata_services_fixture.create_novelist_integration() + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, ProblemDetail) + assert response == FAILED_TO_RUN_SELF_TESTS diff --git a/tests/api/controller/test_work.py b/tests/api/controller/test_work.py index 68ff6ac68..c84b5687a 100644 --- a/tests/api/controller/test_work.py +++ b/tests/api/controller/test_work.py @@ -3,7 +3,7 @@ import urllib.parse from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import feedparser import flask @@ -19,7 +19,7 @@ SeriesFacets, SeriesLane, ) -from api.novelist import MockNoveListAPI +from api.metadata.novelist import NoveListAPI from api.problem_details import NO_SUCH_LANE, NOT_FOUND_ON_REMOTE from core.classifier import Classifier from core.entrypoint import AudiobooksEntryPoint @@ -47,7 +47,6 @@ from core.util.problem_detail import ProblemDetail from tests.fixtures.api_controller import CirculationControllerFixture from tests.fixtures.database import DatabaseTransactionFixture -from tests.mocks.search import fake_hits class WorkFixture(CirculationControllerFixture): @@ -496,7 +495,7 @@ def test_recommendations(self, work_fixture: WorkFixture): # Prep an empty recommendation. source = DataSource.lookup(work_fixture.db.session, self.datasource) metadata = Metadata(source) - mock_api = MockNoveListAPI(work_fixture.db.session) + mock_api = create_autospec(NoveListAPI) args = [self.identifier.type, self.identifier.identifier] kwargs: dict[str, Any] = dict(novelist_api=mock_api) @@ -524,12 +523,6 @@ def test_recommendations(self, work_fixture: WorkFixture): # If the NoveList API is configured, the search index is asked # about its recommendations. - # - # This test no longer makes sense, the external_search no longer blindly returns information - # The query_works is not overidden, so we mock it manually - work_fixture.manager.external_search.query_works = MagicMock( - return_value=fake_hits([work_fixture.english_1]) - ) with work_fixture.request_context_with_library("/"): response = work_fixture.manager.work_controller.recommendations( *args, **kwargs @@ -666,7 +659,7 @@ def test_related_books(self, work_fixture: WorkFixture): ) work_fixture.manager.external_search.mock_query_works([same_author_and_series]) - mock_api = MockNoveListAPI(work_fixture.db.session) + mock_api = create_autospec(NoveListAPI) # Create a fresh book, and set up a mock NoveList API to # recommend its identifier for any input. @@ -680,7 +673,7 @@ def test_related_books(self, work_fixture: WorkFixture): metadata = Metadata(overdrive) recommended_identifier = work_fixture.db.identifier() metadata.recommendations = [recommended_identifier] - mock_api.setup_method(metadata) + mock_api.lookup.return_value = metadata # Now, ask for works related to work_fixture.english_1. with work_fixture.request_context_with_library("/?entrypoint=Book"): @@ -742,7 +735,6 @@ def groups(cls, **kwargs): resp.as_response.return_value = Response("An OPDS feed") return resp - mock_api.setup_method(metadata) with work_fixture.request_context_with_library("/?entrypoint=Audio"): response = work_fixture.manager.work_controller.related( work_fixture.identifier.type, diff --git a/tests/api/feed/test_library_annotator.py b/tests/api/feed/test_library_annotator.py index ac16ecb55..dbd75bd94 100644 --- a/tests/api/feed/test_library_annotator.py +++ b/tests/api/feed/test_library_annotator.py @@ -10,8 +10,9 @@ from api.adobe_vendor_id import AuthdataUtility from api.circulation import BaseCirculationAPI, CirculationAPI, FulfillmentInfo +from api.integration.registry.metadata import MetadataRegistry from api.lanes import ContributorLane -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI, NoveListApiSettings from core.classifier import ( # type: ignore[attr-defined] Classifier, Fantasy, @@ -24,6 +25,7 @@ from core.feed.opds import UnfulfillableWork from core.feed.types import FeedData, WorkEntry from core.feed.util import strftime +from core.integration.goals import Goals from core.lane import Facets, FacetsWithEntryPoint, Pagination from core.lcp.credential import LCPCredentialFactory, LCPHashedPassphrase from core.model import ( @@ -31,7 +33,6 @@ Contributor, DataSource, DeliveryMechanism, - ExternalIntegration, Hyperlink, PresentationCalculationPolicy, Representation, @@ -866,14 +867,16 @@ def test_work_entry_includes_recommendations_link( ] # There's a recommendation link when configuration is found, though! - NoveListAPI.IS_CONFIGURED = None - annotator_fixture.db.external_integration( - ExternalIntegration.NOVELIST, - goal=ExternalIntegration.METADATA_GOAL, - username="library", - password="sure", + protocol = MetadataRegistry().get_protocol(NoveListAPI) + assert protocol is not None + integration = annotator_fixture.db.integration_configuration( + protocol=protocol, + goal=Goals.METADATA_GOAL, libraries=[annotator_fixture.db.default_library()], ) + NoveListAPI.settings_update( + integration, NoveListApiSettings(username="library", password="sure") + ) feed = self.get_parsed_feed(annotator_fixture, [work]) [entry] = feed.entries diff --git a/tests/api/metadata/__init__.py b/tests/api/metadata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/test_novelist.py b/tests/api/metadata/test_novelist.py similarity index 70% rename from tests/api/test_novelist.py rename to tests/api/metadata/test_novelist.py index 0fa3e2560..93c90a615 100644 --- a/tests/api/test_novelist.py +++ b/tests/api/metadata/test_novelist.py @@ -1,12 +1,15 @@ import datetime import json +from unittest.mock import MagicMock, create_autospec import pytest +from _pytest.monkeypatch import MonkeyPatch from api.config import CannotLoadConfiguration -from api.novelist import MockNoveListAPI, NoveListAPI +from api.metadata.novelist import NoveListAPI, NoveListApiSettings +from core.integration.goals import Goals from core.metadata_layer import Metadata -from core.model import DataSource, ExternalIntegration, Identifier +from core.model import DataSource, Identifier from core.util.http import HTTP from tests.core.mock import DummyHTTPClient, MockRequestsResponse from tests.fixtures.api_novelist_files import NoveListFilesFixture @@ -14,21 +17,16 @@ class NoveListFixture: - db: DatabaseTransactionFixture - files: NoveListFilesFixture - integration: ExternalIntegration - novelist: NoveListAPI - def __init__(self, db: DatabaseTransactionFixture, files: NoveListFilesFixture): self.db = db self.files = files - self.integration = db.external_integration( - ExternalIntegration.NOVELIST, - ExternalIntegration.METADATA_GOAL, - username="library", - password="yep", + self.settings = NoveListApiSettings(username="library", password="yep") + self.integration = db.integration_configuration( + "NoveList Select", + Goals.METADATA_GOAL, libraries=[db.default_library()], ) + NoveListAPI.settings_update(self.integration, self.settings) self.novelist = NoveListAPI.from_config(db.default_library()) def sample_data(self, filename): @@ -38,9 +36,6 @@ def sample_representation(self, filename): content = self.sample_data(filename) return self.db.representation(media_type="application/json", content=content)[0] - def close(self): - NoveListAPI.IS_CONFIGURED = None - @pytest.fixture(scope="function") def novelist_fixture( @@ -48,7 +43,6 @@ def novelist_fixture( ): fixture = NoveListFixture(db, api_novelist_files_fixture) yield fixture - fixture.close() class TestNoveListAPI: @@ -57,50 +51,39 @@ class TestNoveListAPI: def test_from_config(self, novelist_fixture: NoveListFixture): """Confirms that NoveListAPI can be built from config successfully""" novelist = NoveListAPI.from_config(novelist_fixture.db.default_library()) - assert True == isinstance(novelist, NoveListAPI) - assert "library" == novelist.profile - assert "yep" == novelist.password - - # Without either configuration value, an error is raised. - novelist_fixture.integration.password = None - pytest.raises( - CannotLoadConfiguration, - NoveListAPI.from_config, - novelist_fixture.db.default_library(), - ) + assert isinstance(novelist, NoveListAPI) is True + assert novelist.profile == "library" + assert novelist.password == "yep" - novelist_fixture.integration.password = "yep" - novelist_fixture.integration.username = None + # If the integration is not configured, an error is raised. + another_library = novelist_fixture.db.library() pytest.raises( CannotLoadConfiguration, NoveListAPI.from_config, - novelist_fixture.db.default_library(), + another_library, ) def test_is_configured(self, novelist_fixture: NoveListFixture): - # If an ExternalIntegration exists, the API is_configured - assert True == NoveListAPI.is_configured(novelist_fixture.db.default_library()) - # A class variable is set to reduce future database requests. - assert ( - novelist_fixture.db.default_library().id - == NoveListAPI._configuration_library_id - ) + # If a IntegrationLibraryConfiguration exists, the API is_configured + assert NoveListAPI.is_configured(novelist_fixture.db.default_library()) is True # If an ExternalIntegration doesn't exist for the library, it is not. library = novelist_fixture.db.library() - assert False == NoveListAPI.is_configured(library) - # And the class variable is updated. - assert library.id == NoveListAPI._configuration_library_id + assert NoveListAPI.is_configured(library) is False def test_review_response(self, novelist_fixture: NoveListFixture): - invalid_credential_response = (403, {}, b"HTML Access Denied page") # type: ignore + invalid_credential_response: tuple[int, dict[str, str], bytes] = ( + 403, + {}, + b"HTML Access Denied page", + ) pytest.raises( Exception, novelist_fixture.novelist.review_response, invalid_credential_response, ) - missing_argument_response = ( # type: ignore + missing_argument_response: tuple[int, dict[str, str], bytes] = ( 200, {}, b'"Missing ISBN, UPC, or Client Identifier!"', @@ -111,8 +94,8 @@ def test_review_response(self, novelist_fixture: NoveListFixture): missing_argument_response, ) - response = (200, {}, b"Here's the goods!") # type: ignore - assert response == novelist_fixture.novelist.review_response(response) + response: tuple[int, dict[str, str], bytes] = (200, {}, b"Here's the goods!") + novelist_fixture.novelist.review_response(response) def test_lookup_info_to_metadata(self, novelist_fixture: NoveListFixture): # Basic book information is returned @@ -122,35 +105,35 @@ def test_lookup_info_to_metadata(self, novelist_fixture: NoveListFixture): bad_character = novelist_fixture.sample_representation("a_bad_character.json") metadata = novelist_fixture.novelist.lookup_info_to_metadata(bad_character) - assert True == isinstance(metadata, Metadata) - assert Identifier.NOVELIST_ID == metadata.primary_identifier.type - assert "10392078" == metadata.primary_identifier.identifier - assert "A bad character" == metadata.title - assert None == metadata.subtitle - assert 1 == len(metadata.contributors) + assert isinstance(metadata, Metadata) + assert metadata.primary_identifier.type == Identifier.NOVELIST_ID + assert metadata.primary_identifier.identifier == "10392078" + assert metadata.title == "A bad character" + assert metadata.subtitle is None + assert len(metadata.contributors) == 1 [contributor] = metadata.contributors - assert "Kapoor, Deepti" == contributor.sort_name - assert 4 == len(metadata.identifiers) - assert 4 == len(metadata.subjects) - assert 2 == len(metadata.measurements) + assert contributor.sort_name == "Kapoor, Deepti" + assert len(metadata.identifiers) == 4 + assert len(metadata.subjects) == 4 + assert len(metadata.measurements) == 2 ratings = sorted(metadata.measurements, key=lambda m: m.value) - assert 2 == ratings[0].value - assert 3.27 == ratings[1].value - assert 625 == len(metadata.recommendations) + assert ratings[0].value == 2 + assert ratings[1].value == 3.27 + assert len(metadata.recommendations) == 625 # Confirm that Lexile and series data is extracted with a # different sample. vampire = novelist_fixture.sample_representation("vampire_kisses.json") metadata = novelist_fixture.novelist.lookup_info_to_metadata(vampire) - - [lexile] = filter(lambda s: s.type == "Lexile", metadata.subjects) - assert "630" == lexile.identifier - assert "Vampire kisses manga" == metadata.series + assert isinstance(metadata, Metadata) + [lexile] = [s for s in metadata.subjects if s.type == "Lexile"] + assert lexile.identifier == "630" + assert metadata.series == "Vampire kisses manga" # The full title should be selected, since every volume # has the same main title: 'Vampire kisses' - assert "Vampire kisses: blood relatives. Volume 1" == metadata.title - assert 1 == metadata.series_position - assert 5 == len(metadata.recommendations) + assert metadata.title == "Vampire kisses: blood relatives. Volume 1" + assert metadata.series_position == 1 + assert len(metadata.recommendations) == 5 def test_get_series_information(self, novelist_fixture: NoveListFixture): metadata = Metadata(data_source=DataSource.NOVELIST) @@ -162,11 +145,11 @@ def test_get_series_information(self, novelist_fixture: NoveListFixture): metadata, series_info, book_info ) # Relevant series information is extracted - assert "Vampire kisses manga" == metadata.series - assert 1 == metadata.series_position + assert metadata.series == "Vampire kisses manga" + assert metadata.series_position == 1 # The 'full_title' key should be returned as ideal because # all the volumes have the same 'main_title' - assert "full_title" == ideal_title_key + assert ideal_title_key == "full_title" watchman = json.loads( novelist_fixture.sample_data("alternate_series_example.json") @@ -184,10 +167,10 @@ def test_get_series_information(self, novelist_fixture: NoveListFixture): (metadata, ideal_title_key) = novelist_fixture.novelist.get_series_information( metadata, series_info, book_info ) - assert "Elvis Cole/Joe Pike novels" == metadata.series - assert 11 == metadata.series_position + assert metadata.series == "Elvis Cole/Joe Pike novels" + assert metadata.series_position == 11 # And recommends using the main_title - assert "main_title" == ideal_title_key + assert ideal_title_key == "main_title" # If the volume is found in the series more than once... book_info = dict( @@ -221,23 +204,26 @@ def test_lookup(self, novelist_fixture: NoveListFixture): h = DummyHTTPClient() h.queue_response(200, "text/html", content="yay") - class Mock(NoveListAPI): - def build_query_url(self, params): - self.build_query_url_called_with = params - return "http://query-url/" + novelist = novelist_fixture.novelist - def scrubbed_url(self, params): - self.scrubbed_url_called_with = params - return "http://scrubbed-url/" + mock_build_query_url = create_autospec( + novelist.build_query_url, return_value="http://query-url/" + ) + novelist.build_query_url = mock_build_query_url + + mock_scrubbed_url = create_autospec( + novelist.scrubbed_url, return_value="http://scrubbed-url/" + ) + novelist.scrubbed_url = mock_scrubbed_url - def review_response(self, response): - self.review_response_called_with = response + mock_review_response = create_autospec(novelist.review_response) + novelist.review_response = mock_review_response - def lookup_info_to_metadata(self, representation): - self.lookup_info_to_metadata_called_with = representation - return "some metadata" + mock_lookup_info_to_metadata = create_autospec( + novelist.lookup_info_to_metadata, return_value="some metadata" + ) + novelist.lookup_info_to_metadata = mock_lookup_info_to_metadata - novelist = Mock.from_config(novelist_fixture.db.default_library()) identifier = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) # Do the lookup. @@ -247,30 +233,27 @@ def lookup_info_to_metadata(self, representation): # get the URL of the HTTP request. The same parameters were # also passed into scrubbed_url(), to get the URL that should # be used when storing the Representation in the database. - params1 = novelist.build_query_url_called_with - params2 = novelist.scrubbed_url_called_with - assert params1 == params2 + assert mock_build_query_url.call_args == mock_scrubbed_url.call_args - assert ( - dict( - profile=novelist.profile, - ClientIdentifier=identifier.urn, - ISBN=identifier.identifier, - password=novelist.password, - version=novelist.version, - ) - == params1 + assert mock_build_query_url.call_args.args[0] == dict( + profile=novelist.profile, + ClientIdentifier=identifier.urn, + ISBN=identifier.identifier, + password=novelist.password, + version=novelist.version, ) # The HTTP request went out to the query URL -- not the scrubbed URL. assert ["http://query-url/"] == h.requests # The HTTP response was passed into novelist.review_response() - assert ( - 200, - {"content-type": "text/html"}, - b"yay", - ) == novelist.review_response_called_with + mock_review_response.assert_called_once_with( + ( + 200, + {"content-type": "text/html"}, + b"yay", + ) + ) # Finally, the Representation was passed into # lookup_info_to_metadata, which returned a hard-coded string @@ -280,7 +263,8 @@ def lookup_info_to_metadata(self, representation): # Looking at the Representation we can see that it was stored # in the database under its scrubbed URL, not the URL used to # make the request. - rep = novelist.lookup_info_to_metadata_called_with + mock_lookup_info_to_metadata.assert_called_once() + rep = mock_lookup_info_to_metadata.call_args.args[0] assert "http://scrubbed-url/" == rep.url assert b"yay" == rep.content @@ -291,13 +275,13 @@ def test_lookup_info_to_metadata_ignores_empty_responses( null_response = novelist_fixture.sample_representation("null_data.json") result = novelist_fixture.novelist.lookup_info_to_metadata(null_response) - assert None == result + assert result is None # This also happens when NoveList indicates with an empty # response that it doesn't know the ISBN. empty_response = novelist_fixture.sample_representation("unknown_isbn.json") result = novelist_fixture.novelist.lookup_info_to_metadata(empty_response) - assert None == result + assert result is None def test_build_query_url(self, novelist_fixture: NoveListFixture): params = dict( @@ -311,7 +295,7 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): # Authentication information is included in the URL by default full_result = novelist_fixture.novelist.build_query_url(params) auth_details = "&profile=username&password=secret" - assert True == full_result.endswith(auth_details) + assert full_result.endswith(auth_details) is True assert "profile=username" in full_result assert "password=secret" in full_result @@ -319,7 +303,7 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): scrubbed_result = novelist_fixture.novelist.build_query_url( params, include_auth=False ) - assert False == scrubbed_result.endswith(auth_details) + assert scrubbed_result.endswith(auth_details) is False assert "profile=username" not in scrubbed_result assert "password=secret" not in scrubbed_result @@ -331,16 +315,16 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): # The method to create a scrubbed url returns the same result # as the NoveListAPI.build_query_url - assert scrubbed_result == novelist_fixture.novelist.scrubbed_url(params) + assert novelist_fixture.novelist.scrubbed_url(params) == scrubbed_result def test_scrub_subtitle(self, novelist_fixture: NoveListFixture): """Unnecessary title segments are removed from subtitles""" scrub = novelist_fixture.novelist._scrub_subtitle - assert None == scrub(None) - assert None == scrub("[electronic resource]") - assert None == scrub("[electronic resource] : ") - assert "A Biomythography" == scrub("[electronic resource] : A Biomythography") + assert scrub(None) is None + assert scrub("[electronic resource]") is None + assert scrub("[electronic resource] : ") is None + assert scrub("[electronic resource] : A Biomythography") == "A Biomythography" def test_confirm_same_identifier(self, novelist_fixture: NoveListFixture): source = DataSource.lookup(novelist_fixture.db.session, DataSource.NOVELIST) @@ -354,71 +338,68 @@ def test_confirm_same_identifier(self, novelist_fixture: NoveListFixture): match = Metadata(source, primary_identifier=identifier) mistake = Metadata(source, primary_identifier=unmatched_identifier) - assert False == novelist_fixture.novelist._confirm_same_identifier( - [metadata, mistake] + assert ( + novelist_fixture.novelist._confirm_same_identifier([metadata, mistake]) + is False ) - assert True == novelist_fixture.novelist._confirm_same_identifier( - [metadata, match] + assert ( + novelist_fixture.novelist._confirm_same_identifier([metadata, match]) + is True ) def test_lookup_equivalent_isbns(self, novelist_fixture: NoveListFixture): identifier = novelist_fixture.db.identifier( identifier_type=Identifier.OVERDRIVE_ID ) - api = MockNoveListAPI.from_config(novelist_fixture.db.default_library()) + api = novelist_fixture.novelist + mock_lookup = create_autospec(api.lookup) + api.lookup = mock_lookup # If there are no ISBN equivalents, it returns None. - assert None == api.lookup_equivalent_isbns(identifier) + assert api.lookup_equivalent_isbns(identifier) is None source = DataSource.lookup(novelist_fixture.db.session, DataSource.OVERDRIVE) identifier.equivalent_to(source, novelist_fixture.db.identifier(), strength=1) novelist_fixture.db.session.commit() - assert None == api.lookup_equivalent_isbns(identifier) + assert api.lookup_equivalent_isbns(identifier) is None # If there's an ISBN equivalent, but it doesn't result in metadata, # it returns none. isbn = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) identifier.equivalent_to(source, isbn, strength=1) novelist_fixture.db.session.commit() - api.responses.append(None) - assert None == api.lookup_equivalent_isbns(identifier) - - # Create an API class that can mockout NoveListAPI.choose_best_metadata - class MockBestMetadataAPI(MockNoveListAPI): - choose_best_metadata_return = None + mock_lookup.return_value = None + assert api.lookup_equivalent_isbns(identifier) is None - def choose_best_metadata(self, *args, **kwargs): - return self.choose_best_metadata_return - - api = MockBestMetadataAPI.from_config(novelist_fixture.db.default_library()) + # Create an API class that can mockout NoveListAPI.choose_best_metadata, + # and make sure lookup returns something + mock_choose_best_metadata = create_autospec(api.choose_best_metadata) + api.choose_best_metadata = mock_choose_best_metadata + mock_lookup.return_value = create_autospec(Metadata) # Give the identifier another ISBN equivalent. isbn2 = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) identifier.equivalent_to(source, isbn2, strength=1) novelist_fixture.db.session.commit() - # Queue metadata responses for each ISBN lookup. - metadatas = [object(), object()] - api.responses.extend(metadatas) - # If choose_best_metadata returns None, the lookup returns None. - api.choose_best_metadata_return = (None, None) - assert None == api.lookup_equivalent_isbns(identifier) + mock_lookup.reset_mock() + mock_choose_best_metadata.return_value = (None, None) + assert api.lookup_equivalent_isbns(identifier) is None # Lookup was performed for both ISBNs. - assert [] == api.responses + assert mock_lookup.call_count == 2 # If choose_best_metadata returns a low confidence metadata, the # lookup returns None. - api.responses.extend(metadatas) - api.choose_best_metadata_return = (metadatas[0], 0.33) - assert None == api.lookup_equivalent_isbns(identifier) + mock_best_metadata = MagicMock() + mock_choose_best_metadata.return_value = (mock_best_metadata, 0.33) + assert api.lookup_equivalent_isbns(identifier) is None # If choose_best_metadata returns a high confidence metadata, the # lookup returns the metadata. - api.responses.extend(metadatas) - api.choose_best_metadata_return = (metadatas[1], 0.67) - assert metadatas[1] == api.lookup_equivalent_isbns(identifier) + mock_choose_best_metadata.return_value = (mock_best_metadata, 0.67) + assert api.lookup_equivalent_isbns(identifier) is mock_best_metadata def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): more_identifier = novelist_fixture.db.identifier( @@ -433,18 +414,18 @@ def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): result = novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() ) - assert True == isinstance(result, tuple) - assert metadatas[0] == result[0] + assert isinstance(result, tuple) is True + assert result[0] == metadatas[0] # A default confidence of 1.0 is returned. - assert 1.0 == result[1] + assert result[1] == 1.0 # When top identifiers have equal representation, the method returns none. metadatas.append( Metadata(DataSource.NOVELIST, primary_identifier=less_identifier) ) - assert (None, None) == novelist_fixture.novelist.choose_best_metadata( + assert novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() - ) + ) == (None, None) # But when one pulls ahead, we get the metadata object again. metadatas.append( @@ -453,18 +434,19 @@ def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): result = novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() ) - assert True == isinstance(result, tuple) + assert isinstance(result, tuple) metadata, confidence = result - assert True == isinstance(metadata, Metadata) - assert 0.67 == round(confidence, 2) - assert more_identifier == metadata.primary_identifier + assert isinstance(metadata, Metadata) + assert isinstance(confidence, float) + assert round(confidence, 2) == 0.67 + assert metadata.primary_identifier == more_identifier def test_get_items_from_query(self, novelist_fixture: NoveListFixture): items = novelist_fixture.novelist.get_items_from_query( novelist_fixture.db.default_library() ) # There are no books in the current library. - assert items == [] + assert [] == items # Set up a book for this library. edition = novelist_fixture.db.edition( @@ -492,7 +474,7 @@ def test_get_items_from_query(self, novelist_fixture: NoveListFixture): publicationDate=edition.published.strftime("%Y%m%d"), ) - assert items == [item] + assert [item] == items def test_create_item_object(self, novelist_fixture: NoveListFixture): # We pass no identifier or item to process so we get nothing back. @@ -502,10 +484,10 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): newItem, addItem, ) = novelist_fixture.novelist.create_item_object(None, None, None) - assert currentIdentifier == None - assert existingItem == None - assert newItem == None - assert addItem == False + assert currentIdentifier is None + assert existingItem is None + assert newItem is None + assert addItem is False # Item row from the db query # (identifier, identifier type, identifier, @@ -562,7 +544,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): novelist_fixture.novelist.create_item_object(book1_from_query, None, None) ) assert currentIdentifier == book1_from_query[2] - assert existingItem == None + assert existingItem is None assert newItem == { "isbn": "23456", "mediaType": "EBook", @@ -574,7 +556,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): } # We want to still process this item along with the next one in case # the following one has the same ISBN. - assert addItem == False + assert addItem is False # Note that `newItem` is what we get from the previous call from `create_item_object`. # We are now processing the previous object along with the new one. @@ -598,8 +580,8 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert newItem == None - assert addItem == False + assert newItem is None + assert addItem is False # Test that a narrator gets added along with an author. ( @@ -624,8 +606,8 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert newItem == None - assert addItem == False + assert newItem is None + assert addItem is False # New Object ( @@ -656,7 +638,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "14140101", } - assert addItem == True + assert addItem is True # New Object # Test that a narrator got added but not an author @@ -670,7 +652,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): ) assert currentIdentifier == book1_narrator_from_query[2] - assert existingItem == None + assert existingItem is None assert newItem == { "isbn": "23456", "mediaType": "EBook", @@ -680,26 +662,29 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert addItem == False + assert addItem is False def test_put_items_novelist(self, novelist_fixture: NoveListFixture): + mock_http_put = create_autospec(novelist_fixture.novelist.put) + novelist_fixture.novelist.put = mock_http_put + mock_http_put.side_effect = Exception("Failed to put items") + + # No items, so put never gets called and none gets returned response = novelist_fixture.novelist.put_items_novelist( novelist_fixture.db.default_library() ) - - assert response == None + assert response is None + assert mock_http_put.call_count == 0 edition = novelist_fixture.db.edition(identifier_type=Identifier.ISBN) pool = novelist_fixture.db.licensepool( edition, collection=novelist_fixture.db.default_collection() ) mock_response = {"Customer": "NYPL", "RecordsReceived": 10} - - def mockHTTPPut(url, headers, **kwargs): - return MockRequestsResponse(200, content=json.dumps(mock_response)) - - oldPut = novelist_fixture.novelist.put - novelist_fixture.novelist.put = mockHTTPPut + mock_http_put.side_effect = None + mock_http_put.return_value = MockRequestsResponse( + 200, content=json.dumps(mock_response) + ) response = novelist_fixture.novelist.put_items_novelist( novelist_fixture.db.default_library() @@ -707,10 +692,8 @@ def mockHTTPPut(url, headers, **kwargs): assert response == mock_response - novelist_fixture.novelist.put = oldPut - def test_make_novelist_data_object(self, novelist_fixture: NoveListFixture): - bad_data = [] # type: ignore + bad_data: list[dict[str, str]] = [] result = novelist_fixture.novelist.make_novelist_data_object(bad_data) assert result == {"customer": "library:yep", "records": []} @@ -733,25 +716,14 @@ def test_make_novelist_data_object(self, novelist_fixture: NoveListFixture): assert result == {"customer": "library:yep", "records": data} - def mockHTTPPut(self, *args, **kwargs): - self.called_with = (args, kwargs) - - def test_put(self, novelist_fixture: NoveListFixture): - oldPut = HTTP.put_with_timeout + def test_put(self, novelist_fixture: NoveListFixture, monkeypatch: MonkeyPatch): + mock_put = create_autospec(HTTP.put_with_timeout) + monkeypatch.setattr(HTTP, "put_with_timeout", mock_put) - HTTP.put_with_timeout = self.mockHTTPPut # type: ignore + headers = {"AuthorizedIdentifier": "authorized!"} + data = ["12345", "12346", "12347"] - try: - headers = {"AuthorizedIdentifier": "authorized!"} - isbns = ["12345", "12346", "12347"] - data = novelist_fixture.novelist.make_novelist_data_object(isbns) - - response = novelist_fixture.novelist.put( - "http://apiendpoint.com", headers, data=data - ) - (params, args) = self.called_with - - assert params == ("http://apiendpoint.com", data) - assert args["headers"] == headers - finally: - HTTP.put_with_timeout = oldPut # type: ignore + novelist_fixture.novelist.put("http://apiendpoint.com", headers, data=data) + mock_put.assert_called_once_with( + "http://apiendpoint.com", data, headers=headers, timeout=None + ) diff --git a/tests/api/test_nyt.py b/tests/api/metadata/test_nyt.py similarity index 90% rename from tests/api/test_nyt.py rename to tests/api/metadata/test_nyt.py index 8aea651c0..c24c60d85 100644 --- a/tests/api/test_nyt.py +++ b/tests/api/metadata/test_nyt.py @@ -1,11 +1,20 @@ import datetime import json +from unittest.mock import MagicMock import pytest -from api.nyt import NYTAPI, NYTBestSellerAPI, NYTBestSellerList, NYTBestSellerListTitle +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.nyt import ( + NYTAPI, + NYTBestSellerAPI, + NytBestSellerApiSettings, + NYTBestSellerList, + NYTBestSellerListTitle, +) from core.config import CannotLoadConfiguration -from core.model import Contributor, CustomListEntry, Edition, ExternalIntegration +from core.integration.goals import Goals +from core.model import Contributor, CustomListEntry, Edition from core.util.http import IntegrationException from tests.fixtures.api_nyt_files import NYTFilesFixture from tests.fixtures.database import DatabaseTransactionFixture @@ -40,6 +49,12 @@ def midnight(self, *args): """ return datetime.datetime(*args, tzinfo=NYTAPI.TIME_ZONE) + def protocol(self) -> str: + registry = MetadataRegistry() + protocol = registry.get_protocol(NYTBestSellerAPI) + assert protocol is not None + return protocol + def __init__(self, db: DatabaseTransactionFixture, files: NYTFilesFixture): self.db = db self.api = DummyNYTBestSellerAPI(db.session, files) @@ -60,28 +75,19 @@ def test_from_config(self, nyt_fixture: NYTBestSellerAPIFixture): # You have to have an ExternalIntegration for the NYT. with pytest.raises(CannotLoadConfiguration) as excinfo: NYTBestSellerAPI.from_config(nyt_fixture.db.session) - assert "No ExternalIntegration found for the NYT." in str(excinfo.value) - integration = nyt_fixture.db.external_integration( - protocol=ExternalIntegration.NYT, goal=ExternalIntegration.METADATA_GOAL - ) - - # It has to have the api key in its 'password' setting. - with pytest.raises(CannotLoadConfiguration) as excinfo: - NYTBestSellerAPI.from_config(nyt_fixture.db.session) - assert "No NYT API key is specified" in str(excinfo.value) + assert "No Integration found for the NYT." in str(excinfo.value) - integration.password = "api key" + integration = nyt_fixture.db.integration_configuration( + protocol=nyt_fixture.protocol(), goal=Goals.METADATA_GOAL + ) + settings = NytBestSellerApiSettings(password="api key") + NYTBestSellerAPI.settings_update(integration, settings) - # It's okay if you don't have a Metadata Wrangler configuration - # configured. api = NYTBestSellerAPI.from_config(nyt_fixture.db.session) assert "api key" == api.api_key - api = NYTBestSellerAPI.from_config(nyt_fixture.db.session) - - # external_integration() finds the integration used to create - # the API object. - assert integration == api.external_integration(nyt_fixture.db.session) + # integration() finds the integration used to create the API object. + assert integration == api.integration(nyt_fixture.db.session) def test_run_self_tests(self, nyt_fixture: NYTBestSellerAPIFixture): class Mock(NYTBestSellerAPI): @@ -91,9 +97,9 @@ def __init__(self): def list_of_lists(self): return "some lists" - [list_test] = Mock()._run_self_tests(object()) + [list_test] = Mock()._run_self_tests(MagicMock()) assert "Getting list of best-seller lists" == list_test.name - assert True == list_test.success + assert list_test.success is True assert "some lists" == list_test.result def test_list_of_lists(self, nyt_fixture: NYTBestSellerAPIFixture): diff --git a/tests/api/test_lanes.py b/tests/api/test_lanes.py index 7beab88a5..a7a05fca8 100644 --- a/tests/api/test_lanes.py +++ b/tests/api/test_lanes.py @@ -1,8 +1,9 @@ from collections import Counter -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, create_autospec, patch import pytest +from api.integration.registry.metadata import MetadataRegistry from api.lanes import ( ContributorFacets, ContributorLane, @@ -25,20 +26,15 @@ create_lanes_for_large_collection, create_world_languages_lane, ) -from api.novelist import MockNoveListAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from core.classifier import Classifier from core.entrypoint import AudiobooksEntryPoint from core.external_search import Filter +from core.integration.goals import Goals from core.lane import DefaultSortOrderFacets, Facets, FeaturedFacets, Lane, WorkList from core.metadata_layer import ContributorData, Metadata -from core.model import ( - Contributor, - DataSource, - Edition, - ExternalIntegration, - Library, - create, -) +from core.model import Contributor, DataSource, Edition, ExternalIntegration, Library from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.library import LibraryFixture from tests.fixtures.search import ExternalSearchFixtureFake @@ -123,11 +119,12 @@ def test_create_lanes_for_large_collection(self, db: DatabaseTransactionFixture) db.session.delete(lane) # If there's an NYT Best Sellers integration and we create the lanes again... - integration, ignore = create( - db.session, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, - protocol=ExternalIntegration.NYT, + nyt_protocol = MetadataRegistry().get_protocol(NYTBestSellerAPI) + assert nyt_protocol is not None + db.integration_configuration( + nyt_protocol, + goal=Goals.METADATA_GOAL, + password="foo", ) create_lanes_for_large_collection(db.session, db.default_library(), languages) @@ -553,18 +550,11 @@ def test_initialization(self, related_books_fixture: RelatedBooksFixture): # When NoveList is configured and recommendations are available, # a RecommendationLane will be included. - db.external_integration( - ExternalIntegration.NOVELIST, - goal=ExternalIntegration.METADATA_GOAL, - username="library", - password="sure", - libraries=[db.default_library()], - ) - mock_api = MockNoveListAPI(db.session) + mock_api = create_autospec(NoveListAPI) response = Metadata( related_books_fixture.edition.data_source, recommendations=[db.identifier()] ) - mock_api.setup_method(response) + mock_api.lookup.return_value = response result = RelatedBooksLane( db.default_library(), related_books_fixture.work, "", novelist_api=mock_api ) @@ -692,8 +682,8 @@ def generate_mock_api(self, lane_fixture: LaneFixture): source = DataSource.lookup(lane_fixture.db.session, DataSource.OVERDRIVE) metadata = Metadata(source) - mock_api = MockNoveListAPI(lane_fixture.db.session) - mock_api.setup_method(metadata) + mock_api = create_autospec(NoveListAPI) + mock_api.lookup.return_value = metadata return mock_api def test_modify_search_filter_hook(self, lane_fixture: LaneFixture): diff --git a/tests/api/test_scripts.py b/tests/api/test_scripts.py index 3d9f0367c..6a7a40fca 100644 --- a/tests/api/test_scripts.py +++ b/tests/api/test_scripts.py @@ -14,7 +14,7 @@ from alembic.util import CommandError from api.adobe_vendor_id import AuthdataUtility from api.config import Configuration -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI from core.integration.goals import Goals from core.marc import MARCExporter, MarcExporterLibrarySettings, MarcExporterSettings from core.model import ( From b9858ea5d07beff816dafbfe0684e2e875364791 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:17:41 -0800 Subject: [PATCH 22/33] Bump levenshtein from 0.23.0 to 0.24.0 (#1640) Bumps [levenshtein](https://github.com/rapidfuzz/Levenshtein) from 0.23.0 to 0.24.0. - [Release notes](https://github.com/rapidfuzz/Levenshtein/releases) - [Changelog](https://github.com/rapidfuzz/Levenshtein/blob/main/HISTORY.md) - [Commits](https://github.com/rapidfuzz/Levenshtein/compare/v0.23.0...v0.24.0) --- updated-dependencies: - dependency-name: levenshtein dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 204 ++++++++++++++++++++++--------------------------- pyproject.toml | 2 +- 2 files changed, 94 insertions(+), 112 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3ddbfebbc..abc2bf24c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1886,119 +1886,101 @@ deprecated = "*" [[package]] name = "levenshtein" -version = "0.23.0" +version = "0.24.0" description = "Python extension for computing string edit distances and similarities." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d3f2b8e67915268c49f0faa29a29a8c26811a4b46bd96dd043bc8557428065d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10b980dcc865f8fe04723e448fac4e9a32cbd21fb41ab548725a2d30d9a22429"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f8c8c48217b2733ae5bd8ef14e0ad730a30d113c84dc2cfc441435ef900732b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:854a0962d6f5852b891b6b5789467d1e72b69722df1bc0dd85cbf70efeddc83f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5abc4ee22340625ec401d6f11136afa387d377b7aa5dad475618ffce1f0d2e2f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20f79946481052bbbee5284c755aa0a5feb10a344d530e014a50cb9544745dd3"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6084fc909a218843bb55723fde64a8a58bac7e9086854c37134269b3f946aeb"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0acaae1c20c8ed37915b0cde14b5c77d5a3ba08e05f9ce4f55e16843de9c7bb8"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54a51036b02222912a029a6efa2ce1ee2be49c88e0bb32995e0999feba183913"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:68ec2ef442621027f290cb5cef80962889d86fff3e405e5d21c7f9634d096bbf"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d8ba18720bafa4a65f07baba8c3228e98a6f8da7455de4ec58ae06de4ecdaea0"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:af1b70cac87c5627cd2227823318fa39c64fbfed686c8c3c2f713f72bc25813b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe2810c42cc5bca15eeb4a2eb192b1f74ceef6005876b1a166ecbde1defbd22d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win32.whl", hash = "sha256:89a0829637221ff0fd6ce63dfbe59e22b25eeba914d50e191519b9d9b8ccf3e9"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:b8bc81d59205558326ac75c97e236fd72b8bcdf63fcdbfb7387bd63da242b209"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:151046d1c70bdf01ede01f46467c11151ceb9c86fefaf400978b990110d0a55e"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e992de09832ee11b35910c05c1581e8a9ab8ea9737c2f582c7eb540e2cdde69"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5e3461d29b3188518464bd3121fc64635ff884ae544147b5d326ce13c50d36"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1772c4491f6ef6504e591c0dd60e1e418b2015074c3d56ee93af6b1a019906ee"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e125c92cd0ac3b53c4c80fcf2890d89a1d19ff4979dc804031773bc90223859f"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d2f608c5ce7b9a0a0af3c910f43ea7eb060296655aa127b10e4af7be5559303"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe5c3b7d96a838d9d86bb4ec57495749965e598a3ea2c5b877a61aa09478bab7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249eaa351b5355b3e3ca7e3a8e2a0bca7bff4491c89a0b0fa3b9d0614cf3efeb"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0033a243510e829ead1ae62720389c9f17d422a98c0525da593d239a9ff434e5"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f956ad16cab9267c0e7d382a37b4baca6bf3bf1637a76fa95fdbf9dd3ea774d7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3789e4aeaeb830d944e1f502f9aa9024e9cd36b68d6eba6892df7972b884abd7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f91335f056b9a548070cb87b3e6cf017a18b27d34a83f222bdf46a5360615f11"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3497eda857e70863a090673a82442877914c57b5f04673c782642e69caf25c0c"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e17ea59115179c269c6daea52415faaf54c6340d4ad91d9012750845a445a13"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win32.whl", hash = "sha256:da2063cee1fbecc09e1692e7c4de7624fd4c47a54ee7588b7ea20540f8f8d779"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d3b9c9e2852eca20de6bd8ca7f47d817a056993fd4927a4d50728b62315376b"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:ef2e3e93ae612ac87c3a28f08e8544b707d67e99f9624e420762a7c275bb13c5"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85220b27a47df4a5106ef13d43b6181d73da77d3f78646ec7251a0c5eb08ac40"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bb77b3ade7f256ca5882450aaf129be79b11e074505b56c5997af5058a8f834"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b487f08c32530ee608e8aab0c4075048262a7f5a6e113bac495b05154ae427"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f91d0a5d3696e373cae08c80ec99a4ff041e562e55648ebe582725cba555190"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fddda71ae372cd835ffd64990f0d0b160409e881bf8722b6c5dc15dc4239d7db"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7664bcf9a12e62c672a926c4579f74689507beaa24378ad7664f0603b0dafd20"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6d07539502610ee8d6437a77840feedefa47044ab0f35cd3bc37adfc63753bd"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:830a74b6a045a13e1b1d28af62af9878aeae8e7386f14888c84084d577b92771"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f29cbd0c172a8fc1d51eaacd163bdc11596aded5a90db617e6b778c2258c7006"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:df0704fd6a30a7c27c03655ae6dc77345c1655634fe59654e74bb06a3c7c1357"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:0ab52358f54ee48ad7656a773a0c72ef89bb9ba5acc6b380cfffd619fb223a23"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f0a86394c9440e23a29f48f2bbc460de7b19950f46ec2bea3be8c2090839bb29"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a689e6e0514f48a434e7ee44cc1eb29c34b21c51c57accb304eac97fba87bf48"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win32.whl", hash = "sha256:2d3229c1336498c2b72842dd4c850dff1040588a5468abe5104444a372c1a573"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b9b6a8509415bc214d33f5828d7c700c80292ea25f9d9e8cba95ad5a74b3cdf"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:5a61606bad3afb9fcec0a2a21871319c3f7da933658d2e0e6e55ab4a34814f48"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:078bb87ea32a28825900f5d29ba2946dc9cf73094dfed4ba5d70f042f2435609"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26b468455f29fb255b62c22522026985cb3181a02e570c8b37659fedb1bc0170"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc62b2f74e4050f0a1261a34e11fd9e7c6d80a45679c0e02ac452b16fda7b34"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b65b0b4e8b88e8326cdbfd3ec119953a0b10b514947f4bd03a4ed0fc58f6471"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bccaf7f16b9da5edb608705edc3c38401e83ea0ff04c6375f25c6fc15e88f9b3"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b35f752d04c0828fb1877d9bee5d1786b2574ec3b1cba0533008aa1ff203712"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2c32f86bb54b9744c95c27b5398f108158cc6a87c5dbb3ad5a344634bf9b07d3"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fa8b65f483cdd3114d41736e0e9c3841e7ee6ac5861bae3d26e21e19faa229ff"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:9fdf67c10a5403b1668d1b6ade7744d20790367b10866d27394e64716992c3e4"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:eb6dfba3264b38a3e95cac8e64f318ad4c27e2232f6c566a69b3b113115c06ef"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8541f1b7516290f6ccc3faac9aea681183c5d0b1f8078b957ae41dfbd5b93b58"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-win32.whl", hash = "sha256:f35b138bb698b29467627318af9258ec677e021e0816ae0da9b84f9164ed7518"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-win_amd64.whl", hash = "sha256:936320113eadd3d71d9ce371d9027b1c56299001b48ed197a0db4140e1d13bbd"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:da64e19e1ec0c1e8a1cd77c4802a0d656f8a6e0ab7a1479d435a9d2575e473f8"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e729781b6134a6e3b380a2d8eae0843a230fc3716bdc8bba4cde2b0ce260982b"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:97d0841a2682a3c302f70537e8316077e56795062c6f629714f5d0771f7a5838"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727a679d19b18a0b4532abf87f9788070bcd94b78ff07135abe41c716bccbb7d"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48c8388a321e55c1feeef543b49fc969be6a5cf6bcf4dcb5dced82f5fea6793c"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58f8b8f5d4348e470e8c0d4e9f7c23a8f7cfc3cbd8024cc5a1fc68cc81f7d6cb"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:549170257f052289df93a13526877cb397d351b0c8a3e4c9ae3936aeafd8ad17"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d32f3b28065e430d54781e1f3b31198b6bfc21e6d565f0c06218e7618884551"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ecc8c12e710212c4d959fda3a52377ae6a30fa204822f2e63fd430e018be3d6f"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:88b47fbabbd9cee8be5d6c26ac4d599dd66146628b9ca23d9f4f209c4e3e143e"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:5106bce4e94bc1ae137b50d1e5f49b726997be879baf66eafc6ee365adec3db5"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d36634491e06234672492715bc6ff7be61aeaf44822cb366dbbe9d924f2614cc"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a591c94f7047d105c29630e7606a2b007f96cf98651fb93e9f820272b0361e02"}, - {file = "Levenshtein-0.23.0-cp38-cp38-win32.whl", hash = "sha256:9fce199af18d459c8f19747501d1e852d86550162e7ccdc2c193b44e55d9bbfb"}, - {file = "Levenshtein-0.23.0-cp38-cp38-win_amd64.whl", hash = "sha256:b4303024ffea56fd164a68f80f23df9e9158620593b7515c73c885285ec6a558"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:73aed4856e672ab12769472cf7aece04b4a6813eb917390d22e58002576136e0"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4e93dbfdf08360b4261a2385340d26ac491a1bf9bd17bf22a59636705d2d6479"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b847f716fc314cf83d128fedc2c16ffdff5431a439db412465c4b0ac1762478e"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0d567beb47cd403394bf241df8cfc14499279d0f3a6675f89b667249841aab1"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e13857d870048ff58ce95c8eb32e10285918ee74e1c9bf1825af08dd49b0bc6"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4250f507bb1b7501f7187af8345e200cbc1a58ceb3730bf4e3fdc371fe732c0"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fb90de8a279ce83797bcafbbfe6d641362c3c96148c17d8c8612dddb02744c5"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039dc7323fd28de44d6c13a334a34ab1ddee598762cb2dae3223ca1f083577f9"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d5739f513cb02039f970054eabeccc62696ed2a1afff6e17f75d5492a3ed8d74"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2a3801a0463791440b4350b734e4ec0dbc140b675a3ce9ef936feed06b23c58d"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:606ba30bbdf06fc51b0a763760e113dea9085011a2399cf4b1f72316836e4d03"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:14c5f90859e512004cc25b50b79c7ae6f068ebe69a7213a9018c83bd88c1305b"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c8a75233798e334fd53305656ffcf0601f60e9ff461af759677006c07c060939"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win32.whl", hash = "sha256:9a271d50643cf927bfc002d397b4f715abdbc6ca46a5a93d1d66a033eabaa5f3"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:684118d9e070e00df91bc4bd276e0559df7bb2319659699dafda16b5a0229553"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win_arm64.whl", hash = "sha256:98412a7bdc49c7fbb493be3c3e7fd2f874eff29ed636b8c0eca325a1e3e74264"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:760c964ff0be8dea5f7eda20314cf66238fdd0fec63f1ce9c474736bb2904924"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de42400ea86e3e8be3dc7f9b3b9ed51da7fd06dc2f3a426d7effd7fbf35de848"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2080ee52aeac03854a0c6e73d4214d5be2120bdd5f16def4394f9fbc5666e04"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb00ecae116e62801613788d8dc3938df26f582efce5a3d3320e9692575e7c4d"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f351694f65d4df48ee2578d977d37a0560bd3e8535e85dfe59df6abeed12bd6e"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34859c5ff7261f25daea810b5439ad80624cbb9021381df2c390c20eb75b79c6"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ece1d077d9006cff329bb95eb9704f407933ff4484e5d008a384d268b993439"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35ce82403730dd2a3b397abb2535786af06835fcf3dc40dc8ea67ed589bbd010"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a88aa3b5f49aeca08080b6c3fa7e1095d939eafb13f42dbe8f1b27ff405fd43"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:748fbba6d9c04fc39b956b44ccde8eb14f34e21ab68a0f9965aae3fa5c8fdb5e"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:60440d583986e344119a15cea9e12099f3a07bdddc1c98ec2dda69e96429fb25"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b048a83b07fc869648460f2af1255e265326d75965157a165dde2d9ba64fa73"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4be0e5e742f6a299acf7aa8d2e5cfca946bcff224383fd451d894e79499f0a46"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7a626637c1d967e3e504ced353f89c2a9f6c8b4b4dbf348fdd3e1daa947a23c"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:88d8a13cf310cfc893e3734f8e7e42ef20c52780506e9bdb96e76a8b75e3ba20"}, - {file = "Levenshtein-0.23.0.tar.gz", hash = "sha256:de7ccc31a471ea5bfafabe804c12a63e18b4511afc1014f23c3cc7be8c70d3bd"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5bff001ba5edfcf632560e0843d9f1ccab874a8776c30025bbdad4345891b4c9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:55e4676a5b6cde331ce3483cd7862dcd58f4fb3c4d2eded1934b00c176320324"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f7fb893187ef35d911490d6b9dbf7b0ed21b4c1f31468a2f1d7980b37f182eb"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a475a61eed02e8bd07f779d3246f987a4330d4ad16d117e08dff2dba091984d9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:042ad920544dbed0b29950b52bd24170aa8c4a01f5046f485e530a483e9af1c0"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e856b22f570991f2e9f3c82ab349aeb90843876636b13edcf770b946f56fbfe"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1fbf7113aa71483053cd927741aa89957c8d1cce1757f00dfcb9c609c9317b8"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81d542ebb10a2e83608c3cb57500f348f0f300cf9bd64b108eb34029b18351d9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:951b07179ba6cdd903e0f63027661034a29a568197d59473da9f68d4d97761d8"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d44e983a5c4120da921d892050e32e9c4ff6fc60d206ce44d1bcc65cbff51f2f"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c440a27ee1bfe63ffd7caa228b75ffac82ba2338473197a8826026e979b9281b"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dccc42d90924b1488a69b9bdd3250c6f977e4cca9cff664fb8734956a01cca34"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf3152ce6923f5827bf4b24ab9be8bb2a8576a0b656b1aca47ed262e308c4dd3"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win32.whl", hash = "sha256:18431eef98194254dda8e8e91f9abdea4f9ceeeda729aff2ba7ab9980dc3d984"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:d9feef328de5f39b68795bbe7d5b9fc92b95d34a3720614c1684a1b991a0aa4f"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win_arm64.whl", hash = "sha256:2c12be1e026496d7a261edc2fff2ddc05b36484cf00b9ae0f6b1f4b33d2d1775"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7614d077c9c0e9eeadc6923f589e8ce4c50d5277012d35928c747f5b35b9d535"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:23b6712bbcc844f922b148463485f1de34f3c69bcd1e7df664c477718526b933"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fcb5f941cc94b339cfbf1edd6a077c214cf30f93920552b44a4515b7d2b5b40"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f611755384ec658cc4dd8ebe2d1fd8def57d9ea89685f31341ca928b9eac674"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c003e5e8140bf31cc487283ed3c7edd7444aceedf9980388661bc594f87d1244"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:44fd4b3f88af58edcfba35b12f189c2e9380f3a48fecc8707c1511ac7acf8757"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fcf49de3ea0a1fc53f58878b6330f14779f1da2c5a69eb5e1f8d0b18a2f0bbb"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:598b4dfb7b95914fb8389acfc97b94373ba4a2d1756d2f9711e9d9113eeaa436"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0da21e303bda4887c5e4d9b0b24424f50d963ffdab79c456e1b47e8f0cd6141e"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0134c7c0942d9d89923f5b20e437a4dc00ff448e47100d3da254518bea348fc9"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:58e14117c86b7c33315e448610ab70ba34f41476b6784141aeabff5cf90a5736"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:8982669a679bee243dc813551254b31ecc148ee230757c9e6179f85f3e4f3cf2"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0727e03527a952ead76727304d8ec673636a25ce0d867d81bf04c652f4a928e2"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win32.whl", hash = "sha256:80aa2c01ac5207ff005d3ea4ab5d65ca7e00d9b9a97893ca10fa6546317893a9"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:4ba8b9c3fd5083a7d8a5b28423a083a116697e53eac23f2b85804353f9bbaaee"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win_arm64.whl", hash = "sha256:0d8f275016b606ef85f7b5ac00b3f82779ef894e760a5da4c57867dab7affb73"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:98bd3aa83551d22c18b101bf820bd72f6ee3f71be6ae4ac4eeb1d232d2f05a87"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ca03ea4609a0ec3a06a57dff7e4311c690f7b8281af6062c23ebd79dfa8961a3"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a799395d22267ebbc591407eed3e3cb62d9f24cba33e3d3dfec28969d14865c5"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fb6e42f8c411b052cbfa745684b7beffc27c2a16d8be46d4beee151c657898"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13ed45e77b2f61eb081a3130cb758589fa53f56a3c76a14bf5230eb7ade9ee61"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6b7a01bc434ce87e2785da51d8c568801afef5112215b5d6bb6e0c589cacb04"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f1ba9a84dae8a4d36dfc3fc81eaee41cf5215fb1716f22c1a6b0c6878d2fa4"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddc2ccb91a35271b625bf05fc8cb350cc531df00d1f7e3b79a752eaf142b3425"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2af8eca734dc067ad8806d73f8b4596eb5c8642953b6e83e9fcc18f85d9d664c"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5ef80249ace0c9c927db0057712ea6725b79576c747e282d59bd190c446eb19c"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:214504d8bae60f9cd654362ed59b19c5c1f569e2cd0dbf38494c0ea2e5576d11"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:3eb5ccd0b0f6453ca0d775c36eb687a3ff7d4239fa37f7d5384ca1805b7c278f"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de13a0c8aa49cbb436e9ddb65c96f79814da89250f7994494753d5122cb85e97"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win32.whl", hash = "sha256:c91130765011e880e94430f5cd72ca855f429a5e42a0813718a4b2145ab8da26"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:c3fb6647a4f8430088619825628f594348a14e7b4be1cf4de3e72494c1104552"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win_arm64.whl", hash = "sha256:c66bfad51e71d4df8dbafe56bd8f3e49554c31cdd86839e735ba02d943b764f7"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:25bb9ecfe1b9cdfbf0fda650a3cf0a4137a070164f8a1b983b1baecf97da6d93"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:770aeae22c181402e9bc9e10c8fee24d70cc4ec7f38cd1f2c5aedacddee157a0"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e713a10982c944c4a92a62cbedcd65058511fd33104385adff863c9eaac3882e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a4d514b8b30d7cc1e8b15e81b9695d5f30f39952f257e1e8f24b838e6b102a"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec9aa857b96f1b0e18af641b56329932e6bb04bf2ce99bc8c8f3b2820b7b704b"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40dcf41927a48aeba1c879ab9794113b214f9275b6372d0bb10ee2f07c94fb68"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc66309c19ecd12dace6170746c449e51d266ef3243673d21f221d8e464cb683"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0833836a5741bfceb67c710bd6368b5a5315da9a8950bda5ffbd8ded1aace56e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aeae28552a6aedaf6cd7c4111a15b22f3246bf885fb918f420995cabe21058cc"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f32f8e77a1ce17b0cb502bdbf7756ff204124d0b37ad091bcaa117e0926c5c82"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:37ce96068ce24423ddb869adb2a6562630652868698932fe2e0e02ccc5d8f56e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:780644075108e349f58cfd3ecbb917598186f08ca366f106f05f8a8d2822454a"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3ef431af9bcb4fd79bbf44f9b16b755ea74ecfcc5ec5727904c4544069b9cf82"}, + {file = "Levenshtein-0.24.0-cp38-cp38-win32.whl", hash = "sha256:a2cb7b1cbbab0810f80eda12897e5c5a2cffc07f279e115dcf293863311d72b2"}, + {file = "Levenshtein-0.24.0-cp38-cp38-win_amd64.whl", hash = "sha256:6b1a704559893efd217d1c59c20d980b97551480cf646961362f5a07206eb80a"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a564b93fb845b1b1caf03628357300d3b228be17f5bd8f338d470a91b2963989"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8de6fe37c9be5da21f13345a0c57f3ebdf8b6d9f3d8e8e326541b1b2566f6421"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:559dca81c5717d4ac1ffdc6959d007cf2914194f27afdaa5907ded6680720537"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf164f896e6ee9b6dd03a0173a299cda8137dbd625baeb441a1b00197e71eb4"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd815f679c05225e667148cd702632552d56d55cc6c215ca8f1147e4c5f05f98"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c8dc9346d016638ecb565fa00bf9227d14047c60acc38d9aba8f17b49c44809"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e7765f2793af58dfc34949e21378c181374bb7e95787bf8dd39da14e37bc18f"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a3eaedf4b0f5ae0c9d798bb1debea27ee555733048610ce83dd89511d469a52"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ce6a3cb5e50c2028fa91a5ab3418b194cb8596cedd859d1bab5b8ca8ea56ab19"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f319af67483978f8536eaa3abbefe540d1d1e59729fcb882a3126a311480f0cd"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:aaf7fedae112417b3015ad237c30fbbb7787cbc4dd53c886c6118439b1ae1314"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:57df7e6b5f719bc53be27f1b932437608e25f2fbc75aa6e14e9696abf023b3b2"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c85a3c172cf49935c41c9132e511836a489febc3a557ad80ed2c1ebcc6f3ab0e"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win32.whl", hash = "sha256:a93ae01e1c2dc1f5ca42e4597a25fc4544afb2a580c61f13d4716314d7ff3480"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:c228b8e3be9913deab16bf4f17a07b99034df58a3b0e161ad04995694b7dfda2"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win_arm64.whl", hash = "sha256:75aa294bfd4f43373c76e792296eb45a2ca6477937196a03779b620d3276673f"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:15c4a6dc9c25cfb02ae96031702ce88ff25ed9e3dd7357bb3ac89c13f4faa50d"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d3f811cac76099f1f329ec10ac6b8b5402a1e202393f561c47c33c4c610b4f1"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678aca306e1811e4cf57e6b8dd2041785f087e459d2dd9ec1a68e691fe595cb5"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3e4c2f6fe12f33f895ade893151a3b1356bfbb41499a5249063ab73b59296f0"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8cd4647eff7ffc43a46fea9fecbe312980bf4bf2fd73bc11f0f1f4567f17143f"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5d0b8d44a9666ac28badecf938bb069b4ebd2a4c0e7fcfd80b56c856bb9251c7"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac823793cbd850984740c7e2d553ebd2684f4404045a4f87af23c48dd62dc3ab"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f81cb7582b6bc263ba29a5960bfac82a8967ae94923e23f496db500a0da28fe2"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf0d19ae6f76e1048a44298b394f897910723ae732eb8c98c705a6eba02138e"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:12c915d89ba3ade46877c351ea48c6eec1f3f53af1fe2d57b8715e18e686940e"}, + {file = "Levenshtein-0.24.0.tar.gz", hash = "sha256:0cbcf3c9a7c77de3a405bfc857ab94341b4049e8c5c6b917f5ffcd5a92ff169a"}, ] [package.dependencies] @@ -4500,4 +4482,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "6ee1fee2f1e8df6c286e1c02eed6b54cc93da373bb5459e98944e519dc166cf7" +content-hash = "8c6ad1cedd4a1af38974f1bacace481a495dd5a5a3ad711a52b7aa83d2370384" diff --git a/pyproject.toml b/pyproject.toml index 8424ff89e..59a43f588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,7 +206,7 @@ html-sanitizer = "^2.1.0" isbnlib = "^3.10.14" itsdangerous = "^2.1.2" jwcrypto = "^1.4.2" -levenshtein = "^0.23" +levenshtein = "^0.24" lxml = "^4.9.3" money = "1.3.0" multipledispatch = "^1.0" From e30bca7d0249aa3e32d9d49dae441ec9e65bf989 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:18:07 -0800 Subject: [PATCH 23/33] Bump pytz from 2023.3.post1 to 2023.4 (#1639) Bumps [pytz](https://github.com/stub42/pytz) from 2023.3.post1 to 2023.4. - [Commits](https://github.com/stub42/pytz/compare/release_2023.3.post1...release_2023.4) --- updated-dependencies: - dependency-name: pytz dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index abc2bf24c..5902849f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3335,13 +3335,13 @@ test = ["coverage (>=4.5.2)", "flake8 (>=3.6.0,<=5.0.0)", "freezegun (>=0.3.11,< [[package]] name = "pytz" -version = "2023.3.post1" +version = "2023.4" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2023.4-py2.py3-none-any.whl", hash = "sha256:f90ef520d95e7c46951105338d918664ebfd6f1d995bd7d153127ce90efafa6a"}, + {file = "pytz-2023.4.tar.gz", hash = "sha256:31d4583c4ed539cd037956140d695e42c033a19e984bfce9964a3f7d59bc2b40"}, ] [[package]] From 082bc77943209ecd9d1400cb52eadc7167c5144e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:20:09 -0800 Subject: [PATCH 24/33] Bump pyinstrument from 4.6.1 to 4.6.2 (#1638) Bumps [pyinstrument](https://github.com/joerick/pyinstrument) from 4.6.1 to 4.6.2. - [Release notes](https://github.com/joerick/pyinstrument/releases) - [Commits](https://github.com/joerick/pyinstrument/compare/v4.6.1...v4.6.2) --- updated-dependencies: - dependency-name: pyinstrument dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 122 ++++++++++++++++++++++++++-------------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5902849f8..0516eec97 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2972,71 +2972,71 @@ files = [ [[package]] name = "pyinstrument" -version = "4.6.1" +version = "4.6.2" description = "Call stack profiler for Python. Shows you why your code is slow!" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73476e4bc6e467ac1b2c3c0dd1f0b71c9061d4de14626676adfdfbb14aa342b4"}, - {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4d1da8efd974cf9df52ee03edaee2d3875105ddd00de35aa542760f7c612bdf7"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507be1ee2f2b0c9fba74d622a272640dd6d1b0c9ec3388b2cdeb97ad1e77125f"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cee6de08eb45754ef4f602ce52b640d1c535d934a6a8733a974daa095def37"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7873e8cec92321251fdf894a72b3c78f4c5c20afdd1fef0baf9042ec843bb04"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a242f6cac40bc83e1f3002b6b53681846dfba007f366971db0bf21e02dbb1903"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:97c9660cdb4bd2a43cf4f3ab52cffd22f3ac9a748d913b750178fb34e5e39e64"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e304cd0723e2b18ada5e63c187abf6d777949454c734f5974d64a0865859f0f4"}, - {file = "pyinstrument-4.6.1-cp310-cp310-win32.whl", hash = "sha256:cee21a2d78187dd8a80f72f5d0f1ddb767b2d9800f8bb4d94b6d11f217c22cdb"}, - {file = "pyinstrument-4.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:2000712f71d693fed2f8a1c1638d37b7919124f367b37976d07128d49f1445eb"}, - {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a366c6f3dfb11f1739bdc1dee75a01c1563ad0bf4047071e5e77598087df457f"}, - {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6be327be65d934796558aa9cb0f75ce62ebd207d49ad1854610c97b0579ad47"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e160d9c5d20d3e4ef82269e4e8b246ff09bdf37af5fb8cb8ccca97936d95ad6"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffbf56605ef21c2fcb60de2fa74ff81f417d8be0c5002a407e414d6ef6dee43"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c92cc4924596d6e8f30a16182bbe90893b1572d847ae12652f72b34a9a17c24a"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f4b48a94d938cae981f6948d9ec603bab2087b178d2095d042d5a48aabaecaab"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7a386392275bdef4a1849712dc5b74f0023483fca14ef93d0ca27d453548982"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:871b131b83e9b1122f2325061c68ed1e861eebcb568c934d2fb193652f077f77"}, - {file = "pyinstrument-4.6.1-cp311-cp311-win32.whl", hash = "sha256:8d8515156dd91f5652d13b5fcc87e634f8fe1c07b68d1d0840348cdd50bf5ace"}, - {file = "pyinstrument-4.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb868fbe089036e9f32525a249f4c78b8dc46967612393f204b8234f439c9cc4"}, - {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a18cd234cce4f230f1733807f17a134e64a1f1acabf74a14d27f583cf2b183df"}, - {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:574cfca69150be4ce4461fb224712fbc0722a49b0dc02fa204d02807adf6b5a0"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e02cf505e932eb8ccf561b7527550a67ec14fcae1fe0e25319b09c9c166e914"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832fb2acef9d53701c1ab546564c45fb70a8770c816374f8dd11420d399103c9"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13cb57e9607545623ebe462345b3d0c4caee0125d2d02267043ece8aca8f4ea0"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9be89e7419bcfe8dd6abb0d959d6d9c439c613a4a873514c43d16b48dae697c9"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:476785cfbc44e8e1b1ad447398aa3deae81a8df4d37eb2d8bbb0c404eff979cd"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e9cebd90128a3d2fee36d3ccb665c1b9dce75261061b2046203e45c4a8012d54"}, - {file = "pyinstrument-4.6.1-cp312-cp312-win32.whl", hash = "sha256:1d0b76683df2ad5c40eff73607dc5c13828c92fbca36aff1ddf869a3c5a55fa6"}, - {file = "pyinstrument-4.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:c4b7af1d9d6a523cfbfedebcb69202242d5bd0cb89c4e094cc73d5d6e38279bd"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79ae152f8c6a680a188fb3be5e0f360ac05db5bbf410169a6c40851dfaebcce9"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07cad2745964c174c65aa75f1bf68a4394d1b4d28f33894837cfd315d1e836f0"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb81f66f7f94045d723069cf317453d42375de9ff3c69089cf6466b078ac1db4"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab30ae75969da99e9a529e21ff497c18fdf958e822753db4ae7ed1e67094040"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f36cb5b644762fb3c86289324bbef17e95f91cd710603ac19444a47f638e8e96"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8b45075d9dbbc977dbc7007fb22bb0054c6990fbe91bf48dd80c0b96c6307ba7"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:475ac31477f6302e092463896d6a2055f3e6abcd293bad16ff94fc9185308a88"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-win32.whl", hash = "sha256:29172ab3d8609fdf821c3f2562dc61e14f1a8ff5306607c32ca743582d3a760e"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:bd176f297c99035127b264369d2bb97a65255f65f8d4e843836baf55ebb3cee4"}, - {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:23e9b4526978432e9999021da9a545992cf2ac3df5ee82db7beb6908fc4c978c"}, - {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2dbcaccc9f456ef95557ec501caeb292119c24446d768cb4fb43578b0f3d572c"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2097f63c66c2bc9678c826b9ff0c25acde3ed455590d9dcac21220673fe74fbf"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:205ac2e76bd65d61b9611a9ce03d5f6393e34ec5b41dd38808f25d54e6b3e067"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f414ddf1161976a40fc0a333000e6a4ad612719eac0b8c9bb73f47153187148"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65e62ebfa2cd8fb57eda90006f4505ac4c70da00fc2f05b6d8337d776ea76d41"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d96309df4df10be7b4885797c5f69bb3a89414680ebaec0722d8156fde5268c3"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f3d1ad3bc8ebb4db925afa706aa865c4bfb40d52509f143491ac0df2440ee5d2"}, - {file = "pyinstrument-4.6.1-cp38-cp38-win32.whl", hash = "sha256:dc37cb988c8854eb42bda2e438aaf553536566657d157c4473cc8aad5692a779"}, - {file = "pyinstrument-4.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:2cd4ce750c34a0318fc2d6c727cc255e9658d12a5cf3f2d0473f1c27157bdaeb"}, - {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ca95b21f022e995e062b371d1f42d901452bcbedd2c02f036de677119503355"}, - {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ac1e1d7e1f1b64054c4eb04eb4869a7a5eef2261440e73943cc1b1bc3c828c18"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0711845e953fce6ab781221aacffa2a66dbc3289f8343e5babd7b2ea34da6c90"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7d28582017de35cb64eb4e4fa603e753095108ca03745f5d17295970ee631f"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be57db08bd366a37db3aa3a6187941ee21196e8b14975db337ddc7d1490649d"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9a0ac0f56860398d2628ce389826ce83fb3a557d0c9a2351e8a2eac6eb869983"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a9045186ff13bc826fef16be53736a85029aae3c6adfe52e666cad00d7ca623b"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6c4c56b6eab9004e92ad8a48bb54913fdd71fc8a748ae42a27b9e26041646f8b"}, - {file = "pyinstrument-4.6.1-cp39-cp39-win32.whl", hash = "sha256:37e989c44b51839d0c97466fa2b623638b9470d56d79e329f359f0e8fa6d83db"}, - {file = "pyinstrument-4.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:5494c5a84fee4309d7d973366ca6b8b9f8ba1d6b254e93b7c506264ef74f2cef"}, - {file = "pyinstrument-4.6.1.tar.gz", hash = "sha256:f4731b27121350f5a983d358d2272fe3df2f538aed058f57217eef7801a89288"}, + {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7a1b1cd768ea7ea9ab6f5490f7e74431321bcc463e9441dbc2f769617252d9e2"}, + {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a386b9d09d167451fb2111eaf86aabf6e094fed42c15f62ec51d6980bce7d96"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23c3e3ca8553b9aac09bd978c73d21b9032c707ac6d803bae6a20ecc048df4a8"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f329f5534ca069420246f5ce57270d975229bcb92a3a3fd6b2ca086527d9764"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dcdcc7ba224a0c5edfbd00b0f530f5aed2b26da5aaa2f9af5519d4aa8c7e41"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73db0c2c99119c65b075feee76e903b4ed82e59440fe8b5724acf5c7cb24721f"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:da58f265326f3cf3975366ccb8b39014f1e69ff8327958a089858d71c633d654"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:feebcf860f955401df30d029ec8de7a0c5515d24ea809736430fd1219686fe14"}, + {file = "pyinstrument-4.6.2-cp310-cp310-win32.whl", hash = "sha256:b2b66ff0b16c8ecf1ec22de001cfff46872b2c163c62429055105564eef50b2e"}, + {file = "pyinstrument-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:8d104b7a7899d5fa4c5bf1ceb0c1a070615a72c5dc17bc321b612467ad5c5d88"}, + {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:62f6014d2b928b181a52483e7c7b82f2c27e22c577417d1681153e5518f03317"}, + {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5c8d763c5df55131670ba2a01a8aebd0d490a789904a55eb6a8b8d497f110"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ed4e8c6c84e0e6429ba7008a66e435ede2d8cb027794c20923c55669d9c5633"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c0f0e1d8f8c70faa90ff57f78ac0dda774b52ea0bfb2d9f0f41ce6f3e7c869e"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3c44cb037ad0d6e9d9a48c14d856254ada641fbd0ae9de40da045fc2226a2a"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:be9901f17ac2f527c352f2fdca3d717c1d7f2ce8a70bad5a490fc8cc5d2a6007"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a9791bf8916c1cf439c202fded32de93354b0f57328f303d71950b0027c7811"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d6162615e783c59e36f2d7caf903a7e3ecb6b32d4a4ae8907f2760b2ef395bf6"}, + {file = "pyinstrument-4.6.2-cp311-cp311-win32.whl", hash = "sha256:28af084aa84bbfd3620ebe71d5f9a0deca4451267f363738ca824f733de55056"}, + {file = "pyinstrument-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:dd6007d3c2e318e09e582435dd8d111cccf30d342af66886b783208813caf3d7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e3813c8ecfab9d7d855c5f0f71f11793cf1507f40401aa33575c7fd613577c23"}, + {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6c761372945e60fc1396b7a49f30592e8474e70a558f1a87346d27c8c4ce50f7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fba3244e94c117bf4d9b30b8852bbdcd510e7329fdd5c7c8b3799e00a9215a8"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:803ac64e526473d64283f504df3b0d5c2c203ea9603cab428641538ffdc753a7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2e554b1bb0df78f5ce8a92df75b664912ca93aa94208386102af454ec31b647"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7c671057fad22ee3ded897a6a361204ea2538e44c1233cad0e8e30f6d27f33db"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d02f31fa13a9e8dc702a113878419deba859563a32474c9f68e04619d43d6f01"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b55983a884f083f93f0fc6d12ff8df0acd1e2fb0580d2f4c7bfe6def33a84b58"}, + {file = "pyinstrument-4.6.2-cp312-cp312-win32.whl", hash = "sha256:fdc0a53b27e5d8e47147489c7dab596ddd1756b1e053217ef5bc6718567099ff"}, + {file = "pyinstrument-4.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:dd5c53a0159126b5ce7cbc4994433c9c671e057c85297ff32645166a06ad2c50"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b082df0bbf71251a7f4880a12ed28421dba84ea7110bb376e0533067a4eaff40"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90350533396071cb2543affe01e40bf534c35cb0d4b8fa9fdb0f052f9ca2cfe3"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67268bb0d579330cff40fd1c90b8510363ca1a0e7204225840614068658dab77"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20e15b4e1d29ba0b7fc81aac50351e0dc0d7e911e93771ebc3f408e864a2c93b"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2e625fc6ffcd4fd420493edd8276179c3f784df207bef4c2192725c1b310534c"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:113d2fc534c9ca7b6b5661d6ada05515bf318f6eb34e8d05860fe49eb7cfe17e"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3098cd72b71a322a72dafeb4ba5c566465e193d2030adad4c09566bd2f89bf4f"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:08fdc7f88c989316fa47805234c37a40fafe7b614afd8ae863f0afa9d1707b37"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5ebeba952c0056dcc9b9355328c78c4b5c2a33b4b4276a9157a3ab589f3d1bac"}, + {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:34e59e91c88ec9ad5630c0964eca823949005e97736bfa838beb4789e94912a2"}, + {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cd0320c39e99e3c0a3129d1ed010ac41e5a7eb96fb79900d270080a97962e995"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46992e855d630575ec635eeca0068a8ddf423d4fd32ea0875a94e9f8688f0b95"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e474c56da636253dfdca7cd1998b240d6b39f7ed34777362db69224fcf053b1"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b559322f30509ad8f082561792352d0805b3edfa508e492a36041fdc009259"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:06a8578b2943eb1dbbf281e1e59e44246acfefd79e1b06d4950f01b693de12af"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7bd3da31c46f1c1cb7ae89031725f6a1d1015c2041d9c753fe23980f5f9fd86c"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e63f4916001aa9c625976a50779282e0a5b5e9b17c52a50ef4c651e468ed5b88"}, + {file = "pyinstrument-4.6.2-cp38-cp38-win32.whl", hash = "sha256:32ec8db6896b94af790a530e1e0edad4d0f941a0ab8dd9073e5993e7ea46af7d"}, + {file = "pyinstrument-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:a59fc4f7db738a094823afe6422509fa5816a7bf74e768ce5a7a2ddd91af40ac"}, + {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3a165e0d2deb212d4cf439383982a831682009e1b08733c568cac88c89784e62"}, + {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ba858b3d6f6e5597c641edcc0e7e464f85aba86d71bc3b3592cb89897bf43f6"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd8e547cf3df5f0ec6e4dffbe2e857f6b28eda51b71c3c0b5a2fc0646527835"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de2c1714a37a820033b19cf134ead43299a02662f1379140974a9ab733c5f3a"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01fc45dedceec3df81668d702bca6d400d956c8b8494abc206638c167c78dfd9"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5b6e161ef268d43ee6bbfae7fd2cdd0a52c099ddd21001c126ca1805dc906539"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6ba8e368d0421f15ba6366dfd60ec131c1b46505d021477e0f865d26cf35a605"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edca46f04a573ac2fb11a84b937844e6a109f38f80f4b422222fb5be8ecad8cb"}, + {file = "pyinstrument-4.6.2-cp39-cp39-win32.whl", hash = "sha256:baf375953b02fe94d00e716f060e60211ede73f49512b96687335f7071adb153"}, + {file = "pyinstrument-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:af1a953bce9fd530040895d01ff3de485e25e1576dccb014f76ba9131376fcad"}, + {file = "pyinstrument-4.6.2.tar.gz", hash = "sha256:0002ee517ed8502bbda6eb2bb1ba8f95a55492fcdf03811ba13d4806e50dd7f6"}, ] [package.extras] From 178f69cb0c21d2a449f7405610d73835f2fee686 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 29 Jan 2024 16:16:31 -0400 Subject: [PATCH 25/33] Refactor controller tests to use flask_app_fixture (PP-893) (#1634) * Remove uses of the admin settings controller. * Convert discovery_services tests. * Fix up library settings tests * Fix up catalog services controller tests. * Replace admin controller fixture for TestAdminSearchController * Replace admin controller fixture for DiscoveryServiceLibraryRegistrationsController * Replace fixture for TestAdminPermissionsControllerMixin * Replace fixtures in DeviceTokensController tests. --- api/admin/controller/__init__.py | 2 - api/circulation_manager.py | 2 - .../test_admin_search_controller.py | 63 ++-- tests/api/admin/controller/test_base.py | 86 +++-- .../admin/controller/test_catalog_services.py | 104 +++--- .../controller/test_discovery_services.py | 126 +++---- tests/api/admin/controller/test_library.py | 308 ++++++++++-------- .../controller/test_library_registrations.py | 94 +++--- tests/api/admin/controller/test_settings.py | 14 +- tests/api/test_device_tokens.py | 68 ++-- tests/fixtures/api_admin.py | 6 + tests/fixtures/flask.py | 10 +- 12 files changed, 497 insertions(+), 386 deletions(-) diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 822c133a5..65936c732 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -30,7 +30,6 @@ def setup_admin_controllers(manager: CirculationManager): from api.admin.controller.patron import PatronController from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, @@ -48,7 +47,6 @@ def setup_admin_controllers(manager: CirculationManager): manager.admin_custom_lists_controller = CustomListsController(manager) manager.admin_lanes_controller = LanesController(manager) manager.admin_dashboard_controller = DashboardController(manager) - manager.admin_settings_controller = SettingsController(manager) manager.admin_patron_controller = PatronController(manager) manager.admin_discovery_services_controller = DiscoveryServicesController(manager) manager.admin_discovery_service_library_registrations_controller = ( diff --git a/api/circulation_manager.py b/api/circulation_manager.py index e66f5f530..1ed139391 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -64,7 +64,6 @@ from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, @@ -100,7 +99,6 @@ class CirculationManager(LoggerMixin): admin_custom_lists_controller: CustomListsController admin_lanes_controller: LanesController admin_dashboard_controller: DashboardController - admin_settings_controller: SettingsController admin_patron_controller: PatronController admin_discovery_services_controller: DiscoveryServicesController admin_discovery_service_library_registrations_controller: DiscoveryServiceLibraryRegistrationsController diff --git a/tests/api/admin/controller/test_admin_search_controller.py b/tests/api/admin/controller/test_admin_search_controller.py index 6d34f0d19..cad5d2cbc 100644 --- a/tests/api/admin/controller/test_admin_search_controller.py +++ b/tests/api/admin/controller/test_admin_search_controller.py @@ -1,19 +1,22 @@ +from unittest.mock import MagicMock + import pytest +from api.admin.controller.admin_search import AdminSearchController from core.model.classification import Subject from core.model.datasource import DataSource from core.model.licensing import LicensePool from core.model.work import Work -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture class AdminSearchFixture: - def __init__(self, admin_ctrl_fixture: AdminControllerFixture): - self.admin_ctrl_fixture = admin_ctrl_fixture - self.manager = admin_ctrl_fixture.manager - self.db = self.admin_ctrl_fixture.ctrl.db - - db = self.db + def __init__(self, db: DatabaseTransactionFixture): + self.db = db + mock_manager = MagicMock() + mock_manager._db = db.session + self.controller = AdminSearchController(mock_manager) # Setup works with subjects, languages, audiences etc... gutenberg = DataSource.lookup(db.session, DataSource.GUTENBERG) @@ -77,20 +80,23 @@ def __init__(self, admin_ctrl_fixture: AdminControllerFixture): @pytest.fixture(scope="function") def admin_search_fixture( - admin_ctrl_fixture: AdminControllerFixture, + db: DatabaseTransactionFixture, ) -> AdminSearchFixture: - return AdminSearchFixture(admin_ctrl_fixture) + return AdminSearchFixture(db) class TestAdminSearchController: - def test_search_field_values(self, admin_search_fixture: AdminSearchFixture): - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + def test_search_field_values( + self, + admin_search_fixture: AdminSearchFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert response["subjects"] == { "subject 1": 1, @@ -104,14 +110,19 @@ def test_search_field_values(self, admin_search_fixture: AdminSearchFixture): assert response["publishers"] == {"Publisher 1": 3, "Publisher 10": 10} assert response["distributors"] == {"Gutenberg": 13} - def test_different_license_types(self, admin_search_fixture: AdminSearchFixture): + def test_different_license_types( + self, + admin_search_fixture: AdminSearchFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): # Remove the cache - admin_search_fixture.manager.admin_search_controller.__class__._search_field_values_cached.ttls = ( # type: ignore + admin_search_fixture.controller.__class__._search_field_values_cached.ttls = ( # type: ignore 0 ) w = ( - admin_search_fixture.db.session.query(Work) + db.session.query(Work) .filter(Work.presentation_edition.has(title="work3")) .first() ) @@ -122,24 +133,20 @@ def test_different_license_types(self, admin_search_fixture: AdminSearchFixture) # A pool without licenses should not attribute to the count pool.licenses_owned = 0 - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert "Horror" not in response["genres"] assert "Spanish" not in response["languages"] # An open access license should get counted even without owned licenses pool.open_access = True - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert "Horror" in response["genres"] assert "Spanish" in response["languages"] diff --git a/tests/api/admin/controller/test_base.py b/tests/api/admin/controller/test_base.py index 635df2acf..249c117ea 100644 --- a/tests/api/admin/controller/test_base.py +++ b/tests/api/admin/controller/test_base.py @@ -1,59 +1,83 @@ import pytest +from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.exceptions import AdminNotAuthorized from core.model import AdminRole -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture() +def controller() -> AdminPermissionsControllerMixin: + return AdminPermissionsControllerMixin() class TestAdminPermissionsControllerMixin: - def test_require_system_admin(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_system_admin( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_system_admin, + controller.require_system_admin, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - admin_ctrl_fixture.manager.admin_work_controller.require_system_admin() + with flask_app_fixture.test_request_context_system_admin("/admin"): + controller.require_system_admin() def test_require_sitewide_library_manager( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_sitewide_library_manager, + controller.require_sitewide_library_manager, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SITEWIDE_LIBRARY_MANAGER) - admin_ctrl_fixture.manager.admin_work_controller.require_sitewide_library_manager() + library_manager = flask_app_fixture.admin_user( + role=AdminRole.SITEWIDE_LIBRARY_MANAGER + ) + with flask_app_fixture.test_request_context("/admin", admin=library_manager): + controller.require_sitewide_library_manager() - def test_require_library_manager(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_library_manager( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_library_manager, - admin_ctrl_fixture.ctrl.db.default_library(), + controller.require_library_manager, + db.default_library(), ) - admin_ctrl_fixture.admin.add_role( - AdminRole.LIBRARY_MANAGER, admin_ctrl_fixture.ctrl.db.default_library() - ) - admin_ctrl_fixture.manager.admin_work_controller.require_library_manager( - admin_ctrl_fixture.ctrl.db.default_library() - ) + library_manager = flask_app_fixture.admin_user( + role=AdminRole.LIBRARY_MANAGER, library=db.default_library() + ) + with flask_app_fixture.test_request_context("/admin", admin=library_manager): + controller.require_library_manager(db.default_library()) - def test_require_librarian(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_librarian( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_librarian, - admin_ctrl_fixture.ctrl.db.default_library(), + controller.require_librarian, + db.default_library(), ) - admin_ctrl_fixture.admin.add_role( - AdminRole.LIBRARIAN, admin_ctrl_fixture.ctrl.db.default_library() - ) - admin_ctrl_fixture.manager.admin_work_controller.require_librarian( - admin_ctrl_fixture.ctrl.db.default_library() - ) + librarian = flask_app_fixture.admin_user( + role=AdminRole.LIBRARIAN, library=db.default_library() + ) + with flask_app_fixture.test_request_context("/admin", admin=librarian): + controller.require_librarian(db.default_library()) diff --git a/tests/api/admin/controller/test_catalog_services.py b/tests/api/admin/controller/test_catalog_services.py index fa8f5d76d..5c9e9e101 100644 --- a/tests/api/admin/controller/test_catalog_services.py +++ b/tests/api/admin/controller/test_catalog_services.py @@ -1,11 +1,13 @@ import json from contextlib import nullcontext +from unittest.mock import MagicMock import flask import pytest from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.catalog_services import CatalogServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, @@ -19,26 +21,31 @@ from api.integration.registry.catalog_services import CatalogServicesRegistry from core.integration.goals import Goals from core.marc import MARCExporter, MarcExporterLibrarySettings -from core.model import AdminRole, IntegrationConfiguration, get_one +from core.model import IntegrationConfiguration, get_one from core.util.problem_detail import ProblemDetail -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> CatalogServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return CatalogServicesController(mock_manager) class TestCatalogServicesController: def test_catalog_services_get_with_no_services( - self, admin_ctrl_fixture: AdminControllerFixture + self, flask_app_fixture: FlaskAppFixture, controller: CatalogServicesController ): - with admin_ctrl_fixture.request_context_with_admin("/"): + with flask_app_fixture.test_request_context("/"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services, + controller.process_catalog_services, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 data = response.json @@ -55,10 +62,11 @@ def test_catalog_services_get_with_no_services( assert "library_settings" in protocols[0] def test_catalog_services_get_with_marc_exporter( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) library_settings = MarcExporterLibrarySettings( include_summary=True, include_genres=True, organization_code="US-MaBoDPL" ) @@ -77,10 +85,8 @@ def test_catalog_services_get_with_marc_exporter( library_settings_integration, library_settings ) - with admin_ctrl_fixture.request_context_with_admin("/"): - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 data = response.json @@ -93,10 +99,7 @@ def test_catalog_services_get_with_marc_exporter( assert integration.name == service.get("name") assert integration.protocol == service.get("protocol") [library] = service.get("libraries") - assert ( - admin_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") assert "US-MaBoDPL" == library.get("organization_code") assert library.get("include_summary") is True assert library.get("include_genres") is True @@ -156,18 +159,21 @@ def test_catalog_services_get_with_marc_exporter( ) def test_catalog_services_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, post_data: dict[str, str], expected: ProblemDetail | None, admin: bool, raises: type[Exception] | None, ): if admin: - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + make_request = flask_app_fixture.test_request_context_system_admin + else: + make_request = flask_app_fixture.test_request_context context_manager = pytest.raises(raises) if raises is not None else nullcontext() - db = admin_ctrl_fixture.ctrl.db service = db.integration_configuration( "fake protocol", Goals.CATALOG_GOAL, @@ -178,12 +184,10 @@ def test_catalog_services_post_errors( if post_data.get("id") == "": post_data["id"] = str(service.id) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with make_request("/", method="POST"): flask.request.form = ImmutableMultiDict(post_data) with context_manager: - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, ProblemDetail) assert isinstance(expected, ProblemDetail) assert response.uri == expected.uri @@ -191,14 +195,15 @@ def test_catalog_services_post_errors( assert response.title == expected.title def test_catalog_services_post_create( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "exporter name"), @@ -217,9 +222,7 @@ def test_catalog_services_post_create( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 201 @@ -240,12 +243,13 @@ def test_catalog_services_post_create( assert settings.include_genres is True def test_catalog_services_post_edit( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) service = db.integration_configuration( protocol, @@ -253,7 +257,7 @@ def test_catalog_services_post_edit( name="name", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "exporter name"), @@ -273,9 +277,7 @@ def test_catalog_services_post_edit( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 @@ -288,8 +290,12 @@ def test_catalog_services_post_edit( assert settings.include_summary is True assert settings.include_genres is False - def test_catalog_services_delete(self, admin_ctrl_fixture: AdminControllerFixture): - db = admin_ctrl_fixture.ctrl.db + def test_catalog_services_delete( + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, + ): protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None @@ -299,17 +305,15 @@ def test_catalog_services_delete(self, admin_ctrl_fixture: AdminControllerFixtur name="name", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_delete, + controller.process_delete, service.id, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = admin_ctrl_fixture.manager.admin_catalog_services_controller.process_delete( - service.id - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete(service.id) assert isinstance(response, Response) assert response.status_code == 200 diff --git a/tests/api/admin/controller/test_discovery_services.py b/tests/api/admin/controller/test_discovery_services.py index 5c5ed4d07..d533817dc 100644 --- a/tests/api/admin/controller/test_discovery_services.py +++ b/tests/api/admin/controller/test_discovery_services.py @@ -1,12 +1,14 @@ from __future__ import annotations from typing import TYPE_CHECKING +from unittest.mock import MagicMock import flask import pytest from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.discovery_services import DiscoveryServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( INCOMPLETE_CONFIGURATION, @@ -19,12 +21,22 @@ from api.discovery.opds_registration import OpdsRegistrationService from api.integration.registry.discovery import DiscoveryRegistry from core.integration.goals import Goals -from core.model import AdminRole, ExternalIntegration, IntegrationConfiguration, get_one +from core.model import ExternalIntegration, IntegrationConfiguration, get_one from core.util.problem_detail import ProblemDetail +from tests.fixtures.flask import FlaskAppFixture if TYPE_CHECKING: - from tests.fixtures.api_admin import SettingsControllerFixture - from tests.fixtures.database import IntegrationConfigurationFixture + from tests.fixtures.database import ( + DatabaseTransactionFixture, + IntegrationConfigurationFixture, + ) + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> DiscoveryServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DiscoveryServicesController(mock_manager) class TestDiscoveryServices: @@ -39,12 +51,12 @@ def protocol(self): return registry.get_protocol(OpdsRegistrationService) def test_discovery_services_get_with_no_services_creates_default( - self, settings_ctrl_fixture: SettingsControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_discovery_services() assert response.status_code == 200 assert isinstance(response, Response) json = response.get_json() @@ -60,25 +72,26 @@ def test_discovery_services_get_with_no_services_creates_default( "name" ) + with flask_app_fixture.test_request_context("/"): # Only system admins can see the discovery services. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services, + controller.process_discovery_services, ) def test_discovery_services_get_with_one_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( - url=settings_ctrl_fixture.ctrl.db.fresh_str() + url=db.fresh_str() ) - controller = settings_ctrl_fixture.manager.admin_discovery_services_controller + controller = controller - with settings_ctrl_fixture.request_context_with_admin("/"): + with flask_app_fixture.test_request_context_system_admin("/"): response = controller.process_discovery_services() assert isinstance(response, Response) [service] = response.get_json().get("discovery_services") @@ -91,11 +104,13 @@ def test_discovery_services_get_with_one_service( def test_discovery_services_post_errors( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): - controller = settings_ctrl_fixture.manager.admin_discovery_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + controller = controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -105,7 +120,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == UNKNOWN_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -114,7 +129,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == NO_PROTOCOL_FOR_NEW_SERVICE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -125,11 +140,11 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == MISSING_SERVICE - integration_url = settings_ctrl_fixture.ctrl.db.fresh_url() + integration_url = db.fresh_url() existing_integration = create_integration_configuration.discovery_service( url=integration_url ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): assert isinstance(existing_integration.name, str) flask.request.form = ImmutableMultiDict( [ @@ -141,7 +156,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): assert isinstance(existing_integration.protocol, str) flask.request.form = ImmutableMultiDict( [ @@ -153,7 +168,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == INTEGRATION_URL_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(existing_integration.id)), @@ -164,8 +179,7 @@ def test_discovery_services_post_errors( assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("protocol", self.protocol), @@ -175,9 +189,12 @@ def test_discovery_services_post_errors( pytest.raises(AdminNotAuthorized, controller.process_discovery_services) def test_discovery_services_post_create( - self, settings_ctrl_fixture: SettingsControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, ): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -185,13 +202,11 @@ def test_discovery_services_post_create( (ExternalIntegration.URL, "http://registry.url"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response.status_code == 201 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, goal=Goals.DISCOVERY_GOAL, ) @@ -205,14 +220,15 @@ def test_discovery_services_post_create( def test_discovery_services_post_edit( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( url="registry url" ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -221,9 +237,7 @@ def test_discovery_services_post_edit( (ExternalIntegration.URL, "http://new_registry_url.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response.status_code == 200 assert isinstance(response, Response) @@ -236,7 +250,8 @@ def test_discovery_services_post_edit( def test_check_name_unique( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, create_integration_configuration: IntegrationConfigurationFixture, ): existing_service = create_integration_configuration.discovery_service() @@ -244,7 +259,7 @@ def test_check_name_unique( # Try to change new service so that it has the same name as existing service # -- this is not allowed. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", str(existing_service.name)), @@ -253,13 +268,11 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE # Try to edit existing service without changing its name -- this is fine. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", str(existing_service.name)), @@ -268,14 +281,12 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert isinstance(response, Response) assert response.status_code == 200 # Changing the existing service's name is also fine. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "New name"), @@ -284,38 +295,37 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert isinstance(response, Response) assert response.status_code == 200 def test_discovery_service_delete( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( url="registry url" ) - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_delete, + controller.process_delete, discovery_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_discovery_services_controller.process_delete( + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete( discovery_service.id # type: ignore[arg-type] ) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, id=discovery_service.id, ) - assert None == service + assert service is None diff --git a/tests/api/admin/controller/test_library.py b/tests/api/admin/controller/test_library.py index 6cc878577..0cf36be50 100644 --- a/tests/api/admin/controller/test_library.py +++ b/tests/api/admin/controller/test_library.py @@ -4,7 +4,8 @@ import datetime import json from io import BytesIO -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import MagicMock, create_autospec import flask import pytest @@ -30,13 +31,21 @@ from core.model.library import LibraryLogo from core.util.problem_detail import ProblemDetail, ProblemError from tests.fixtures.announcements import AnnouncementFixture -from tests.fixtures.api_controller import ControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture from tests.fixtures.library import LibraryFixture +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> LibrarySettingsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return LibrarySettingsController(mock_manager) + + class TestLibrarySettings: @pytest.fixture() - def logo_properties(self): + def logo_properties(self) -> dict[str, Any]: image_data_raw = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00\x00\x00%\xdbV\xca\x00\x00\x00\x06PLTE\xffM\x00\x01\x01\x01\x8e\x1e\xe5\x1b\x00\x00\x00\x01tRNS\xcc\xd24V\xfd\x00\x00\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01H\xaf\xa4q\x00\x00\x00\x00IEND\xaeB`\x82" image_data_b64_bytes = base64.b64encode(image_data_raw) image_data_b64_unicode = image_data_b64_bytes.decode("utf-8") @@ -52,7 +61,7 @@ def logo_properties(self): def library_form( self, library: Library, fields: dict[str, str | list[str]] | None = None - ): + ) -> ImmutableMultiDict[str, str]: fields = fields or {} defaults: dict[str, str | list[str]] = { "uuid": str(library.uuid), @@ -75,39 +84,45 @@ def library_form( form = ImmutableMultiDict(form_data) return form - def test_libraries_get_with_no_libraries(self, settings_ctrl_fixture): + def test_libraries_get_with_no_libraries( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): # Delete any existing library created by the controller test setup. - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library) + library = get_one(db.session, Library) if library: - settings_ctrl_fixture.ctrl.db.session.delete(library) + db.session.delete(library) - with settings_ctrl_fixture.ctrl.app.test_request_context("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) assert response.json.get("libraries") == [] def test_libraries_get_with_announcements( - self, settings_ctrl_fixture, announcement_fixture: AnnouncementFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + announcement_fixture: AnnouncementFixture, ): - db = settings_ctrl_fixture.ctrl.db # Delete any existing library created by the controller test setup. library = get_one(db.session, Library) if library: db.session.delete(library) # Set some announcements for this library. - test_library = settings_ctrl_fixture.ctrl.db.library("Library 1", "L1") + test_library = db.library("Library 1", "L1") a1 = announcement_fixture.active_announcement(db.session, test_library) a2 = announcement_fixture.expired_announcement(db.session, test_library) a3 = announcement_fixture.forthcoming_announcement(db.session, test_library) # When we request information about this library... - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - library_settings = response.json.get("libraries")[0].get("settings") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) + library_settings = response.json.get("libraries", [])[0].get("settings") # We find out about the library's announcements. announcements = library_settings.get(ANNOUNCEMENTS_SETTING_NAME) @@ -130,21 +145,23 @@ def test_libraries_get_with_announcements( datetime.date, ) - def test_libraries_get_with_logo(self, settings_ctrl_fixture, logo_properties): - db = settings_ctrl_fixture.ctrl.db - + def test_libraries_get_with_logo( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + logo_properties: dict[str, Any], + ): library = db.default_library() # Give the library a logo library.logo = LibraryLogo(content=logo_properties["base64_bytes"]) # When we request information about this library... - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - - libraries = response.json.get("libraries") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) + libraries = response.json.get("libraries", []) assert len(libraries) == 1 library_settings = libraries[0].get("settings") @@ -152,12 +169,16 @@ def test_libraries_get_with_logo(self, settings_ctrl_fixture, logo_properties): assert library_settings["logo"] == logo_properties["data_url"] def test_libraries_get_with_multiple_libraries( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): # Delete any existing library created by the controller test setup. - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library) + library = get_one(db.session, Library) if library: - settings_ctrl_fixture.ctrl.db.session.delete(library) + db.session.delete(library) l1 = library_fixture.library("Library 1", "L1") l2 = library_fixture.library("Library 2", "L2") @@ -175,15 +196,15 @@ def test_libraries_get_with_multiple_libraries( l2.update_settings(settings) # The admin only has access to L1 and L2. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.admin.add_role(AdminRole.LIBRARIAN, l1) - settings_ctrl_fixture.admin.add_role(AdminRole.LIBRARY_MANAGER, l2) - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - libraries = response.json.get("libraries") + admin = flask_app_fixture.admin_user() + admin.remove_role(AdminRole.SYSTEM_ADMIN) + admin.add_role(AdminRole.LIBRARIAN, l1) + admin.add_role(AdminRole.LIBRARY_MANAGER, l2) + + with flask_app_fixture.test_request_context("/", admin=admin): + response = controller.process_get() + assert isinstance(response.json, dict) + libraries = response.json.get("libraries", []) assert 2 == len(libraries) assert l1.uuid == libraries[0].get("uuid") @@ -211,77 +232,82 @@ def test_libraries_get_with_multiple_libraries( ] == settings_dict.get("facets_enabled_order") assert ["fre"] == settings_dict.get("large_collection_languages") - def test_libraries_post_errors(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + def test_libraries_post_errors( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict([]) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri assert ( "Required field 'Name' is missing." == excinfo.value.problem_detail.detail ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Brooklyn Public Library"), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri assert ( "Required field 'Short name' is missing." == excinfo.value.problem_detail.detail ) - library = settings_ctrl_fixture.ctrl.db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + library = db.library() + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library, {"uuid": "1234"}) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == LIBRARY_NOT_FOUND.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Brooklyn Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail == LIBRARY_SHORT_NAME_ALREADY_IN_USE - bpl = settings_ctrl_fixture.ctrl.db.library(short_name="bpl") - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + bpl = db.library(short_name="bpl") + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ - ("uuid", bpl.uuid), + ("uuid", str(bpl.uuid)), ("name", "Brooklyn Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail == LIBRARY_SHORT_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ - ("uuid", library.uuid), + ("uuid", str(library.uuid)), ("name", "The New York Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri # Either patron support email or website MUST be present - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Email or Website Library"), @@ -291,8 +317,9 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "'Patron support email address' or 'Patron support website'" in excinfo.value.problem_detail.detail @@ -300,7 +327,7 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): # Test a web primary and secondary color that doesn't contrast # well on white. Here primary will, secondary should not. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, { @@ -309,8 +336,9 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): }, ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "contrast-ratio.com/#%23e0e0e0-on-%23ffffff" in excinfo.value.problem_detail.detail @@ -322,7 +350,7 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): # Test a list of web header links and a list of labels that # aren't the same length. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, { @@ -334,24 +362,25 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): }, ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri # Test bad language code - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, {"tiny_collection_languages": "zzz"} ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == UNKNOWN_LANGUAGE.uri + assert excinfo.value.problem_detail.detail is not None assert ( '"zzz" is not a valid language code' in excinfo.value.problem_detail.detail ) # Test uploading a logo that is in the wrong format. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library) flask.request.files = ImmutableMultiDict( { @@ -363,15 +392,16 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): } ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "Image upload must be in GIF, PNG, or JPG format." in excinfo.value.problem_detail.detail ) # Test uploading a logo that we can't open to resize. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library) flask.request.files = ImmutableMultiDict( { @@ -383,13 +413,14 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): } ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "Unable to open uploaded image" in excinfo.value.problem_detail.detail ) - def test__process_image(self, logo_properties, settings_ctrl_fixture): + def test__process_image(self, logo_properties: dict[str, Any]): image, expected_encoded_image = ( logo_properties[key] for key in ("image", "base64_bytes") ) @@ -408,12 +439,12 @@ def test__process_image(self, logo_properties, settings_ctrl_fixture): def test_libraries_post_create( self, - logo_properties, - settings_ctrl_fixture, + logo_properties: dict[str, Any], + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, announcement_fixture: AnnouncementFixture, ): - db = settings_ctrl_fixture.ctrl.db - # Pull needed properties from logo fixture image_data, expected_logo_data_url, image = ( logo_properties[key] for key in ("raw_bytes", "data_url", "image") @@ -423,7 +454,7 @@ def test_libraries_post_create( # a mismatch between the expected data URL and the one configured. assert max(*image.size) <= Configuration.LOGO_MAX_DIMENSION - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "The New York Public Library"), @@ -477,9 +508,7 @@ def test_libraries_post_create( ) } ) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 library = get_one(db.session, Library, short_name="nypl") @@ -532,7 +561,11 @@ def test_libraries_post_create( assert ["ger"] == german.languages def test_libraries_post_edit( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): # A library already exists. settings = library_fixture.mock_settings() @@ -548,7 +581,7 @@ def test_libraries_post_edit( library_to_edit.logo = LibraryLogo(content=b"A tiny image") library_fixture.reset_settings_cache(library_to_edit) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("uuid", str(library_to_edit.uuid)), @@ -576,15 +609,10 @@ def test_libraries_post_edit( ), ] ) - flask.request.files = ImmutableMultiDict([]) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library_to_edit.uuid - ) + library = get_one(db.session, Library, uuid=library_to_edit.uuid) assert library is not None assert library.uuid == response.get_data(as_text=True) @@ -609,7 +637,11 @@ def test_libraries_post_edit( assert library.logo.content == b"A tiny image" def test_library_post_empty_values_edit( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): settings = library_fixture.mock_settings() settings.library_description = "description" @@ -618,7 +650,7 @@ def test_library_post_empty_values_edit( ) library_fixture.reset_settings_cache(library_to_edit) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("uuid", str(library_to_edit.uuid)), @@ -629,19 +661,20 @@ def test_library_post_empty_values_edit( ("help_email", "help@example.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library_to_edit.uuid - ) + library = get_one(db.session, Library, uuid=library_to_edit.uuid) assert library is not None assert library.settings.library_description is None - def test_library_post_empty_values_create(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + def test_library_post_empty_values_create( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "The New York Public Library"), @@ -651,75 +684,76 @@ def test_library_post_empty_values_create(self, settings_ctrl_fixture): ("help_email", "help@example.com"), ] ) - response: Response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response: Response = controller.process_post() assert response.status_code == 201 uuid = response.get_data(as_text=True) - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library, uuid=uuid) + library = get_one(db.session, Library, uuid=uuid) + assert library is not None assert library.settings.library_description is None - def test_library_delete(self, settings_ctrl_fixture): - library = settings_ctrl_fixture.ctrl.db.library() + def test_library_delete( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + library = db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_library_settings_controller.process_delete, + controller.process_delete, library.uuid, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_library_settings_controller.process_delete( - library.uuid - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete(str(library.uuid)) assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library.uuid - ) - assert None == library + queried_library = get_one(db.session, Library, uuid=library.uuid) + assert queried_library is None - def test_process_libraries(self, controller_fixture: ControllerFixture): - manager = MagicMock() - controller = LibrarySettingsController(manager) - controller.process_get = MagicMock() - controller.process_post = MagicMock() + def test_process_libraries( + self, flask_app_fixture: FlaskAppFixture, controller: LibrarySettingsController + ): + mock_process_get = create_autospec(controller.process_get) + controller.process_get = mock_process_get + mock_process_post = create_autospec(controller.process_post) + controller.process_post = mock_process_post # Make sure we call process_get for a get request - with controller_fixture.request_context_with_library("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): controller.process_libraries() - controller.process_get.assert_called_once() - controller.process_post.assert_not_called() - controller.process_get.reset_mock() - controller.process_post.reset_mock() + mock_process_get.assert_called_once() + mock_process_post.assert_not_called() + mock_process_get.reset_mock() + mock_process_post.reset_mock() # Make sure we call process_post for a post request - with controller_fixture.request_context_with_library("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): controller.process_libraries() - controller.process_get.assert_not_called() - controller.process_post.assert_called_once() - controller.process_get.reset_mock() - controller.process_post.reset_mock() + mock_process_get.assert_not_called() + mock_process_post.assert_called_once() + mock_process_get.reset_mock() + mock_process_post.reset_mock() # For any other request, make sure we return a ProblemDetail - with controller_fixture.request_context_with_library("/", method="PUT"): + with flask_app_fixture.test_request_context("/", method="PUT"): resp = controller.process_libraries() - controller.process_get.assert_not_called() - controller.process_post.assert_not_called() + mock_process_get.assert_not_called() + mock_process_post.assert_not_called() assert isinstance(resp, ProblemDetail) # Make sure that if process_get or process_post raises a ProblemError, # we return the problem detail. - controller.process_get.side_effect = ProblemError( + mock_process_get.side_effect = ProblemError( problem_detail=INCOMPLETE_CONFIGURATION.detailed("test") ) - with controller_fixture.request_context_with_library("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): resp = controller.process_libraries() assert isinstance(resp, ProblemDetail) assert resp.detail == "test" diff --git a/tests/api/admin/controller/test_library_registrations.py b/tests/api/admin/controller/test_library_registrations.py index 889b566c9..f5d54bb40 100644 --- a/tests/api/admin/controller/test_library_registrations.py +++ b/tests/api/admin/controller/test_library_registrations.py @@ -6,11 +6,14 @@ from requests_mock import Mocker from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.discovery_service_library_registrations import ( + DiscoveryServiceLibraryRegistrationsController, +) from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import MISSING_SERVICE, NO_SUCH_LIBRARY from api.discovery.opds_registration import OpdsRegistrationService from api.problem_details import REMOTE_INTEGRATION_FAILED -from core.model import AdminRole, create +from core.model import create from core.model.discovery_service_registration import ( DiscoveryServiceRegistration, RegistrationStage, @@ -18,23 +21,35 @@ ) from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail, ProblemError -from tests.fixtures.api_admin import AdminControllerFixture -from tests.fixtures.database import IntegrationConfigurationFixture +from tests.fixtures.database import ( + DatabaseTransactionFixture, + IntegrationConfigurationFixture, +) +from tests.fixtures.flask import FlaskAppFixture from tests.fixtures.library import LibraryFixture +@pytest.fixture +def controller( + db: DatabaseTransactionFixture, +) -> DiscoveryServiceLibraryRegistrationsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DiscoveryServiceLibraryRegistrationsController(mock_manager) + + class TestLibraryRegistration: """Test the process of registering a library with a OpdsRegistrationService.""" def test_discovery_service_library_registrations_get( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: DiscoveryServiceLibraryRegistrationsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, library_fixture: LibraryFixture, requests_mock: Mocker, ) -> None: - db = admin_ctrl_fixture.ctrl.db - # Here's a discovery service. discovery_service = create_integration_configuration.discovery_service( url="http://service-url.com/" @@ -112,20 +127,18 @@ def test_discovery_service_library_registrations_get( headers={"Content-Type": OpdsRegistrationService.OPDS_2_TYPE}, ) - controller = ( - admin_ctrl_fixture.ctrl.manager.admin_discovery_service_library_registrations_controller - ) - m = controller.process_discovery_service_library_registrations - with admin_ctrl_fixture.request_context_with_admin("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): # When the user lacks the SYSTEM_ADMIN role, the # controller won't even start processing their GET # request. - pytest.raises(AdminNotAuthorized, m) - - # Add the admin role and try again. - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + pytest.raises( + AdminNotAuthorized, + controller.process_discovery_service_library_registrations, + ) - response = m() + # Request again with system admin role + with flask_app_fixture.test_request_context_system_admin("/", method="GET"): + response = controller.process_discovery_service_library_registrations() # The document we get back from the controller is a # dictionary with useful information on all known # discovery integrations -- just one, in this case. @@ -179,7 +192,7 @@ def test_discovery_service_library_registrations_get( status_code=502, ) - response = m() + response = controller.process_discovery_service_library_registrations() # Everything looks good, except that there's no TOS data # available. @@ -198,7 +211,8 @@ def test_discovery_service_library_registrations_get( def test_discovery_service_library_registrations_post( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: DiscoveryServiceLibraryRegistrationsController, + flask_app_fixture: FlaskAppFixture, create_integration_configuration: IntegrationConfigurationFixture, library_fixture: LibraryFixture, ) -> None: @@ -206,34 +220,30 @@ def test_discovery_service_library_registrations_post( discovery_service_library_registrations. """ - controller = ( - admin_ctrl_fixture.manager.admin_discovery_service_library_registrations_controller - ) - m = controller.process_discovery_service_library_registrations - # Here, the user doesn't have permission to start the # registration process. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): - pytest.raises(AdminNotAuthorized, m) - - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="POST"): + pytest.raises( + AdminNotAuthorized, + controller.process_discovery_service_library_registrations, + ) # We might not get an integration ID parameter. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict() - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The integration ID might not correspond to a valid # ExternalIntegration. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", "1234"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert MISSING_SERVICE == response @@ -243,44 +253,44 @@ def test_discovery_service_library_registrations_post( ) # We might not get a library short name. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The library name might not correspond to a real library. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ("library_short_name", "not-a-library"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert NO_SUCH_LIBRARY == response # Take care of that problem. library = library_fixture.library() # We might not get a registration stage. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ("library_short_name", str(library.short_name)), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The registration stage might not be valid. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), @@ -288,7 +298,7 @@ def test_discovery_service_library_registrations_post( ("registration_stage", "not-a-stage"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri @@ -307,9 +317,9 @@ def test_discovery_service_library_registrations_post( ) controller.look_up_registry = MagicMock(return_value=mock_registry) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = form - response = m() + response = controller.process_discovery_service_library_registrations() assert REMOTE_INTEGRATION_FAILED == response # But if that doesn't happen, success! @@ -317,7 +327,7 @@ def test_discovery_service_library_registrations_post( mock_registry.register_library.return_value = True controller.look_up_registry = MagicMock(return_value=mock_registry) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = form response = controller.process_discovery_service_library_registrations() assert isinstance(response, Response) diff --git a/tests/api/admin/controller/test_settings.py b/tests/api/admin/controller/test_settings.py index 12f380ab6..479d72187 100644 --- a/tests/api/admin/controller/test_settings.py +++ b/tests/api/admin/controller/test_settings.py @@ -71,9 +71,7 @@ def test_get_integration_info( self, settings_ctrl_fixture: SettingsControllerFixture ): """Test the _get_integration_info helper method.""" - m = ( - settings_ctrl_fixture.manager.admin_settings_controller._get_integration_info - ) + m = settings_ctrl_fixture.controller._get_integration_info # Test the case where there are integrations in the database # with the given goal, but none of them match the @@ -87,7 +85,7 @@ def test_get_integration_info( def test_create_integration(self, settings_ctrl_fixture: SettingsControllerFixture): """Test the _create_integration helper method.""" - m = settings_ctrl_fixture.manager.admin_settings_controller._create_integration + m = settings_ctrl_fixture.controller._create_integration protocol_definitions = [ dict(name="allow many"), @@ -131,7 +129,7 @@ def test_create_integration(self, settings_ctrl_fixture: SettingsControllerFixtu def test_check_url_unique(self, settings_ctrl_fixture: SettingsControllerFixture): # Verify our ability to catch duplicate integrations for a # given URL. - m = settings_ctrl_fixture.manager.admin_settings_controller.check_url_unique + m = settings_ctrl_fixture.controller.check_url_unique # Here's an ExternalIntegration. original = settings_ctrl_fixture.ctrl.db.external_integration( @@ -215,9 +213,7 @@ def m(url): def test__get_protocol_class( self, settings_ctrl_fixture: SettingsControllerFixture ): - _get_protocol_class = ( - settings_ctrl_fixture.manager.admin_settings_controller._get_settings_class - ) + _get_protocol_class = settings_ctrl_fixture.controller._get_settings_class registry = IntegrationRegistry[Any](Goals.LICENSE_GOAL) class P1Settings(BaseSettings): @@ -261,7 +257,7 @@ def test__set_configuration_library( db = settings_ctrl_fixture.ctrl.db config = db.default_collection().integration_configuration _set_configuration_library = ( - settings_ctrl_fixture.manager.admin_settings_controller._set_configuration_library + settings_ctrl_fixture.controller._set_configuration_library ) library = db.library(short_name="short-name") diff --git a/tests/api/test_device_tokens.py b/tests/api/test_device_tokens.py index f807a1f8a..24a5c2915 100644 --- a/tests/api/test_device_tokens.py +++ b/tests/api/test_device_tokens.py @@ -1,25 +1,37 @@ from unittest.mock import MagicMock, patch +import pytest + +from api.controller.device_tokens import DeviceTokensController from api.problem_details import DEVICE_TOKEN_NOT_FOUND, DEVICE_TOKEN_TYPE_INVALID from core.model.devicetokens import DeviceToken, DeviceTokenTypes -from tests.fixtures.api_controller import ControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> DeviceTokensController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DeviceTokensController(mock_manager) @patch("api.controller.device_tokens.flask") class TestDeviceTokens: - def test_create_invalid_type(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_invalid_type( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): request = MagicMock() request.patron = db.patron() request.json = {"device_token": "xx", "token_type": "aninvalidtoken"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.create_patron_device() + detail = controller.create_patron_device() assert detail is DEVICE_TOKEN_TYPE_INVALID assert detail.status_code == 400 - def test_create_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): request = MagicMock() request.patron = db.patron() request.json = { @@ -27,7 +39,7 @@ def test_create_token(self, flask, controller_fixture: ControllerFixture): "token_type": DeviceTokenTypes.FCM_ANDROID, } flask.request = request - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response[1] == 201 @@ -42,8 +54,9 @@ def test_create_token(self, flask, controller_fixture: ControllerFixture): assert device.device_token == "xxx" assert device.token_type == DeviceTokenTypes.FCM_ANDROID - def test_get_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_get_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -53,14 +66,15 @@ def test_get_token(self, flask, controller_fixture: ControllerFixture): request.patron = patron request.args = {"device_token": "xx"} flask.request = request - response = controller_fixture.app.manager.patron_devices.get_patron_device() + response = controller.get_patron_device() assert response[1] == 200 assert response[0]["token_type"] == DeviceTokenTypes.FCM_ANDROID assert response[0]["device_token"] == "xx" - def test_get_token_not_found(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_get_token_not_found( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -70,14 +84,13 @@ def test_get_token_not_found(self, flask, controller_fixture: ControllerFixture) request.patron = patron request.args = {"device_token": "xxs"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.get_patron_device() + detail = controller.get_patron_device() assert detail == DEVICE_TOKEN_NOT_FOUND def test_get_token_different_patron( - self, flask, controller_fixture: ControllerFixture + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture ): - db = controller_fixture.db patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -87,12 +100,13 @@ def test_get_token_different_patron( request.patron = db.patron() request.args = {"device_token": "xx"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.get_patron_device() + detail = controller.get_patron_device() assert detail == DEVICE_TOKEN_NOT_FOUND - def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_duplicate_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -105,7 +119,7 @@ def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixtu } flask.request = request nested = db.session.begin_nested() # rollback only affects device create - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response == (dict(exists=True), 200) # different patron same token @@ -117,12 +131,13 @@ def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixtu "token_type": DeviceTokenTypes.FCM_ANDROID, } flask.request = request - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response[1] == 201 - def test_delete_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_delete_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -134,14 +149,15 @@ def test_delete_token(self, flask, controller_fixture: ControllerFixture): } flask.request = request - response = controller_fixture.app.manager.patron_devices.delete_patron_device() + response = controller.delete_patron_device() db.session.commit() assert response.status_code == 204 assert db.session.query(DeviceToken).get(device.id) == None - def test_delete_no_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_delete_no_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -153,5 +169,5 @@ def test_delete_no_token(self, flask, controller_fixture: ControllerFixture): } flask.request = request - response = controller_fixture.app.manager.patron_devices.delete_patron_device() + response = controller.delete_patron_device() assert response == DEVICE_TOKEN_NOT_FOUND diff --git a/tests/fixtures/api_admin.py b/tests/fixtures/api_admin.py index 081841cc9..b536219df 100644 --- a/tests/fixtures/api_admin.py +++ b/tests/fixtures/api_admin.py @@ -1,9 +1,11 @@ from contextlib import contextmanager +from unittest.mock import MagicMock import flask import pytest from api.admin.controller import setup_admin_controllers +from api.admin.controller.settings import SettingsController from api.app import initialize_admin from api.circulation_manager import CirculationManager from api.config import Configuration @@ -100,6 +102,10 @@ def __init__(self, controller_fixture: ControllerFixture): # Make the admin a system admin so they can do everything by default. self.admin.add_role(AdminRole.SYSTEM_ADMIN) + mock_manager = MagicMock() + mock_manager._db = self.ctrl.db.session + self.controller = SettingsController(mock_manager) + def do_request(self, url, *args, **kwargs): """Mock HTTP get/post method to replace HTTP.get_with_timeout or post_with_timeout.""" self.requests.append((url, args, kwargs)) diff --git a/tests/fixtures/flask.py b/tests/fixtures/flask.py index c105472e1..3ef24f849 100644 --- a/tests/fixtures/flask.py +++ b/tests/fixtures/flask.py @@ -8,6 +8,7 @@ import pytest from flask.ctx import RequestContext from flask_babel import Babel +from werkzeug.datastructures import ImmutableMultiDict from api.util.flask import PalaceFlask from core.model import Admin, AdminRole, Library, get_one_or_create @@ -32,11 +33,18 @@ def admin_user( @contextmanager def test_request_context( - self, *args: Any, admin: Admin | None = None, **kwargs: Any + self, + *args: Any, + admin: Admin | None = None, + library: Library | None = None, + **kwargs: Any, ) -> Generator[RequestContext, None, None]: with self.app.test_request_context(*args, **kwargs) as c: self.db.session.begin_nested() + flask.request.library = library # type: ignore[attr-defined] flask.request.admin = admin # type: ignore[attr-defined] + flask.request.form = ImmutableMultiDict() + flask.request.files = ImmutableMultiDict() yield c # Flush any changes that may have occurred during the request, then From f39f2920215656d628515f0757247f5691b398ab Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 30 Jan 2024 10:53:32 -0400 Subject: [PATCH 26/33] Remove SettingsController from IndividualAdminSettingsController (PP-893) (#1635) * Remove SettingsController from IndividualAdminSettingsController --- api/admin/controller/__init__.py | 2 +- .../controller/individual_admin_settings.py | 19 +- .../controller/test_individual_admins.py | 594 +++++++++--------- 3 files changed, 328 insertions(+), 287 deletions(-) diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 65936c732..1ea4dbb71 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -63,7 +63,7 @@ def setup_admin_controllers(manager: CirculationManager): ) manager.admin_library_settings_controller = LibrarySettingsController(manager) manager.admin_individual_admin_settings_controller = ( - IndividualAdminSettingsController(manager) + IndividualAdminSettingsController(manager._db) ) manager.admin_catalog_services_controller = CatalogServicesController(manager) manager.admin_announcement_service = AnnouncementSettings(manager) diff --git a/api/admin/controller/individual_admin_settings.py b/api/admin/controller/individual_admin_settings.py index 8e4297012..010046ffc 100644 --- a/api/admin/controller/individual_admin_settings.py +++ b/api/admin/controller/individual_admin_settings.py @@ -3,13 +3,16 @@ import flask from flask import Response from flask_babel import lazy_gettext as _ +from pydantic import EmailStr, parse_obj_as from sqlalchemy.exc import ProgrammingError +from sqlalchemy.orm import Session -from api.admin.controller.settings import SettingsController +from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( ADMIN_AUTH_NOT_CONFIGURED, INCOMPLETE_CONFIGURATION, + INVALID_EMAIL, MISSING_ADMIN, MISSING_PGCRYPTO_EXTENSION, UNKNOWN_ROLE, @@ -19,7 +22,10 @@ from core.util.problem_detail import ProblemDetail -class IndividualAdminSettingsController(SettingsController): +class IndividualAdminSettingsController(AdminPermissionsControllerMixin): + def __init__(self, db: Session): + self._db = db + def process_individual_admins(self): if flask.request.method == "GET": return self.process_get() @@ -290,9 +296,12 @@ def validate_form_fields(self, email): _("The email field cannot be blank.") ) - email_error = self.validate_formats(email) - if email_error: - return email_error + try: + parse_obj_as(EmailStr, email) + except ValueError: + return INVALID_EMAIL.detailed( + _('"%(email)s" is not a valid email address.', email=email) + ) def validate_role_exists(self, role): if role.get("role") not in AdminRole.ROLES: diff --git a/tests/api/admin/controller/test_individual_admins.py b/tests/api/admin/controller/test_individual_admins.py index 9e90f4a26..c10a7a292 100644 --- a/tests/api/admin/controller/test_individual_admins.py +++ b/tests/api/admin/controller/test_individual_admins.py @@ -2,22 +2,36 @@ import flask import pytest -from werkzeug.datastructures import MultiDict +from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.individual_admin_settings import ( + IndividualAdminSettingsController, +) from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( ADMIN_AUTH_NOT_CONFIGURED, INCOMPLETE_CONFIGURATION, + INVALID_EMAIL, UNKNOWN_ROLE, ) from api.problem_details import LIBRARY_NOT_FOUND from core.model import Admin, AdminRole, create, get_one +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture -class TestIndividualAdmins: - def test_individual_admins_get(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> IndividualAdminSettingsController: + return IndividualAdminSettingsController(db.session) + +class TestIndividualAdmins: + def test_individual_admins_get( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): for admin in db.session.query(Admin): db.session.delete(admin) @@ -42,38 +56,36 @@ def test_individual_admins_get(self, settings_ctrl_fixture): admin6, ignore = create(db.session, Admin, email="admin6@l2.org") admin6.add_role(AdminRole.SITEWIDE_LIBRARY_MANAGER) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin1): + with flask_app_fixture.test_request_context("/", admin=admin1): # A system admin can see all other admins' roles. - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) - admins = response.get("individualAdmins") + response = controller.process_get() + admins = response.get("individualAdmins", []) expected = { "admin1@nypl.org": [{"role": AdminRole.SYSTEM_ADMIN}], "admin2@nypl.org": [ { "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, + "library": str(db.default_library().short_name), }, {"role": AdminRole.SITEWIDE_LIBRARIAN}, ], "admin3@nypl.org": [ { "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, + "library": str(db.default_library().short_name), } ], "admin4@l2.org": [ { "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, + "library": str(library2.short_name), } ], "admin5@l2.org": [ { "role": AdminRole.LIBRARIAN, - "library": library2.short_name, + "library": str(library2.short_name), } ], "admin6@l2.org": [ @@ -90,177 +102,177 @@ def test_individual_admins_get(self, settings_ctrl_fixture): expected[admin["email"]], key=lambda x: x["role"] ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin2): + with flask_app_fixture.test_request_context("/", admin=admin2): # A sitewide librarian or library manager can also see all admins' roles. - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins: list[dict[str, str | list[dict[str, str]]]] = [ + { + "email": "admin2@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(db.default_library().short_name), + }, + {"role": AdminRole.SITEWIDE_LIBRARIAN}, + ], + }, + { + "email": "admin3@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(db.default_library().short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, - }, - {"role": AdminRole.SITEWIDE_LIBRARIAN}, - ], - }, - { - "email": "admin3@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin3): + with flask_app_fixture.test_request_context("/", admin=admin3): # A librarian cannot view this API anymore pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get, + controller.process_get, ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin4): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", admin=admin4): + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins = [ + { + "email": "admin2@nypl.org", + "roles": [{"role": AdminRole.SITEWIDE_LIBRARIAN}], + }, + { + "email": "admin4@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin5@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [{"role": AdminRole.SITEWIDE_LIBRARIAN}], - }, - { - "email": "admin4@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, - } - ], - }, - { - "email": "admin5@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": library2.short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin5): + with flask_app_fixture.test_request_context("/", admin=admin5): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get, + controller.process_get, ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin6): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", admin=admin6): + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins = [ + { + "email": "admin2@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(db.default_library().short_name), + }, + {"role": AdminRole.SITEWIDE_LIBRARIAN}, + ], + }, + { + "email": "admin3@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(db.default_library().short_name), + } + ], + }, + { + "email": "admin4@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin5@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, - }, - {"role": AdminRole.SITEWIDE_LIBRARIAN}, - ], - }, - { - "email": "admin3@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, - } - ], - }, - { - "email": "admin4@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, - } - ], - }, - { - "email": "admin5@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": library2.short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - def test_individual_admins_get_no_admin(self, settings_ctrl_fixture): + def test_individual_admins_get_no_admin( + self, + flask_app_fixture: FlaskAppFixture, + controller: IndividualAdminSettingsController, + ): # When the application is first started, there is no admin user. In that # case, we return a problem detail. - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="GET"): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", method="GET"): + response = controller.process_get() assert response == ADMIN_AUTH_NOT_CONFIGURED - def test_individual_admins_post_errors(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([]) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + def test_individual_admins_post_errors( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict([]) + response = controller.process_post() assert response.uri == INCOMPLETE_CONFIGURATION.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "test@library.org"), ("password", "334df3f70bfe1979"), @@ -272,14 +284,23 @@ def test_individual_admins_post_errors(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.uri == LIBRARY_NOT_FOUND.uri + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("email", "not-a-email"), + ("password", "334df3f70bfe1979"), + ] + ) + response = controller.process_post() + assert response.uri == INVALID_EMAIL.uri + assert '"not-a-email" is not a valid email address' in response.detail + library = db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "test@library.org"), ("password", "334df3f70bfe1979"), @@ -291,14 +312,15 @@ def test_individual_admins_post_errors(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.uri == UNKNOWN_ROLE.uri - def test_individual_admins_post_permissions(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admins_post_permissions( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): l1 = db.library() l2 = db.library() system, ignore = create(db.session, Admin, email="system@example.com") @@ -341,22 +363,22 @@ def test_individual_admins_post_permissions(self, settings_ctrl_fixture): def test_changing_roles( admin_making_request, target_admin, roles=None, allowed=False ): - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_making_request ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", target_admin.email), ("roles", json.dumps(roles or [])), ] ) if allowed: - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() + controller.process_post() db.session.rollback() else: pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) # Various types of user trying to change a system admin's roles @@ -420,10 +442,10 @@ def test_changing_roles( ) def test_changing_password(admin_making_request, target_admin, allowed=False): - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_making_request ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", target_admin.email), ("password", "new password"), @@ -434,12 +456,12 @@ def test_changing_password(admin_making_request, target_admin, allowed=False): ] ) if allowed: - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() + controller.process_post() db.session.rollback() else: pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) # Various types of user trying to change a system admin's password @@ -524,11 +546,14 @@ def test_changing_password(admin_making_request, target_admin, allowed=False): test_changing_password(manager1_2, sitewide_manager) test_changing_password(manager1_2, sitewide_librarian) - def test_individual_admins_post_create(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + def test_individual_admins_post_create( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "admin@nypl.org"), ("password", "pass"), @@ -545,13 +570,12 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 # The admin was created. admin_match = Admin.authenticate(db.session, "admin@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -561,10 +585,10 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): assert db.default_library() == role.library # The new admin is a library manager, so they can create librarians. - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_match ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "admin2@nypl.org"), ("password", "pass"), @@ -581,12 +605,11 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 admin_match = Admin.authenticate(db.session, "admin2@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -595,9 +618,12 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): assert AdminRole.LIBRARIAN == role.role assert db.default_library() == role.library - def test_individual_admins_post_edit(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admins_post_edit( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): # An admin exists. admin, ignore = create( db.session, @@ -607,8 +633,8 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): admin.password = "password" admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "admin@nypl.org"), ("password", "new password"), @@ -626,9 +652,7 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 assert admin.email == response.get_data(as_text=True) @@ -646,15 +670,18 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): # The roles were changed. assert False == admin.is_system_admin() - [librarian_all, manager] = sorted(admin.roles, key=lambda x: x.role) + [librarian_all, manager] = sorted(admin.roles, key=lambda x: str(x.role)) assert AdminRole.SITEWIDE_LIBRARIAN == librarian_all.role assert None == librarian_all.library assert AdminRole.LIBRARY_MANAGER == manager.role assert db.default_library() == manager.library - def test_individual_admin_delete(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admin_delete( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): librarian, ignore = create(db.session, Admin, email=db.fresh_str()) librarian.password = "password" librarian.add_role(AdminRole.LIBRARIAN, db.default_library()) @@ -665,35 +692,31 @@ def test_individual_admin_delete(self, settings_ctrl_fixture): system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=librarian ): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete, + controller.process_delete, librarian.email, ) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=sitewide_manager ): - response = settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete( - librarian.email - ) + response = controller.process_delete(librarian.email) assert response.status_code == 200 pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete, + controller.process_delete, system_admin.email, ) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=system_admin ): - response = settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete( - system_admin.email - ) + response = controller.process_delete(system_admin.email) assert response.status_code == 200 admin = get_one(db.session, Admin, id=librarian.id) @@ -702,15 +725,19 @@ def test_individual_admin_delete(self, settings_ctrl_fixture): admin = get_one(db.session, Admin, id=system_admin.id) assert None == admin - def test_individual_admins_post_create_not_system(self, settings_ctrl_fixture): + def test_individual_admins_post_create_not_system( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating an admin that's not a system admin will fail.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", "pass"), @@ -727,82 +754,85 @@ def test_individual_admins_post_create_not_system(self, settings_ctrl_fixture): ), ] ) - flask.request.files = {} + flask.request.files = ImmutableMultiDict() pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) def test_individual_admins_post_create_requires_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """The password is required.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code assert response.uri == INCOMPLETE_CONFIGURATION.uri def test_individual_admins_post_create_requires_non_empty_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """The password is required.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", ""), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code assert response.uri == INCOMPLETE_CONFIGURATION.uri - def test_individual_admins_post_create_on_setup(self, settings_ctrl_fixture): + def test_individual_admins_post_create_on_setup( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating a system admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", "pass"), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code # The admin was created. admin_match = Admin.authenticate(db.session, "first_admin@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -810,9 +840,13 @@ def test_individual_admins_post_create_on_setup(self, settings_ctrl_fixture): [role] = admin_match.roles assert AdminRole.SYSTEM_ADMIN == role.role - def test_individual_admins_post_create_second_admin(self, settings_ctrl_fixture): + def test_individual_admins_post_create_second_admin( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating a second admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -820,27 +854,27 @@ def test_individual_admins_post_create_second_admin(self, settings_ctrl_fixture) system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", "pass"), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code def test_individual_admins_post_create_second_admin_no_roles( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -848,23 +882,23 @@ def test_individual_admins_post_create_second_admin_no_roles( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [("email", "second_admin@nypl.org"), ("password", "pass")] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code def test_individual_admins_post_create_second_admin_no_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -872,26 +906,26 @@ def test_individual_admins_post_create_second_admin_no_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code def test_individual_admins_post_create_second_admin_empty_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -899,27 +933,27 @@ def test_individual_admins_post_create_second_admin_empty_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", ""), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code def test_individual_admins_post_create_second_admin_blank_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -927,18 +961,16 @@ def test_individual_admins_post_create_second_admin_blank_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", " "), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code From 9e1fc487b91857f4c2a1144ad9fb9612ac3ed714 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 30 Jan 2024 11:29:52 -0400 Subject: [PATCH 27/33] Clean up dead code in self tests (PP-893) (#1633) * Clean up dead code in self tests Remove the now dead HasSelfTests class. Rename HasSelfTestsIntegrationConfiguration to HasSelfTests since it has replaced the HasSelfTests class. Roll up the BaseHasSelfTests class, since its no longer needed. Update the tests to make sure they cover the new HasSelfTests classes. * Codereview feedback: Fix comment. --- api/admin/controller/collection_settings.py | 4 +- api/admin/controller/integration_settings.py | 4 +- api/admin/controller/metadata_services.py | 4 +- api/authentication/base.py | 4 +- api/metadata/nyt.py | 4 +- api/selftest.py | 14 +- core/selftest.py | 90 ++-------- .../api/admin/controller/test_collections.py | 10 +- .../api/admin/controller/test_patron_auth.py | 6 +- tests/api/test_selftest.py | 12 +- tests/core/test_selftest.py | 168 ++++++++---------- 11 files changed, 113 insertions(+), 207 deletions(-) diff --git a/api/admin/controller/collection_settings.py b/api/admin/controller/collection_settings.py index fdb74b4f4..61dbe5e35 100644 --- a/api/admin/controller/collection_settings.py +++ b/api/admin/controller/collection_settings.py @@ -30,7 +30,7 @@ json_serializer, site_configuration_has_changed, ) -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail, ProblemError @@ -180,7 +180,7 @@ def run_self_tests( self, integration: IntegrationConfiguration ) -> dict[str, Any] | None: protocol_class = self.get_protocol_class(integration.protocol) - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + if issubclass(protocol_class, HasSelfTests): test_result, _ = protocol_class.run_self_tests( self._db, protocol_class, self._db, integration.collection ) diff --git a/api/admin/controller/integration_settings.py b/api/admin/controller/integration_settings.py index fa4f53392..291db556f 100644 --- a/api/admin/controller/integration_settings.py +++ b/api/admin/controller/integration_settings.py @@ -36,7 +36,7 @@ json_serializer, ) from core.problem_details import INTERNAL_SERVER_ERROR, INVALID_INPUT -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.cache import memoize from core.util.log import LoggerMixin from core.util.problem_detail import ProblemDetail, ProblemError @@ -489,7 +489,7 @@ def get_prior_test_results( self test results dict that gets returned to the admin UI. """ protocol_class = self.get_protocol_class(integration.protocol) - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + if issubclass(protocol_class, HasSelfTests): self_test_results = protocol_class.load_self_test_results(integration) # type: ignore[unreachable] else: self_test_results = dict( diff --git a/api/admin/controller/metadata_services.py b/api/admin/controller/metadata_services.py index 19e6f43dd..cb4085056 100644 --- a/api/admin/controller/metadata_services.py +++ b/api/admin/controller/metadata_services.py @@ -19,7 +19,7 @@ json_serializer, site_configuration_has_changed, ) -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail, ProblemError @@ -100,7 +100,7 @@ def run_self_tests( self, integration: IntegrationConfiguration ) -> dict[str, Any] | None: protocol_class = self.get_protocol_class(integration.protocol) - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): + if issubclass(protocol_class, HasSelfTests): settings = protocol_class.settings_load(integration) test_result, _ = protocol_class.run_self_tests( self._db, protocol_class, self._db, settings diff --git a/api/authentication/base.py b/api/authentication/base.py index 913fd8676..405fb3a32 100644 --- a/api/authentication/base.py +++ b/api/authentication/base.py @@ -13,7 +13,7 @@ from core.model import CirculationEvent, Library, Patron, get_one_or_create from core.model.hybrid import hybrid_property from core.model.integration import IntegrationConfiguration -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.authentication_for_opds import OPDSAuthenticationFlow from core.util.datetime_helpers import utc_now from core.util.log import LoggerMixin @@ -37,7 +37,7 @@ class AuthProviderLibrarySettings(BaseSettings): class AuthenticationProvider( OPDSAuthenticationFlow, HasLibraryIntegrationConfiguration[SettingsType, LibrarySettingsType], - HasSelfTestsIntegrationConfiguration, + HasSelfTests, LoggerMixin, ABC, ): diff --git a/api/metadata/nyt.py b/api/metadata/nyt.py index ad74cd5f1..020ac08c4 100644 --- a/api/metadata/nyt.py +++ b/api/metadata/nyt.py @@ -1,6 +1,6 @@ from __future__ import annotations -from core.selftest import HasSelfTestsIntegrationConfiguration, SelfTestResult +from core.selftest import HasSelfTests, SelfTestResult """Interface to the New York Times APIs.""" import json @@ -83,7 +83,7 @@ def date_string(cls, d: date) -> str: class NYTBestSellerAPI( NYTAPI, MetadataService[NytBestSellerApiSettings], - HasSelfTestsIntegrationConfiguration, + HasSelfTests, LoggerMixin, ): BASE_URL = "http://api.nytimes.com/svc/books/v3/lists" diff --git a/api/selftest.py b/api/selftest.py index 8a5d4b048..e1e562449 100644 --- a/api/selftest.py +++ b/api/selftest.py @@ -9,13 +9,11 @@ from core.exceptions import BaseError from core.model import Collection, Library, LicensePool, Patron from core.model.integration import IntegrationConfiguration -from core.selftest import BaseHasSelfTests -from core.selftest import HasSelfTests as CoreHasSelfTests -from core.selftest import HasSelfTestsIntegrationConfiguration, SelfTestResult +from core.selftest import HasSelfTests, SelfTestResult from core.util.problem_detail import ProblemDetail -class HasPatronSelfTests(BaseHasSelfTests, ABC): +class HasPatronSelfTests(HasSelfTests, ABC): """Circulation-specific enhancements for HasSelfTests. Circulation self-tests frequently need to test the ability to act @@ -116,13 +114,7 @@ def _determine_self_test_patron( raise cls._NoValidLibrarySelfTestPatron(message, detail=detail) -class HasSelfTests(CoreHasSelfTests, HasPatronSelfTests): - """Circulation specific self-tests, with the external integration paradigm""" - - -class HasCollectionSelfTests( - HasSelfTestsIntegrationConfiguration, HasPatronSelfTests, ABC -): +class HasCollectionSelfTests(HasPatronSelfTests, ABC): """Extra tests to verify the integrity of imported collections of books. diff --git a/core/selftest.py b/core/selftest.py index 246cb1152..cd9d86a8b 100644 --- a/core/selftest.py +++ b/core/selftest.py @@ -2,8 +2,6 @@ """ from __future__ import annotations -import json -import logging import sys import traceback from abc import ABC, abstractmethod @@ -13,7 +11,7 @@ from sqlalchemy.orm import Session -from core.model import Collection, ExternalIntegration +from core.model import Collection from core.model.integration import IntegrationConfiguration from core.util.datetime_helpers import utc_now from core.util.http import IntegrationException @@ -136,7 +134,7 @@ def debug_message(self) -> str | None: P = ParamSpec("P") -class BaseHasSelfTests(ABC): +class HasSelfTests(LoggerMixin, ABC): """An object capable of verifying its own setup by running a series of self-tests. """ @@ -267,82 +265,6 @@ def test_failure( result.exception = exception return result - @abstractmethod - def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: - """Run the self-tests. - - :return: A generator that yields SelfTestResult objects. - """ - ... - - @abstractmethod - def store_self_test_results( - self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] - ) -> None: - ... - - -class HasSelfTests(BaseHasSelfTests, ABC): - """An object capable of verifying its own setup by running a - series of self-tests. - """ - - # Self-test results are stored in a ConfigurationSetting with this name, - # associated with the appropriate ExternalIntegration. - SELF_TEST_RESULTS_SETTING = "self_test_results" - - def store_self_test_results( - self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] - ) -> None: - """Store the results of a self-test in the database.""" - integration = self.external_integration(_db) - - if integration is not None: - integration.setting(self.SELF_TEST_RESULTS_SETTING).value = json.dumps( - value - ) - - @classmethod - def prior_test_results( - cls: type[Self], - _db: Session, - constructor_method: Callable[..., Self] | None = None, - *args: Any, - **kwargs: Any, - ) -> dict[str, Any] | None | str: - """Retrieve the last set of test results from the database. - - The arguments here are the same as the arguments to run_self_tests. - """ - constructor_method = constructor_method or cls - instance = constructor_method(*args, **kwargs) - - integration = instance.external_integration(_db) - - if integration: - return ( - integration.setting(cls.SELF_TEST_RESULTS_SETTING).json_value - or "No results yet" - ) - - return None - - def external_integration(self, _db: Session) -> ExternalIntegration | None: - """Locate the ExternalIntegration associated with this object. - The status of the self-tests will be stored as a ConfigurationSetting - on this ExternalIntegration. - - By default, there is no way to get from an object to its - ExternalIntegration, and self-test status will not be stored. - """ - logger = logging.getLogger("Self-test system") - logger.error( - "No ExternalIntegration was found. Self-test results will not be stored." - ) - return None - - -class HasSelfTestsIntegrationConfiguration(BaseHasSelfTests, LoggerMixin, ABC): def store_self_test_results( self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] ) -> None: @@ -376,6 +298,14 @@ def load_self_test_results( return integration.self_test_results + @abstractmethod + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: + """Run the self-tests. + + :return: A generator that yields SelfTestResult objects. + """ + ... + @abstractmethod def integration(self, _db: Session) -> IntegrationConfiguration | None: ... diff --git a/tests/api/admin/controller/test_collections.py b/tests/api/admin/controller/test_collections.py index 08bc43e84..9ab737c46 100644 --- a/tests/api/admin/controller/test_collections.py +++ b/tests/api/admin/controller/test_collections.py @@ -27,7 +27,7 @@ from api.integration.registry.license_providers import LicenseProvidersRegistry from api.selftest import HasCollectionSelfTests from core.model import AdminRole, Collection, ExternalIntegration, get_one -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail, ProblemError from tests.api.mockapi.axis import MockAxis360API from tests.fixtures.database import DatabaseTransactionFixture @@ -809,9 +809,7 @@ def test_collection_self_tests_test_get( results=[], ) mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock - ) + monkeypatch.setattr(HasSelfTests, "load_self_test_results", mock) # Make sure that HasSelfTest.prior_test_results() was called and that # it is in the response's collection object. @@ -846,9 +844,7 @@ def test_collection_self_tests_failed_post( # This makes HasSelfTests.run_self_tests return no values self_test_results = (None, None) mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) + monkeypatch.setattr(HasSelfTests, "run_self_tests", mock) # Failed to run self tests assert collection.integration_configuration.id is not None diff --git a/tests/api/admin/controller/test_patron_auth.py b/tests/api/admin/controller/test_patron_auth.py index 05edc7ab5..e80fd7bfa 100644 --- a/tests/api/admin/controller/test_patron_auth.py +++ b/tests/api/admin/controller/test_patron_auth.py @@ -40,7 +40,7 @@ from core.model import Library, get_one from core.model.integration import IntegrationConfiguration from core.problem_details import INVALID_INPUT -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail from tests.fixtures.flask import FlaskAppFixture @@ -791,9 +791,7 @@ def test_patron_auth_self_tests_test_post( ): expected_results = ("value", "results") mock = MagicMock(return_value=expected_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) + monkeypatch.setattr(HasSelfTests, "run_self_tests", mock) library = db.default_library() auth_service, _ = create_simple_auth_integration(library=library) diff --git a/tests/api/test_selftest.py b/tests/api/test_selftest.py index b7eb79f42..a3acd2b40 100644 --- a/tests/api/test_selftest.py +++ b/tests/api/test_selftest.py @@ -11,18 +11,18 @@ from api.authentication.basic import BasicAuthenticationProvider from api.circulation import CirculationAPI -from api.selftest import HasCollectionSelfTests, HasSelfTests, SelfTestResult +from api.selftest import HasCollectionSelfTests, HasPatronSelfTests, SelfTestResult from core.exceptions import IntegrationException from core.model import Patron from core.scripts import RunSelfTestsScript from core.util.problem_detail import ProblemDetail +from tests.fixtures.authenticator import SimpleAuthIntegrationFixture if TYPE_CHECKING: - from tests.fixtures.authenticator import SimpleAuthIntegrationFixture from tests.fixtures.database import DatabaseTransactionFixture -class TestHasSelfTests: +class TestHasPatronSelfTests: def test__determine_self_test_patron( self, db: DatabaseTransactionFixture, @@ -35,8 +35,8 @@ def test__determine_self_test_patron( - raises the expected _NoValidLibrarySelfTestPatron exception. """ - test_patron_lookup_method = HasSelfTests._determine_self_test_patron - test_patron_lookup_exception = HasSelfTests._NoValidLibrarySelfTestPatron + test_patron_lookup_method = HasPatronSelfTests._determine_self_test_patron + test_patron_lookup_exception = HasPatronSelfTests._NoValidLibrarySelfTestPatron # This library has no patron authentication integration configured. library_without_default_patron = db.library() @@ -101,7 +101,7 @@ def test_default_patrons( default_patrons() method finds the default Patron for every Library associated with a given Collection. """ - h = HasSelfTests + h = HasPatronSelfTests # This collection is not in any libraries, so there's no way # to test it. diff --git a/tests/core/test_selftest.py b/tests/core/test_selftest.py index 49ea4cf14..7090fb880 100644 --- a/tests/core/test_selftest.py +++ b/tests/core/test_selftest.py @@ -7,11 +7,13 @@ import datetime from collections.abc import Generator -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec +from _pytest.monkeypatch import MonkeyPatch from sqlalchemy.orm import Session -from core.model import ExternalIntegration +from core.integration.goals import Goals +from core.model import IntegrationConfiguration from core.selftest import HasSelfTests, SelfTestResult from core.util.datetime_helpers import utc_now from core.util.http import IntegrationException @@ -93,60 +95,53 @@ def test_repr_failure(self): class MockSelfTest(HasSelfTests): + _integration: IntegrationConfiguration | None = None + + def __init__(self, *args, **kwargs): + self.called_with_args = args + self.called_with_kwargs = kwargs + + def integration(self, _db: Session) -> IntegrationConfiguration | None: + return self._integration + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: - raise Exception("I don't work!") + raise Exception("oh no") class TestHasSelfTests: - def test_run_self_tests(self, db: DatabaseTransactionFixture): + def test_run_self_tests( + self, db: DatabaseTransactionFixture, monkeypatch: MonkeyPatch + ): """See what might happen when run_self_tests tries to instantiate an object and run its self-tests. """ - - class Tester(HasSelfTests): - integration: ExternalIntegration | None - - def __init__(self, extra_arg=None): - """This constructor works.""" - self.invoked_with = extra_arg - - @classmethod - def good_alternate_constructor(self, another_extra_arg=None): - """This alternate constructor works.""" - tester = Tester() - tester.another_extra_arg = another_extra_arg - return tester - - @classmethod - def bad_alternate_constructor(self): - """This constructor doesn't work.""" - raise Exception("I don't work!") - - def external_integration(self, _db): - """This integration will be used to store the test results.""" - return self.integration - - def _run_self_tests(self, _db): - self._run_self_tests_called_with = _db - return [SelfTestResult("a test result")] - mock_db = MagicMock(spec=Session) # This integration will be used to store the test results. - integration = db.external_integration(db.fresh_str()) - Tester.integration = integration + integration = db.integration_configuration( + protocol="test", goal=Goals.PATRON_AUTH_GOAL + ) # By default, the default constructor is instantiated and its # _run_self_tests method is called. - data, [setup, test] = Tester.run_self_tests(mock_db, extra_arg="a value") - assert mock_db == setup.result._run_self_tests_called_with + mock__run_self_tests = create_autospec(MockSelfTest._run_self_tests) + mock__run_self_tests.return_value = [SelfTestResult("a test result")] + monkeypatch.setattr(MockSelfTest, "_run_self_tests", mock__run_self_tests) + monkeypatch.setattr(MockSelfTest, "_integration", integration) + + data, [setup, test] = MockSelfTest.run_self_tests(mock_db, extra_arg="a value") + assert mock__run_self_tests.call_count == 1 + assert isinstance(mock__run_self_tests.call_args.args[0], MockSelfTest) + assert mock__run_self_tests.call_args.args[1] == mock_db # There are two results -- `setup` from the initial setup # and `test` from the _run_self_tests call. - assert "Initial setup." == setup.name - assert True == setup.success - assert "a value" == setup.result.invoked_with - assert "a test result" == test.name + assert setup.name == "Initial setup." + assert setup.success is True + assert isinstance(setup.result, MockSelfTest) + assert setup.result.called_with_args == () + assert setup.result.called_with_kwargs == dict(extra_arg="a value") + assert test.name == "a test result" # The `data` variable contains a dictionary describing the test # suite as a whole. @@ -161,119 +156,114 @@ def _run_self_tests(self, _db): assert r2 == test.to_dict # A JSON version of `data` is stored in the - # ExternalIntegration returned by the external_integration() + # Integration returned by the integration() # method. - [result_setting] = integration.settings - assert HasSelfTests.SELF_TEST_RESULTS_SETTING == result_setting.key - assert data == result_setting.json_value + assert integration.self_test_results == data # Remove the testing integration to show what happens when # HasSelfTests doesn't support the storage of test results. - Tester.integration = None - result_setting.value = "this value will not be changed" + monkeypatch.setattr(MockSelfTest, "_integration", None) # You can specify a different class method to use as the # constructor. Once the object is instantiated, the same basic # code runs. - data, [setup, test] = Tester.run_self_tests( + integration.self_test_results = "this value will not be changed" + data, [setup, test] = MockSelfTest.run_self_tests( mock_db, - Tester.good_alternate_constructor, - another_extra_arg="another value", + lambda **kwargs: MockSelfTest(extra_extra_arg="foo", **kwargs), + extra_arg="a value", ) assert "Initial setup." == setup.name - assert True == setup.success - assert None == setup.result.invoked_with - assert "another value" == setup.result.another_extra_arg + assert setup.success is True + assert setup.result.called_with_args == () + assert setup.result.called_with_kwargs == dict( + extra_extra_arg="foo", extra_arg="a value" + ) assert "a test result" == test.name # Since the HasSelfTests object no longer has an associated - # ExternalIntegration, the test results are not persisted + # Integration, the test results are not persisted # anywhere. - assert "this value will not be changed" == result_setting.value + assert integration.self_test_results == "this value will not be changed" # If there's an exception in the constructor, the result is a # single SelfTestResult describing that failure. Since there is # no instance, _run_self_tests can't be called. - data, [result] = Tester.run_self_tests( + data, [result] = MockSelfTest.run_self_tests( mock_db, - Tester.bad_alternate_constructor, + MagicMock(side_effect=Exception("I don't work!")), ) assert isinstance(result, SelfTestResult) - assert False == result.success - assert "I don't work!" == str(result.exception) + assert result.success is False + assert str(result.exception) == "I don't work!" def test_exception_in_has_self_tests(self): """An exception raised in has_self_tests itself is converted into a test failure. """ - class Tester(HasSelfTests): - def _run_self_tests(self, _db): - yield SelfTestResult("everything's ok so far") - raise Exception("oh no") - yield SelfTestResult("i'll never be called.") + status, [init, failure] = MockSelfTest.run_self_tests(MagicMock()) + assert init.name == "Initial setup." - status, [init, success, failure] = Tester.run_self_tests(object()) - assert "Initial setup." == init.name - assert "everything's ok so far" == success.name - - assert "Uncaught exception in the self-test method itself." == failure.name - assert False == failure.success + assert failure.name == "Uncaught exception in the self-test method itself." + assert failure.success is False # The Exception was turned into an IntegrationException so that # its traceback could be included as debug_message. assert isinstance(failure.exception, IntegrationException) - assert "oh no" == str(failure.exception) + assert str(failure.exception) == "oh no" assert failure.exception.debug_message.startswith("Traceback") def test_run_test_success(self): - o = MockSelfTest() + mock = MockSelfTest() # This self-test method will succeed. def successful_test(arg, kwarg): return arg, kwarg - result = o.run_test("A successful test", successful_test, "arg1", kwarg="arg2") - assert True == result.success - assert "A successful test" == result.name - assert ("arg1", "arg2") == result.result + result = mock.run_test( + "A successful test", successful_test, "arg1", kwarg="arg2" + ) + assert result.success is True + assert result.name == "A successful test" + assert result.result == ("arg1", "arg2") assert (result.end - result.start).total_seconds() < 1 def test_run_test_failure(self): - o = MockSelfTest() + mock = MockSelfTest() # This self-test method will fail. def unsuccessful_test(arg, kwarg): raise IntegrationException(arg, kwarg) - result = o.run_test( + result = mock.run_test( "An unsuccessful test", unsuccessful_test, "arg1", kwarg="arg2" ) - assert False == result.success - assert "An unsuccessful test" == result.name - assert None == result.result - assert "arg1" == str(result.exception) - assert "arg2" == result.exception.debug_message + assert result.success is False + assert result.name == "An unsuccessful test" + assert result.result is None + assert str(result.exception) == "arg1" + assert result.exception.debug_message == "arg2" assert (result.end - result.start).total_seconds() < 1 def test_test_failure(self): - o = MockSelfTest() + mock = MockSelfTest() # You can pass in an Exception... exception = Exception("argh") now = utc_now() - result = o.test_failure("a failure", exception) + result = mock.test_failure("a failure", exception) # ...which will be turned into an IntegrationException. - assert "a failure" == result.name + assert result.name == "a failure" assert isinstance(result.exception, IntegrationException) - assert "argh" == str(result.exception) + assert str(result.exception) == "argh" assert (result.start - now).total_seconds() < 1 # ... or you can pass in arguments to an IntegrationException - result = o.test_failure("another failure", "message", "debug") + result = mock.test_failure("another failure", "message", "debug") assert isinstance(result.exception, IntegrationException) - assert "message" == str(result.exception) - assert "debug" == result.exception.debug_message + assert str(result.exception) == "message" + assert result.exception.debug_message == "debug" # Since no test code actually ran, the end time is the # same as the start time. From 24c3461619a7b1caf452bdce2e54029ecca0e06f Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 30 Jan 2024 11:30:32 -0400 Subject: [PATCH 28/33] Remove SettingsController from AnnouncementSettings controller (PP-893) (#1636) * Remove SettingsController from AnnouncementSettings * Fix initialization --- api/admin/controller/__init__.py | 2 +- api/admin/controller/announcement_service.py | 7 ++- .../controller/test_announcement_service.py | 54 ++++++++++--------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 1ea4dbb71..62b814d73 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -66,6 +66,6 @@ def setup_admin_controllers(manager: CirculationManager): IndividualAdminSettingsController(manager._db) ) manager.admin_catalog_services_controller = CatalogServicesController(manager) - manager.admin_announcement_service = AnnouncementSettings(manager) + manager.admin_announcement_service = AnnouncementSettings(manager._db) manager.admin_search_controller = AdminSearchController(manager) manager.admin_quicksight_controller = QuickSightController(manager) diff --git a/api/admin/controller/announcement_service.py b/api/admin/controller/announcement_service.py index 8ec18ac0a..78c6db022 100644 --- a/api/admin/controller/announcement_service.py +++ b/api/admin/controller/announcement_service.py @@ -4,18 +4,21 @@ from typing import Any import flask +from sqlalchemy.orm import Session from api.admin.announcement_list_validator import AnnouncementListValidator -from api.admin.controller.settings import SettingsController from api.config import Configuration from core.model.announcements import Announcement from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail, ProblemError -class AnnouncementSettings(SettingsController): +class AnnouncementSettings: """Controller that manages global announcements for all libraries""" + def __init__(self, db: Session) -> None: + self._db = db + def _action(self) -> Callable: method = flask.request.method.lower() return getattr(self, method) diff --git a/tests/api/admin/controller/test_announcement_service.py b/tests/api/admin/controller/test_announcement_service.py index fb78b8053..108675b5b 100644 --- a/tests/api/admin/controller/test_announcement_service.py +++ b/tests/api/admin/controller/test_announcement_service.py @@ -1,23 +1,25 @@ import json import uuid -from werkzeug.datastructures import MultiDict +from werkzeug.datastructures import ImmutableMultiDict from api.admin.controller.announcement_service import AnnouncementSettings from core.model.announcements import Announcement, AnnouncementData from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail from tests.fixtures.announcements import AnnouncementFixture -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture class TestAnnouncementService: def test_get( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): - session = admin_ctrl_fixture.ctrl.db.session + session = db.session a1 = announcement_fixture.active_announcement(session) a2 = announcement_fixture.expired_announcement(session) a3 = announcement_fixture.forthcoming_announcement(session) @@ -34,8 +36,8 @@ def test_get( session.execute(Announcement.global_announcements()).scalars().all() ) - with admin_ctrl_fixture.request_context_with_admin("/", method="GET") as ctx: - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + with flask_app_fixture.test_request_context("/", method="GET"): + response = AnnouncementSettings(db.session).process_many() assert isinstance(response, dict) assert set(response.keys()) == {"settings", "announcements"} @@ -56,23 +58,24 @@ def test_get( def test_post( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: + with flask_app_fixture.test_request_context("/", method="POST") as ctx: data = AnnouncementData( id=uuid.uuid4(), start=announcement_fixture.yesterday, finish=announcement_fixture.tomorrow, content="This is a test announcement.", ) - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([data.as_dict()]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert response == {"success": True} - session = admin_ctrl_fixture.ctrl.db.session + session = db.session announcements = ( session.execute(Announcement.global_announcements()).scalars().all() ) @@ -84,15 +87,16 @@ def test_post( def test_post_edit( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): # Two existing announcements. - session = admin_ctrl_fixture.ctrl.db.session + session = db.session a1 = announcement_fixture.active_announcement(session) a2 = announcement_fixture.active_announcement(session) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: + with flask_app_fixture.test_request_context("/", method="POST") as ctx: # a1 is edited, a2 is deleted, a3 is added. a1_edited = a1.to_data() a1_edited.content = "This is an edited announcement." @@ -102,10 +106,10 @@ def test_post_edit( finish=announcement_fixture.tomorrow, content="This is new test announcement.", ) - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([a1_edited.as_dict(), a3.as_dict()]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert response == {"success": True} announcements = ( @@ -120,21 +124,21 @@ def test_post_edit( def test_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, - announcement_fixture: AnnouncementFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: - ctx.request.form = None - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + with flask_app_fixture.test_request_context("/", method="POST") as ctx: + ctx.request.form = ImmutableMultiDict() + response = AnnouncementSettings(db.session).process_many() assert response == INVALID_INPUT - ctx.request.form = MultiDict([("somethingelse", json.dumps([]))]) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + ctx.request.form = ImmutableMultiDict([("somethingelse", json.dumps([]))]) + response = AnnouncementSettings(db.session).process_many() assert response == INVALID_INPUT - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([{"id": str(uuid.uuid4())}]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert isinstance(response, ProblemDetail) assert "Missing required field: content" == response.detail From 409865ae12f8ca5ce896598fff38d0e94182dce3 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 30 Jan 2024 13:26:55 -0400 Subject: [PATCH 29/33] Don't pre-load our configuration settings table cache anymore. (#1641) --- api/circulation_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/circulation_manager.py b/api/circulation_manager.py index 1ed139391..77ed322ad 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -186,7 +186,6 @@ def load_settings(self): ): # Populate caches Library.cache_warm(self._db, lambda: libraries) - ConfigurationSetting.cache_warm(self._db) self.auth = Authenticator(self._db, libraries, self.analytics) From cd2a2503a2f9fa41a1cdef6c9c0ce207994f13ae Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 30 Jan 2024 13:27:50 -0400 Subject: [PATCH 30/33] =?UTF-8?q?Remove=20first=20book=20auth=20API=20(PP-?= =?UTF-8?q?903)=20=F0=9F=94=A5=20(#1642)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove first book auth API. * Update test --- api/admin/routes.py | 16 +- api/circulation_manager.py | 3 - api/controller/static_file.py | 26 +- api/firstbook2.py | 175 ------------ api/integration/registry/patron_auth.py | 2 - resources/OpenSans-Bold.ttf | Bin 224592 -> 0 bytes resources/OpenSans-Regular.ttf | Bin 217360 -> 0 bytes resources/images/FirstBookLoginButton280.png | Bin 9007 -> 0 bytes .../api/admin/controller/test_patron_auth.py | 2 +- tests/api/admin/test_routes.py | 32 --- tests/api/controller/test_staticfile.py | 46 +-- tests/api/test_firstbook2.py | 267 ------------------ 12 files changed, 27 insertions(+), 542 deletions(-) delete mode 100644 api/firstbook2.py delete mode 100755 resources/OpenSans-Bold.ttf delete mode 100755 resources/OpenSans-Regular.ttf delete mode 100644 resources/images/FirstBookLoginButton280.png delete mode 100644 tests/api/test_firstbook2.py diff --git a/api/admin/routes.py b/api/admin/routes.py index ca98eff30..a30f59392 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -8,6 +8,7 @@ from flask_pydantic_spec import Response as SpecResponse from api.admin.config import Configuration as AdminClientConfig +from api.admin.config import OperationalMode from api.admin.controller.custom_lists import CustomListsController from api.admin.dashboard_stats import generate_statistics from api.admin.model.dashboard_statistics import StatisticsResponse @@ -18,6 +19,7 @@ ) from api.admin.templates import admin_sign_in_again as sign_in_again_template from api.app import api_spec, app +from api.controller.static_file import StaticFileController from api.routes import allows_library, has_library, library_route from core.app_server import ensure_pydantic_after_problem_detail, returns_problem_detail from core.util.problem_detail import ProblemDetail, ProblemDetailModel, ProblemError @@ -748,9 +750,11 @@ def admin_base(**kwargs): # This path is used only in debug mode to serve frontend assets. -@app.route("/admin/static/") -@returns_problem_detail -def admin_static_file(filename): - return app.manager.static_files.static_file( - AdminClientConfig.static_files_directory(), filename - ) +if AdminClientConfig.operational_mode() == OperationalMode.development: + + @app.route("/admin/static/") + @returns_problem_detail + def admin_static_file(filename): + return StaticFileController.static_file( + AdminClientConfig.static_files_directory(), filename + ) diff --git a/api/circulation_manager.py b/api/circulation_manager.py index 77ed322ad..cb67cc402 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -23,7 +23,6 @@ from api.controller.patron_auth_token import PatronAuthTokenController from api.controller.playtime_entries import PlaytimeEntriesController from api.controller.profile import ProfileController -from api.controller.static_file import StaticFileController from api.controller.urn_lookup import URNLookupController from api.controller.work import WorkController from api.custom_index import CustomIndexView @@ -87,7 +86,6 @@ class CirculationManager(LoggerMixin): patron_devices: DeviceTokensController version: ApplicationVersionController odl_notification_controller: ODLNotificationController - static_files: StaticFileController playtime_entries: PlaytimeEntriesController # Admin controllers @@ -288,7 +286,6 @@ def setup_one_time_controllers(self): self.patron_devices = DeviceTokensController(self) self.version = ApplicationVersionController() self.odl_notification_controller = ODLNotificationController(self) - self.static_files = StaticFileController(self) self.patron_auth_token = PatronAuthTokenController(self) self.playtime_entries = PlaytimeEntriesController(self) diff --git a/api/controller/static_file.py b/api/controller/static_file.py index 4016f0966..2b446ac04 100644 --- a/api/controller/static_file.py +++ b/api/controller/static_file.py @@ -1,27 +1,9 @@ from __future__ import annotations -import os - import flask -from api.config import Configuration -from api.controller.circulation_manager import CirculationManagerController -from core.model import ConfigurationSetting - - -class StaticFileController(CirculationManagerController): - def static_file(self, directory, filename): - max_age = ConfigurationSetting.sitewide( - self._db, Configuration.STATIC_FILE_CACHE_TIME - ).int_value - return flask.send_from_directory(directory, filename, max_age=max_age) - def image(self, filename): - directory = os.path.join( - os.path.abspath(os.path.dirname(__file__)), - "..", - "..", - "resources", - "images", - ) - return self.static_file(directory, filename) +class StaticFileController: + @staticmethod + def static_file(directory, filename): + return flask.send_from_directory(directory, filename) diff --git a/api/firstbook2.py b/api/firstbook2.py deleted file mode 100644 index 22a3870f7..000000000 --- a/api/firstbook2.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -import re -import time -from re import Pattern - -import jwt -import requests -from flask_babel import lazy_gettext as _ -from pydantic import HttpUrl - -from api.authentication.base import PatronData -from api.authentication.basic import ( - BasicAuthenticationProvider, - BasicAuthProviderLibrarySettings, - BasicAuthProviderSettings, -) -from api.circulation_exceptions import RemoteInitiatedServerError -from core.integration.settings import ConfigurationFormItem, FormField -from core.model import Patron - - -class FirstBookAuthSettings(BasicAuthProviderSettings): - url: HttpUrl = FormField( - "https://ebooksprod.firstbook.org/api/", - form=ConfigurationFormItem( - label=_("URL"), - description=_("The URL for the First Book authentication service."), - required=True, - ), - ) - password: str = FormField( - ..., - form=ConfigurationFormItem( - label=_("Key"), - description=_("The key for the First Book authentication service."), - ), - ) - # Server-side validation happens before the identifier - # is converted to uppercase, which means lowercase characters - # are valid. - identifier_regular_expression: Pattern = FormField( - re.compile(r"^[A-Za-z0-9@]+$"), - form=ConfigurationFormItem( - label="Identifier Regular Expression", - description="A patron's identifier will be immediately rejected if it doesn't match this " - "regular expression.", - weight=10, - ), - ) - password_regular_expression: Pattern | None = FormField( - re.compile(r"^[0-9]+$"), - form=ConfigurationFormItem( - label="Password Regular Expression", - description="A patron's password will be immediately rejected if it doesn't match this " - "regular expression.", - weight=10, - ), - ) - - -class FirstBookAuthenticationAPI( - BasicAuthenticationProvider[FirstBookAuthSettings, BasicAuthProviderLibrarySettings] -): - @classmethod - def label(cls) -> str: - return "First Book" - - @classmethod - def description(cls) -> str: - return ( - "An authentication service for Open eBooks that authenticates using access codes and " - "PINs. (This is the new version.)" - ) - - @classmethod - def settings_class(cls) -> type[FirstBookAuthSettings]: - return FirstBookAuthSettings - - @classmethod - def library_settings_class(cls) -> type[BasicAuthProviderLibrarySettings]: - return BasicAuthProviderLibrarySettings - - @property - def login_button_image(self) -> str | None: - return "FirstBookLoginButton280.png" - - # The algorithm used to sign JWTs. - ALGORITHM = "HS256" - - # If FirstBook sends this message it means they accepted the - # patron's credentials. - SUCCESS_MESSAGE = "Valid Code Pin Pair" - - def __init__( - self, - library_id: int, - integration_id: int, - settings: FirstBookAuthSettings, - library_settings: BasicAuthProviderLibrarySettings, - analytics=None, - ): - super().__init__( - library_id, integration_id, settings, library_settings, analytics - ) - self.root = settings.url - self.secret = settings.password - - def remote_authenticate( - self, username: str | None, password: str | None - ) -> PatronData | None: - # All FirstBook credentials are in upper-case. - if username is None or username == "": - return None - - username = username.upper() - - # If they fail a PIN test, there is no authenticated patron. - if not self.remote_pin_test(username, password): - return None - - # FirstBook keeps track of absolutely no information - # about the patron other than the permanent ID, - # which is also the authorization identifier. - return PatronData( - permanent_id=username, - authorization_identifier=username, - ) - - def remote_patron_lookup( - self, patron_or_patrondata: PatronData | Patron - ) -> PatronData | None: - if isinstance(patron_or_patrondata, PatronData): - return patron_or_patrondata - - return None - - # End implementation of BasicAuthenticationProvider abstract methods. - - def remote_pin_test(self, barcode, pin): - jwt = self.jwt(barcode, pin) - url = self.root + jwt - try: - response = self.request(url) - except requests.exceptions.ConnectionError as e: - raise RemoteInitiatedServerError(str(e), self.__class__.__name__) - content = response.content.decode("utf8") - if response.status_code != 200: - msg = "Got unexpected response code %d. Content: %s" % ( - response.status_code, - content, - ) - raise RemoteInitiatedServerError(msg, self.__class__.__name__) - if self.SUCCESS_MESSAGE in content: - return True - return False - - def jwt(self, barcode, pin): - """Create and sign a JWT with the payload expected by the - First Book API. - """ - now = int(time.time()) - payload = dict( - barcode=barcode, - pin=pin, - iat=now, - ) - return jwt.encode(payload, self.secret, algorithm=self.ALGORITHM) - - def request(self, url): - """Make an HTTP request. - - Defined solely so it can be overridden in the mock. - """ - return requests.get(url) diff --git a/api/integration/registry/patron_auth.py b/api/integration/registry/patron_auth.py index 80cb1cd5f..f57f84d44 100644 --- a/api/integration/registry/patron_auth.py +++ b/api/integration/registry/patron_auth.py @@ -12,7 +12,6 @@ class PatronAuthRegistry(IntegrationRegistry["AuthenticationProviderType"]): def __init__(self) -> None: super().__init__(Goals.PATRON_AUTH_GOAL) - from api.firstbook2 import FirstBookAuthenticationAPI from api.kansas_patron import KansasAuthenticationAPI from api.millenium_patron import MilleniumPatronAPI from api.saml.provider import SAMLWebSSOAuthenticationProvider @@ -27,7 +26,6 @@ def __init__(self) -> None: ) self.register(MilleniumPatronAPI, canonical="api.millenium_patron") self.register(SIP2AuthenticationProvider, canonical="api.sip") - self.register(FirstBookAuthenticationAPI, canonical="api.firstbook2") self.register(KansasAuthenticationAPI, canonical="api.kansas_patron") self.register(SAMLWebSSOAuthenticationProvider, canonical="api.saml.provider") self.register( diff --git a/resources/OpenSans-Bold.ttf b/resources/OpenSans-Bold.ttf deleted file mode 100755 index fd79d43bea0293ac1b20e8aca1142627983d2c07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224592 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 diff --git a/resources/OpenSans-Regular.ttf b/resources/OpenSans-Regular.ttf deleted file mode 100755 index db433349b7047f72f40072630c1bc110620bf09e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 217360 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 !}#gv~NV!5|5YF|kE)J0y^zWbk4>=%|6F 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 diff --git a/resources/images/FirstBookLoginButton280.png b/resources/images/FirstBookLoginButton280.png deleted file mode 100644 index 54d1d0591f9df15af479cc9264ed7f0afe95db71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9007 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 I+C1d{0XAIivH$=8 diff --git a/tests/api/admin/controller/test_patron_auth.py b/tests/api/admin/controller/test_patron_auth.py index e80fd7bfa..4ab97b7b2 100644 --- a/tests/api/admin/controller/test_patron_auth.py +++ b/tests/api/admin/controller/test_patron_auth.py @@ -87,7 +87,7 @@ def test_patron_auth_services_get_with_no_services( assert response_data.get("patron_auth_services") == [] protocols = response_data.get("protocols") assert isinstance(protocols, list) - assert 7 == len(protocols) + assert 6 == len(protocols) assert "settings" in protocols[0] assert "library_settings" in protocols[0] diff --git a/tests/api/admin/test_routes.py b/tests/api/admin/test_routes.py index 44e5a289e..d5f3cd045 100644 --- a/tests/api/admin/test_routes.py +++ b/tests/api/admin/test_routes.py @@ -1,6 +1,5 @@ import logging from collections.abc import Generator -from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -802,37 +801,6 @@ def test_admin_view(self, fixture: AdminRouteFixture): fixture.assert_request_calls(url, fixture.controller, None, None, path="a/path") -class TestAdminStatic: - CONTROLLER_NAME = "static_files" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_static_file(self, fixture: AdminRouteFixture): - # Go to the back to the root folder to get the right - # path for the static files. - root_path = Path(__file__).parent.parent.parent.parent - local_path = ( - root_path - / "api/admin/node_modules/@thepalaceproject/circulation-admin/dist" - ) - - url = "/admin/static/circulation-admin.js" - fixture.assert_request_calls( - url, fixture.controller.static_file, str(local_path), "circulation-admin.js" # type: ignore - ) - - url = "/admin/static/circulation-admin.css" - fixture.assert_request_calls( - url, - fixture.controller.static_file, # type: ignore - str(local_path), - "circulation-admin.css", - ) - - def test_returns_json_or_response_or_problem_detail(): @routes.returns_json_or_response_or_problem_detail def mock_responses(response): diff --git a/tests/api/controller/test_staticfile.py b/tests/api/controller/test_staticfile.py index e61f1aca6..7916b6381 100644 --- a/tests/api/controller/test_staticfile.py +++ b/tests/api/controller/test_staticfile.py @@ -1,54 +1,32 @@ +from __future__ import annotations + import pytest from werkzeug.exceptions import NotFound -from api.config import Configuration -from core.model import ConfigurationSetting -from tests.fixtures.api_controller import CirculationControllerFixture +from api.controller.static_file import StaticFileController from tests.fixtures.api_images_files import ImageFilesFixture +from tests.fixtures.flask import FlaskAppFixture class TestStaticFileController: def test_static_file( self, - circulation_fixture: CirculationControllerFixture, api_image_files_fixture: ImageFilesFixture, + flask_app_fixture: FlaskAppFixture, ): files = api_image_files_fixture - cache_timeout = ConfigurationSetting.sitewide( - circulation_fixture.db.session, Configuration.STATIC_FILE_CACHE_TIME - ) - cache_timeout.value = 10 - expected_content = files.sample_data("blue.jpg") - with circulation_fixture.app.test_request_context("/"): - response = circulation_fixture.app.manager.static_files.static_file( - files.directory, "blue.jpg" - ) + with flask_app_fixture.test_request_context(): + response = StaticFileController.static_file(files.directory, "blue.jpg") - assert 200 == response.status_code - assert "public, max-age=10" == response.headers.get("Cache-Control") - assert expected_content == response.response.file.read() + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == "no-cache" + assert response.response.file.read() == expected_content - with circulation_fixture.app.test_request_context("/"): + with flask_app_fixture.test_request_context(): pytest.raises( NotFound, - circulation_fixture.app.manager.static_files.static_file, + StaticFileController.static_file, files.directory, "missing.png", ) - - def test_image( - self, - circulation_fixture: CirculationControllerFixture, - resources_files_fixture: ImageFilesFixture, - ): - files = resources_files_fixture - - filename = "FirstBookLoginButton280.png" - expected_content = files.sample_data(filename) - - with circulation_fixture.app.test_request_context("/"): - response = circulation_fixture.app.manager.static_files.image(filename) - - assert 200 == response.status_code - assert expected_content == response.response.file.read() diff --git a/tests/api/test_firstbook2.py b/tests/api/test_firstbook2.py deleted file mode 100644 index 432843de1..000000000 --- a/tests/api/test_firstbook2.py +++ /dev/null @@ -1,267 +0,0 @@ -import os -import time -import urllib.parse -from collections.abc import Callable -from functools import partial - -import jwt -import pytest -import requests - -from api.authentication.base import PatronData -from api.authentication.basic import BasicAuthProviderLibrarySettings -from api.circulation_exceptions import RemoteInitiatedServerError -from api.firstbook2 import FirstBookAuthenticationAPI, FirstBookAuthSettings -from tests.fixtures.database import DatabaseTransactionFixture - - -class MockFirstBookResponse: - def __init__(self, status_code, content): - self.status_code = status_code - # Guarantee that the response content is always a bytestring, - # as it would be in real life. - if isinstance(content, str): - content = content.encode("utf8") - self.content = content - - -class MockFirstBookAuthenticationAPI(FirstBookAuthenticationAPI): - SUCCESS = '"Valid Code Pin Pair"' - FAILURE = '{"code":404,"message":"Access Code Pin Pair not found"}' - - def __init__( - self, - library_id, - integration_id, - settings, - library_settings, - valid=None, - bad_connection=False, - failure_status_code=None, - ): - super().__init__(library_id, integration_id, settings, library_settings, None) - - if valid is None: - valid = {} - self.valid = valid - self.bad_connection = bad_connection - self.failure_status_code = failure_status_code - - self.request_urls = [] - - def request(self, url): - self.request_urls.append(url) - if self.bad_connection: - # Simulate a bad connection. - raise requests.exceptions.ConnectionError("Could not connect!") - elif self.failure_status_code: - # Simulate a server returning an unexpected error code. - return MockFirstBookResponse( - self.failure_status_code, "Error %s" % self.failure_status_code - ) - parsed = urllib.parse.urlparse(url) - token = parsed.path.split("/")[-1] - barcode, pin = self._decode(token) - - # The barcode and pin must be present in self.valid. - if barcode in self.valid and self.valid[barcode] == pin: - return MockFirstBookResponse(200, self.SUCCESS) - else: - return MockFirstBookResponse(200, self.FAILURE) - - def _decode(self, token): - # Decode a JWT. Only used in tests -- in production, this is - # First Book's job. - - # The JWT must be signed with the shared secret. - payload = jwt.decode(token, self.secret, algorithms=self.ALGORITHM) - - # The 'iat' field in the payload must be a recent timestamp. - assert (time.time() - int(payload["iat"])) < 2 - - return payload["barcode"], payload["pin"] - - -@pytest.fixture -def mock_library_id() -> int: - return 20 - - -@pytest.fixture -def mock_integration_id() -> int: - return 20 - - -@pytest.fixture -def create_settings() -> Callable[..., FirstBookAuthSettings]: - return partial( - FirstBookAuthSettings, - url="http://example.com/", - password="secret", - ) - - -@pytest.fixture -def create_provider( - mock_library_id: int, - mock_integration_id: int, - create_settings: Callable[..., FirstBookAuthSettings], -) -> Callable[..., MockFirstBookAuthenticationAPI]: - return partial( - MockFirstBookAuthenticationAPI, - library_id=mock_library_id, - integration_id=mock_integration_id, - settings=create_settings(), - library_settings=BasicAuthProviderLibrarySettings(), - valid={"ABCD": "1234"}, - ) - - -class TestFirstBook: - def test_from_config( - self, - create_settings: Callable[..., FirstBookAuthSettings], - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - settings = create_settings( - password="the_key", - ) - provider = create_provider(settings=settings) - - # Verify that the configuration details were stored properly. - assert "http://example.com/" == provider.root - assert "the_key" == provider.secret - - # Test the default server-side authentication regular expressions. - assert provider.server_side_validation("foo' or 1=1 --;", "1234") is False - assert provider.server_side_validation("foo", "12 34") is False - assert provider.server_side_validation("foo", "1234") is True - assert provider.server_side_validation("foo@bar", "1234") is True - - def test_authentication_success( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - # The mock API successfully decodes the JWT and verifies that - # the given barcode and pin authenticate a specific patron. - assert provider.remote_pin_test("ABCD", "1234") is True - - # Let's see what the mock API had to work with. - requested = provider.request_urls.pop() - assert requested.startswith(provider.root) - token = requested[len(provider.root) :] - - # It's a JWT, with the provided barcode and PIN in the - # payload. - barcode, pin = provider._decode(token) - assert "ABCD" == barcode - assert "1234" == pin - - def test_authentication_failure( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - assert provider.remote_pin_test("ABCD", "9999") is False - assert provider.remote_pin_test("nosuchkey", "9999") is False - - # credentials are uppercased in remote_authenticate; - # remote_pin_test just passes on whatever it's sent. - assert provider.remote_pin_test("abcd", "9999") is False - - def test_remote_authenticate( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - patrondata = provider.remote_authenticate("abcd", "1234") - assert isinstance(patrondata, PatronData) - assert "ABCD" == patrondata.permanent_id - assert "ABCD" == patrondata.authorization_identifier - assert patrondata.username is None - - patrondata = provider.remote_authenticate("ABCD", "1234") - assert isinstance(patrondata, PatronData) - assert "ABCD" == patrondata.permanent_id - assert "ABCD" == patrondata.authorization_identifier - assert patrondata.username is None - - # When username is none, the patrondata object should be None - patrondata = provider.remote_authenticate(None, "1234") - assert patrondata is None - - def test_broken_service_remote_pin_test( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider(failure_status_code=502) - with pytest.raises(RemoteInitiatedServerError) as excinfo: - provider.remote_pin_test("key", "pin") - assert "Got unexpected response code 502. Content: Error 502" in str( - excinfo.value - ) - - def test_bad_connection_remote_pin_test( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider(bad_connection=True) - with pytest.raises(RemoteInitiatedServerError) as excinfo: - provider.remote_pin_test("key", "pin") - assert "Could not connect!" in str(excinfo.value) - - def test_authentication_flow_document( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - db: DatabaseTransactionFixture, - ): - # We're about to call url_for, so we must create an - # application context. - provider = create_provider() - os.environ["AUTOINITIALIZE"] = "False" - from api.app import app - - del os.environ["AUTOINITIALIZE"] - with app.test_request_context("/"): - doc = provider.authentication_flow_document(db.session) - assert provider.label() == doc["description"] - assert provider.flow_type == doc["type"] - - def test_jwt( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - # Test the code that generates and signs JWTs. - token = provider.jwt("a barcode", "a pin") - - # The JWT was signed with the shared secret. Decode it (this - # validates it as a side effect) and we can see the payload. - barcode, pin = provider._decode(token) - - assert "a barcode" == barcode - assert "a pin" == pin - - # If the secrets don't match, decoding won't work. - provider.secret = "bad secret" - pytest.raises(jwt.DecodeError, provider._decode, token) - - def test_remote_patron_lookup( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - db: DatabaseTransactionFixture, - ): - provider = create_provider() - # Remote patron lookup is not supported. It always returns - # the same PatronData object passed into it. - input_patrondata = PatronData() - output_patrondata = provider.remote_patron_lookup(input_patrondata) - assert input_patrondata == output_patrondata - - # if anything else is passed in, it returns None - output_patrondata = provider.remote_patron_lookup(db.patron()) - assert output_patrondata is None From 448df4fb348484b81e8ed93fbbe0f62a82b10725 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jan 2024 09:54:47 -0800 Subject: [PATCH 31/33] Bump types-pytz from 2023.3.1.1 to 2023.4.0.20240130 (#1644) Bumps [types-pytz](https://github.com/python/typeshed) from 2023.3.1.1 to 2023.4.0.20240130. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-pytz dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0516eec97..8e5ba7685 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4179,13 +4179,13 @@ files = [ [[package]] name = "types-pytz" -version = "2023.3.1.1" +version = "2023.4.0.20240130" description = "Typing stubs for pytz" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "types-pytz-2023.3.1.1.tar.gz", hash = "sha256:cc23d0192cd49c8f6bba44ee0c81e4586a8f30204970fc0894d209a6b08dab9a"}, - {file = "types_pytz-2023.3.1.1-py3-none-any.whl", hash = "sha256:1999a123a3dc0e39a2ef6d19f3f8584211de9e6a77fe7a0259f04a524e90a5cf"}, + {file = "types-pytz-2023.4.0.20240130.tar.gz", hash = "sha256:33676a90bf04b19f92c33eec8581136bea2f35ddd12759e579a624a006fd387a"}, + {file = "types_pytz-2023.4.0.20240130-py3-none-any.whl", hash = "sha256:6ce76a9f8fd22bd39b01a59c35bfa2db39b60d11a2f77145e97b730de7e64fe0"}, ] [[package]] From e5a0566af2626e92a4c09f6fa914e5f846f72f03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jan 2024 09:56:36 -0800 Subject: [PATCH 32/33] Bump types-pyopenssl from 23.3.0.20240106 to 24.0.0.20240130 (#1643) Bumps [types-pyopenssl](https://github.com/python/typeshed) from 23.3.0.20240106 to 24.0.0.20240130. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-pyopenssl dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8e5ba7685..c2e2c4a2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4154,13 +4154,13 @@ files = [ [[package]] name = "types-pyopenssl" -version = "23.3.0.20240106" +version = "24.0.0.20240130" description = "Typing stubs for pyOpenSSL" optional = false python-versions = ">=3.8" files = [ - {file = "types-pyOpenSSL-23.3.0.20240106.tar.gz", hash = "sha256:3d6f3462bec0c260caadf93fbb377225c126661b779c7d9ab99b6dad5ca10db9"}, - {file = "types_pyOpenSSL-23.3.0.20240106-py3-none-any.whl", hash = "sha256:47a7eedbd18b7bcad17efebf1c53416148f5a173918a6d75027e75e32fe039ae"}, + {file = "types-pyOpenSSL-24.0.0.20240130.tar.gz", hash = "sha256:c812e5c1c35249f75ef5935708b2a997d62abf9745be222e5f94b9595472ab25"}, + {file = "types_pyOpenSSL-24.0.0.20240130-py3-none-any.whl", hash = "sha256:24a255458b5b8a7fca8139cf56f2a8ad5a4f1a5f711b73a5bb9cb50dc688fab5"}, ] [package.dependencies] @@ -4482,4 +4482,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "8c6ad1cedd4a1af38974f1bacace481a495dd5a5a3ad711a52b7aa83d2370384" +content-hash = "1b927f32f6ca6b17da46d3f4384347658e9c3d3bbb8520eeea029574386a342c" diff --git a/pyproject.toml b/pyproject.toml index 59a43f588..f355f6dac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -234,7 +234,7 @@ redmail = "^0.6.0" requests = "^2.29" sqlalchemy = {version = "^1.4", extras = ["mypy"]} textblob = "0.17.1" -types-pyopenssl = "^23.1.0.3" +types-pyopenssl = "^24.0.0.20240130" types-pyyaml = "^6.0.12.9" # We import typing_extensions, so we can use new annotation features. # - Self (Python 3.11) From 78faa48a88c8f02b7192dfebd25e14e2b348a6a6 Mon Sep 17 00:00:00 2001 From: dbernstein Date: Mon, 5 Feb 2024 12:00:49 -0800 Subject: [PATCH 33/33] Relax pre-sharing validation on list entries with no associated work. (#1649) Resolves: https://ebce-lyrasis.atlassian.net/browse/PP-708 --- core/query/customlist.py | 29 ++++++++--- .../api/admin/controller/test_custom_lists.py | 50 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/core/query/customlist.py b/core/query/customlist.py index 1098cbecb..16ebf8bde 100644 --- a/core/query/customlist.py +++ b/core/query/customlist.py @@ -43,7 +43,20 @@ def share_locally_with_library( # All entries must be valid for the library library_collection_ids = [c.id for c in library.collections] entry: CustomListEntry + missing_work_id_count = 0 for entry in customlist.entries: + # It appears that many many lists have entries without works. + # see https://ebce-lyrasis.atlassian.net/browse/PP-708 for the full story. + # Because of this frequently occurring condition, lists are quietly not shared + # with the majority of libraries causing confusion for our users. As it stands + # there is nothing that prevents lists with work-less entries that have already been + # shared from being unshared. So for the time being the least intrusive intervention + # for enabling sharing to work again for many existing lists would be to relax the + # validation when an entry does not have an associated work. + if not entry.work: + missing_work_id_count += 1 + continue + valid_license = ( _db.query(LicensePool) .filter( @@ -53,17 +66,17 @@ def share_locally_with_library( .first() ) if valid_license is None: - if entry.work: - log.info( - f"Unable to share customlist: No license for work '{entry.work.title}'." - ) - else: - log.info( - f"Unable to share customlist: No work associated with custom list entry where entry.id = {entry.id}" - ) + log.info( + f"Unable to share customlist: No license for work '{entry.work.title}'." + ) return CUSTOMLIST_ENTRY_NOT_VALID_FOR_LIBRARY + if missing_work_id_count > 0: + log.warning( + f"This list contains {missing_work_id_count} {'entries' if missing_work_id_count > 1 else 'entry'} " + f"without an associated work. " + ) customlist.shared_locally_with_libraries.append(library) log.info( f"Successfully shared customlist '{customlist.name}' with library '{library.name}'." diff --git a/tests/api/admin/controller/test_custom_lists.py b/tests/api/admin/controller/test_custom_lists.py index 724e431e6..2928a45c9 100644 --- a/tests/api/admin/controller/test_custom_lists.py +++ b/tests/api/admin/controller/test_custom_lists.py @@ -1,4 +1,5 @@ import json +import logging from unittest import mock import feedparser @@ -969,8 +970,9 @@ def test_share_locally_success( assert response["failures"] == 1 # The default library def test_share_locally_with_invalid_entries( - self, admin_librarian_fixture: AdminLibrarianFixture + self, admin_librarian_fixture: AdminLibrarianFixture, caplog ): + caplog.set_level(logging.INFO, "core.query.customlist.CustomListQueries") s = self._setup_share_locally(admin_librarian_fixture) s.collection1.libraries.append(s.shared_with) @@ -986,6 +988,52 @@ def test_share_locally_with_invalid_entries( assert response["failures"] == 2 assert response["successes"] == 0 + assert self.message_found_n_times( + caplog, "This list contains 1 entry without an associated work", 0 + ) + assert self.message_found_n_times( + caplog, "Unable to share customlist: No license for work", 1 + ) + + def test_share_locally_with_entry_with_missing_work( + self, admin_librarian_fixture: AdminLibrarianFixture, caplog + ): + caplog.set_level(logging.INFO, "core.query.customlist.CustomListQueries") + s = self._setup_share_locally(admin_librarian_fixture) + s.collection1.libraries.append(s.shared_with) + + w = admin_librarian_fixture.ctrl.db.work(collection=s.collection1) + entry, ignore = s.list.add_entry(w) + + entry.work = None + entry.work_id = None + + assert entry.edition is not None + + response = self._share_locally( + s.list, s.primary_library, admin_librarian_fixture + ) + + assert response["failures"] == 1 # The default library + assert response["successes"] == 1 + assert self.message_found_n_times( + caplog, "This list contains 1 entry without an associated work", 1 + ) + + def message_found_n_times(self, caplog, message: str, occurrences: int = 1): + return ( + len( + [ + x + for x in caplog.messages + if x.__contains__( + message, + ) + ] + ) + == occurrences + ) + def test_share_locally_get(self, admin_librarian_fixture: AdminLibrarianFixture): """Does the GET method fetch shared lists""" s = self._setup_share_locally(admin_librarian_fixture)