diff --git a/.github/workflows/image-build-scan.yml b/.github/workflows/image-build-scan.yml index 736445c3..3b806fc4 100644 --- a/.github/workflows/image-build-scan.yml +++ b/.github/workflows/image-build-scan.yml @@ -80,6 +80,7 @@ jobs: uses: aquasecurity/trivy-action@master with: scan-type: 'image' + scanners: 'vuln' image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} vuln-type: 'os,library' severity: 'HIGH,CRITICAL' diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 00000000..d19177f6 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,25 @@ +name: "Scan project for secrets & sensitive information" + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + secret-scan: + name: Scan project for secrets + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Secret scanning + uses: trufflesecurity/trufflehog@main + with: + base: "" + head: ${{ github.ref_name }} + extra_args: --only-verified diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7197bb68..a9d56d59 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,4 +8,4 @@ repos: # For running trufflehog in docker, use the following entry instead: # entry: bash -c 'docker run --rm -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail' language: system - stages: ["commit", "push"] + stages: ["pre-commit", "pre-push"] diff --git a/kustomize/overlays/prod/deployment_patch.yaml b/kustomize/overlays/prod/deployment_patch.yaml index 38b18f1c..48bfb693 100644 --- a/kustomize/overlays/prod/deployment_patch.yaml +++ b/kustomize/overlays/prod/deployment_patch.yaml @@ -168,3 +168,13 @@ spec: secretKeyRef: name: prs-env-prod key: REDIS_CACHE_HOST + - name: API_RESPONSE_CACHE_SECONDS + valueFrom: + secretKeyRef: + name: prs-env-prod + key: API_RESPONSE_CACHE_SECONDS + - name: CACHE_MIDDLEWARE_SECONDS + valueFrom: + secretKeyRef: + name: prs-env-prod + key: CACHE_MIDDLEWARE_SECONDS diff --git a/kustomize/overlays/prod/kustomization.yaml b/kustomize/overlays/prod/kustomization.yaml index 7d427a10..28224ecb 100644 --- a/kustomize/overlays/prod/kustomization.yaml +++ b/kustomize/overlays/prod/kustomization.yaml @@ -26,4 +26,4 @@ patches: - path: typesense_service_patch.yaml images: - name: ghcr.io/dbca-wa/prs - newTag: 2.5.58 + newTag: 2.5.59 diff --git a/kustomize/overlays/uat/deployment_patch.yaml b/kustomize/overlays/uat/deployment_patch.yaml index 6bf74841..0db6849f 100644 --- a/kustomize/overlays/uat/deployment_patch.yaml +++ b/kustomize/overlays/uat/deployment_patch.yaml @@ -167,3 +167,13 @@ spec: secretKeyRef: name: prs-env-uat key: REDIS_CACHE_HOST + - name: API_RESPONSE_CACHE_SECONDS + valueFrom: + secretKeyRef: + name: prs-env-uat + key: API_RESPONSE_CACHE_SECONDS + - name: CACHE_MIDDLEWARE_SECONDS + valueFrom: + secretKeyRef: + name: prs-env-uat + key: CACHE_MIDDLEWARE_SECONDS diff --git a/poetry.lock b/poetry.lock index 613400b7..2f5ef326 100644 --- a/poetry.lock +++ b/poetry.lock @@ -48,13 +48,13 @@ test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] [[package]] name = "azure-core" -version = "1.30.2" +version = "1.31.0" description = "Microsoft Azure Core Library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "azure-core-1.30.2.tar.gz", hash = "sha256:a14dc210efcd608821aa472d9fb8e8d035d29b68993819147bc290a8ac224472"}, - {file = "azure_core-1.30.2-py3-none-any.whl", hash = "sha256:cf019c1ca832e96274ae85abd3d9f752397194d9fea3b41487290562ac8abe4a"}, + {file = "azure_core-1.31.0-py3-none-any.whl", hash = "sha256:22954de3777e0250029360ef31d80448ef1be13b80a459bff80ba7073379e2cd"}, + {file = "azure_core-1.31.0.tar.gz", hash = "sha256:656a0dd61e1869b1506b7c6a3b31d62f15984b1a573d6326f6aa2f3e4123284b"}, ] [package.dependencies] @@ -67,23 +67,23 @@ aio = ["aiohttp (>=3.0)"] [[package]] name = "azure-storage-blob" -version = "12.22.0" +version = "12.23.1" description = "Microsoft Azure Blob Storage Client Library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "azure-storage-blob-12.22.0.tar.gz", hash = "sha256:b3804bb4fe8ab1c32771fa464053da772a682c2737b19da438a3f4e5e3b3736e"}, - {file = "azure_storage_blob-12.22.0-py3-none-any.whl", hash = "sha256:bb7d2d824ce3f11f14a27ee7d9281289f7e072ac8311c52e3652672455b7d5e8"}, + {file = "azure_storage_blob-12.23.1-py3-none-any.whl", hash = "sha256:1c2238aa841d1545f42714a5017c010366137a44a0605da2d45f770174bfc6b4"}, + {file = "azure_storage_blob-12.23.1.tar.gz", hash = "sha256:a587e54d4e39d2a27bd75109db164ffa2058fe194061e5446c5a89bca918272f"}, ] [package.dependencies] -azure-core = ">=1.28.0" +azure-core = ">=1.30.0" cryptography = ">=2.1.4" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" [package.extras] -aio = ["azure-core[aio] (>=1.28.0)"] +aio = ["azure-core[aio] (>=1.30.0)"] [[package]] name = "beautifulsoup4" @@ -108,13 +108,13 @@ lxml = ["lxml"] [[package]] name = "billiard" -version = "4.2.0" +version = "4.2.1" description = "Python multiprocessing fork with improvements and bugfixes" optional = false python-versions = ">=3.7" files = [ - {file = "billiard-4.2.0-py3-none-any.whl", hash = "sha256:07aa978b308f334ff8282bd4a746e681b3513db5c9a514cbdd810cbbdc19714d"}, - {file = "billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c"}, + {file = "billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb"}, + {file = "billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f"}, ] [[package]] @@ -278,78 +278,78 @@ files = [ [[package]] name = "cffi" -version = "1.17.0" +version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, - {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, - {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, - {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, - {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, - {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, - {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, - {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, - {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, - {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, - {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, - {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, - {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, - {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, - {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, - {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] [package.dependencies] @@ -979,19 +979,19 @@ python-dateutil = ">=2.4" [[package]] name = "filelock" -version = "3.15.4" +version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, - {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] -typing = ["typing-extensions (>=4.8)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "geojson" @@ -1027,13 +1027,13 @@ tornado = ["tornado (>=0.2)"] [[package]] name = "identify" -version = "2.6.0" +version = "2.6.1" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, - {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, + {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, + {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, ] [package.extras] @@ -1041,15 +1041,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.8" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" files = [ - {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, - {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "ipdb" version = "0.13.13" @@ -1067,13 +1070,13 @@ ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""} [[package]] name = "ipython" -version = "8.27.0" +version = "8.28.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.10" files = [ - {file = "ipython-8.27.0-py3-none-any.whl", hash = "sha256:f68b3cb8bde357a5d7adc9598d57e22a45dfbea19eb6b98286fa3b288c9cd55c"}, - {file = "ipython-8.27.0.tar.gz", hash = "sha256:0b99a2dc9f15fd68692e898e5568725c6d49c527d36a9fb5960ffbdeaa82ff7e"}, + {file = "ipython-8.28.0-py3-none-any.whl", hash = "sha256:530ef1e7bb693724d3cdc37287c80b07ad9b25986c007a53aa1857272dac3f35"}, + {file = "ipython-8.28.0.tar.gz", hash = "sha256:0d0d15ca1e01faeb868ef56bc7ee5a0de5bd66885735682e8a322ae289a13d1a"}, ] [package.dependencies] @@ -1136,17 +1139,18 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "kombu" -version = "5.4.0" +version = "5.4.2" description = "Messaging library for Python." optional = false python-versions = ">=3.8" files = [ - {file = "kombu-5.4.0-py3-none-any.whl", hash = "sha256:c8dd99820467610b4febbc7a9e8a0d3d7da2d35116b67184418b51cc520ea6b6"}, - {file = "kombu-5.4.0.tar.gz", hash = "sha256:ad200a8dbdaaa2bbc5f26d2ee7d707d9a1fded353a0f4bd751ce8c7d9f449c60"}, + {file = "kombu-5.4.2-py3-none-any.whl", hash = "sha256:14212f5ccf022fc0a70453bb025a1dcc32782a588c49ea866884047d66e14763"}, + {file = "kombu-5.4.2.tar.gz", hash = "sha256:eef572dd2fd9fc614b37580e3caeafdd5af46c1eff31e7fba89138cdb406f2cf"}, ] [package.dependencies] amqp = ">=5.1.1,<6.0.0" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} vine = "5.1.0" [package.extras] @@ -1156,7 +1160,7 @@ confluentkafka = ["confluent-kafka (>=2.2.0)"] consul = ["python-consul2 (==0.1.5)"] librabbitmq = ["librabbitmq (>=2.0.0)"] mongodb = ["pymongo (>=4.1.1)"] -msgpack = ["msgpack (==1.0.8)"] +msgpack = ["msgpack (==1.1.0)"] pyro = ["pyro4 (==4.82)"] qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2)"] @@ -1621,29 +1625,29 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.2.2" +version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, - {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] -type = ["mypy (>=1.8)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pre-commit" -version = "3.8.0" +version = "4.0.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, + {file = "pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234"}, + {file = "pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6"}, ] [package.dependencies] @@ -1655,13 +1659,13 @@ virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" -version = "3.0.47" +version = "3.0.48" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, - {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, ] [package.dependencies] @@ -1767,17 +1771,17 @@ files = [ [[package]] name = "psycopg-pool" -version = "3.2.2" +version = "3.2.3" description = "Connection Pool for Psycopg" optional = false python-versions = ">=3.8" files = [ - {file = "psycopg_pool-3.2.2-py3-none-any.whl", hash = "sha256:273081d0fbfaced4f35e69200c89cb8fbddfe277c38cc86c235b90a2ec2c8153"}, - {file = "psycopg_pool-3.2.2.tar.gz", hash = "sha256:9e22c370045f6d7f2666a5ad1b0caf345f9f1912195b0b25d0d3bcc4f3a7389c"}, + {file = "psycopg_pool-3.2.3-py3-none-any.whl", hash = "sha256:53bd8e640625e01b2927b2ad96df8ed8e8f91caea4597d45e7673fc7bbb85eb1"}, + {file = "psycopg_pool-3.2.3.tar.gz", hash = "sha256:bb942f123bef4b7fbe4d55421bd3fb01829903c95c0f33fd42b7e94e5ac9b52a"}, ] [package.dependencies] -typing-extensions = ">=4.4" +typing-extensions = ">=4.6" [[package]] name = "ptyprocess" @@ -1969,13 +1973,13 @@ files = [ [[package]] name = "redis" -version = "5.1.0" +version = "5.1.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" files = [ - {file = "redis-5.1.0-py3-none-any.whl", hash = "sha256:fd4fccba0d7f6aa48c58a78d76ddb4afc698f5da4a2c1d03d916e4fd7ab88cdd"}, - {file = "redis-5.1.0.tar.gz", hash = "sha256:b756df1e4a3858fcc0ef861f3fc53623a96c41e2b1f5304e09e0fe758d333d40"}, + {file = "redis-5.1.1-py3-none-any.whl", hash = "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24"}, + {file = "redis-5.1.1.tar.gz", hash = "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72"}, ] [package.extras] @@ -2171,13 +2175,13 @@ files = [ [[package]] name = "tzdata" -version = "2024.1" +version = "2024.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, - {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] [[package]] @@ -2210,13 +2214,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.2" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] @@ -2238,13 +2242,13 @@ files = [ [[package]] name = "virtualenv" -version = "20.26.3" +version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, - {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, ] [package.dependencies] @@ -2332,4 +2336,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "dc6b1a361b6d3b8c7b8b8886017fd21667b06ea16a8420430ec98a5ab58f69a5" +content-hash = "76f328a6fd2cef19430f8169f5a18c0dd8af80027608ddacb589f4b0a26ae1f2" diff --git a/prs2/referral/templates/referral/referral_detail.html b/prs2/referral/templates/referral/referral_detail.html index a6995251..522815ce 100644 --- a/prs2/referral/templates/referral/referral_detail.html +++ b/prs2/referral/templates/referral/referral_detail.html @@ -1,98 +1,151 @@ {% extends "base_prs.html" %} -{% load static cache %} - +{% load static %} {% block extra_style %} -{{ block.super }} -{% if geojson_locations %} - - - -{% endif %} - - - - + {{ block.super }} + {% if geojson_locations %} + + + + {% endif %} + + + + {% endblock extra_style %} - {% block navbar_item_extra %} - - {% endblock navbar_item_extra %} - {% block page_content_inner %} -{% cache 600 referral_detail referral.pk %} - -{% if not object.is_deleted %} -

{{ title }}

-
-
- {% if object.related_refs.all %} -
-

Related referrals:

- {% for ref in object.related_refs.all %} -
- {% csrf_token %} - - {{ ref.type }}, ref. {{ ref.reference }} (referral ID {{ ref.pk }}) - - {% if prs_user %} - - {% endif %} -
- {% endfor %} + + {% if not object.is_deleted %} +

{{ title }}

+
+
+ {% if object.related_refs.all %} +
+

Related referrals:

+ {% for ref in object.related_refs.all %} +
+ {% csrf_token %} + + {{ ref.type }}, ref. {{ ref.reference }} (referral ID {{ ref.pk }}) + + {% if prs_user %} + + {% endif %} +
+ {% endfor %} +
+ {% endif %}
- {% endif %} -
-
-
- {% if object.tags.all %} -

Key issues:

- {% for tag in object.tags.all %} - {{ tag.name }} - {% if forloop.last %}{% else %}, {% endif %} - {% endfor %} - {% else %} +
+
+ {% if object.tags.all %} +

Key issues:

+ {% for tag in object.tags.all %} + {{ tag.name }} + {% if forloop.last %} + {% else %} + , + {% endif %} + {% endfor %} + {% else %} No issues recorded {% endif %}
@@ -112,142 +165,154 @@

Key issues:

{% if prs_user %} -
-
-
Drop files here or click to upload.
-
-
+
+
+
Drop files here or click to upload.
+
+
{% endif %}
-
- +
+ +
+ +
+ {% endif %} -{% endcache %} - {% if geojson_locations %} -
-
-
-

- -

-
-
+
+
+
+

+ +

+
+
+
-
{% endif %} {% endblock page_content_inner %} - {% block extra_js %} -{{ block.super }} -{% if geojson_locations %} - - - - - + + + + -{% endif %} - - - + {% endif %} + + + + {% endblock extra_js %} diff --git a/prs2/referral/utils.py b/prs2/referral/utils.py index 4cfadc6f..fd7c5d7a 100644 --- a/prs2/referral/utils.py +++ b/prs2/referral/utils.py @@ -1,376 +1,378 @@ -import json -import re -from datetime import date, datetime - -import requests -from dbca_utils.utils import env -from django.apps import apps -from django.conf import settings -from django.contrib import admin -from django.core.mail import EmailMultiAlternatives -from django.db.models import Q -from django.db.models.base import ModelBase -from django.utils.encoding import smart_str -from django.utils.safestring import mark_safe -from reversion.models import Version -from unidecode import unidecode - - -def is_model_or_string(model): - """This function checks if we passed in a Model, or the name of a model as - a case-insensitive string. The string may also be plural to some extent - (i.e. ending with "s"). If we passed in a string, return the named Model - instead using get_model(). - - Example:: - - from referral.util import is_model_or_string - is_model_or_string('region') - is_model_or_string(Region) - - >>> from referral.models import Region - >>> from django.db.models.base import ModelBase - >>> from referral.util import is_model_or_string - >>> isinstance(is_model_or_string('region'), ModelBase) - True - >>> isinstance(is_model_or_string(Region), ModelBase) - True - """ - if not isinstance(model, ModelBase): - # Hack: if the last character is "s", remove it before calling get_model - x = len(model) - 1 - if model[x] == "s": - model = model[0:x] - try: - model = apps.get_model("referral", model) - except LookupError: - model = None - return model - - -def smart_truncate(content, length=100, suffix="....(more)"): - """Small function to truncate a string in a sensible way, sourced from: - http://stackoverflow.com/questions/250357/smart-truncate-in-python - """ - content = smart_str(content) - if len(content) <= length: - return content - else: - return " ".join(content[: length + 1].split(" ")[0:-1]) + suffix - - -def dewordify_text(txt): - """Function to strip some of the crufty HTML that results from copy-pasting - MS Word documents/HTML emails into the RTF text fields in this application. - Should always return a unicode string. - - Source: - http://stackoverflow.com/questions/1175540/iterative-find-replace-from-a-list-of-tuples-in-python - """ - REPLACEMENTS = { - " ": " ", - "<": "<", - ">": ">", - ' class="MsoNormal"': "", - '': "", - "": "", - "": "", - } - - def replacer(m): - return REPLACEMENTS[m.group(0)] - - if txt: - # Whatever string encoding is passed in, - # use unidecode to replace non-ASCII characters. - txt = unidecode(txt) # Replaces odd characters. - r = re.compile("|".join(REPLACEMENTS.keys())) - r = r.sub(replacer, txt) - return r - else: - return "" - - -def breadcrumbs_li(links): - """Returns HTML: an unordered list of URLs (no surrounding
    tags). - ``links`` should be a iterable of tuples (URL, text). - Reference: https://getbootstrap.com/docs/4.1/components/breadcrumb/ - """ - crumbs = "" - li_str = '' - li_str_active = '' - # Iterate over the list, except for the last item. - if len(links) > 1: - for i in links[:-1]: - crumbs += li_str.format(i[0], i[1]) - # Add the final "active" item. - crumbs += li_str_active.format(links[-1][1]) - return crumbs - - -def get_query(query_string, search_fields): - """Returns a query which is a combination of Q objects. That combination - aims to search keywords within a model by testing the given search fields. - - Splits the query string into individual keywords, getting rid of unecessary - spaces and grouping quoted words together. - """ - findterms = re.compile(r'"([^"]+)"|(\S+)').findall - normspace = re.compile(r"\s{2,}").sub - query = None # Query to search for every search term - terms = [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] - for term in terms: - or_query = None # Query to search for a given term in each field - for field_name in search_fields: - q = Q(**{"%s__icontains" % field_name: term}) - if or_query is None: - or_query = q - else: - or_query = or_query | q - if query is None: - query = or_query - else: - query = query & or_query - return query - - -def as_row_subtract_referral_cell(html_row): - """Function to take some HTML of a table row and then remove the cell - containing the Referral ID (we don't need to display this on the referral details page). - """ - # Use regex to remove the tag of class "referral-id-cell". - html_row = re.sub(r'.+', r"", html_row) - return mark_safe(html_row) - - -def user_referral_history(user, referral): - # Retrieve user profile (create it if it doesn't exist) - try: - profile = user.get_profile() - except Exception: - profile = user.userprofile - # If the user has no history, create an empty list - if not profile.referral_history: - ref_history = [] - else: - try: - # Deserialise the list of lists from the user profile - ref_history = json.loads(profile.referral_history) - except Exception: - # If that failed, assume that the user still has "old style" history in their profile. - ref_history = profile.referral_history.split(",") - # Edge-case: single-ref history profiles only. - if isinstance(ref_history, int): - ref = ref_history - ref_history = [] - ref_history.append([ref, datetime.strftime(datetime.today(), "%d-%m-%Y")]) - # We're going to replace the existing list with a new one. - new_ref_history = [] - # Iterate through the list; it's either a list of unicode strings (old-style) - # or a list of lists (new-style). - for i in ref_history: - # Firstly if the item is a string, convert that to a list ([val, DATE]). - if isinstance(i, str): - i = [int(i), datetime.strftime(datetime.today(), "%d-%m-%Y")] - # If the referral that was passed in exists in the current list, pass (don't append it). - if referral.id == i[0]: - pass - else: - new_ref_history.append(i) - # Add the passed-in referral to the end of the new list. - new_ref_history.append([referral.id, datetime.strftime(datetime.today(), "%d-%m-%Y")]) - # History can be a maximum of 20 referrals; slice the new list accordingly. - if len(new_ref_history) > 20: - new_ref_history = new_ref_history[-20:] - # Save the updated user profile; serialise the new list of lists. - profile.referral_history = json.dumps(new_ref_history) - profile.save() - - -def user_task_history(user, task, comment=None): - """Utility function to update the task history in a user's profile.""" - profile = user.userprofile - if not profile.task_history: - task_history = [] - else: - task_history = json.loads(profile.task_history) - task_history.append([task.pk, datetime.strftime(datetime.today(), "%d-%m-%Y"), comment]) - profile.task_history = json.dumps(task_history) - profile.save() - - -def filter_queryset(request, model, queryset): - """ - Function to dynamically filter a model queryset, based upon the search_fields defined in - admin.py for that model. If search_fields is not defined, the queryset is returned unchanged. - """ - search_string = request.GET["q"] - # Replace single-quotes with double-quotes - search_string = search_string.replace("'", r'"') - if admin.site._registry[model].search_fields: - search_fields = admin.site._registry[model].search_fields - entry_query = get_query(search_string, search_fields) - queryset = queryset.filter(entry_query) - return queryset, search_string - - -def is_prs_user(request): - if "PRS user" not in [group.name for group in request.user.groups.all()]: - return False - return True - - -def is_prs_power_user(request): - if "PRS power user" not in [group.name for group in request.user.groups.all()]: - return False - return True - - -def prs_user(request): - return is_prs_user(request) or is_prs_power_user(request) or request.user.is_superuser - - -def update_revision_history(app_model): - """Function to bulk-update Version objects where the data model - is changed. This function is for reference, as these change will tend to - be one-off and customised. - - Example: the order_date field was added the the Record model, then later - changed from DateTime to Date. This change caused the deserialisation step - to fail for Record versions with a serialised DateTime. - """ - for v in Version.objects.all(): - # Deserialise the object version. - data = json.loads(v.serialized_data)[0] - if data["model"] == app_model: # Example: referral.record - pass - """ - # Do something to the deserialised data here, e.g.: - if 'order_date' in data['fields']: - if data['fields']['order_date']: - data['fields']['order_date'] = data['fields']['order_date'][:10] - v.serialized_data = json.dumps([data]) - v.save() - else: - data['fields']['order_date'] = '' - v.serialized_data = json.dumps([data]) - v.save() - """ - - -def overdue_task_email(): - """A utility function to send an email to each user with tasks that are overdue.""" - from django.contrib.auth.models import Group - - from .models import Task, TaskState - - prs_grp = Group.objects.get(name=settings.PRS_USER_GROUP) - users = prs_grp.user_set.filter(is_active=True) - ongoing_states = TaskState.objects.current().filter(is_ongoing=True) - - # For each user, send an email if they have any incomplete tasks that - # are in an 'ongoing' state (i.e. not stopped). - subject = "PRS overdue task notification" - from_email = settings.APPLICATION_ALERTS_EMAIL - - for user in users: - ongoing_tasks = Task.objects.current().filter( - complete_date=None, - state__in=ongoing_states, - due_date__lt=date.today(), - assigned_user=user, - ) - if ongoing_tasks.exists(): - # Send a single email to this user containing the list of tasks - to_email = [user.email] - text_content = """This is an automated message to let you know that the following tasks - assigned to you within PRS are currently overdue:\n""" - html_content = """

    This is an automated message to let you know that the following tasks - assigned to you within PRS are currently overdue:

    -
      """ - for t in ongoing_tasks: - text_content += "* Referral ID {} - {}\n".format(t.referral.pk, t.type.name) - html_content += '
    • Referral ID {} - {}
    • '.format( - settings.SITE_URL + t.referral.get_absolute_url(), - t.referral.pk, - t.type.name, - ) - text_content += "This is an automatically-generated email - please do not reply.\n" - html_content += "

    This is an automatically-generated email - please do not reply.

    " - msg = EmailMultiAlternatives(subject, text_content, from_email, to_email) - msg.attach_alternative(html_content, "text/html") - # Email should fail gracefully - ie no Exception raised on failure. - msg.send(fail_silently=True) - - return True - - -def wfs_getfeature(type_name, crs="EPSG:4326", cql_filter=None, max_features=50): - """A utility function to perform a GetFeature request on a WFS endpoint - and return results as GeoJSON. - """ - url = env("GEOSERVER_URL", None) - auth = (env("GEOSERVER_SSO_USER", None), env("GEOSERVER_SSO_PASS", None)) - params = { - "service": "WFS", - "version": "1.1.0", - "typeName": type_name, - "request": "getFeature", - "outputFormat": "json", - "SRSName": f"urn:x-ogc:def:crs:{crs}", - "maxFeatures": max_features, - } - if cql_filter: - params["cql_filter"] = cql_filter - resp = requests.get(url, auth=auth, params=params) - try: - resp.raise_for_status() - except: - # On exception, return an empty dict. - return {} - - return resp.json() - - -def query_caddy(q): - """Utility function to proxy queries to the Caddy geocoder service.""" - url = env("GEOCODER_URL", None) - auth = (env("GEOSERVER_SSO_USER", None), env("GEOSERVER_SSO_PASS", None)) - params = {"q": q} - resp = requests.get(url, auth=auth, params=params) - try: - resp.raise_for_status() - except: - # On exception, return an empty list. - return [] - - return resp.json() - - -def get_previous_pages(page_num, count=5): - """Convenience function to take a Paginator page object and return the previous `count` - page numbers, to a minimum of 1. - """ - prev_page_numbers = [] - - if page_num and page_num.has_previous(): - for i in range(page_num.previous_page_number(), page_num.previous_page_number() - count, -1): - if i >= 1: - prev_page_numbers.append(i) - - prev_page_numbers.reverse() - return prev_page_numbers - - -def get_next_pages(page_num, count=5): - """Convenience function to take a Paginator page object and return the next `count` - page numbers, to a maximum of the paginator page count. - """ - next_page_numbers = [] - - if page_num and page_num.has_next(): - for i in range(page_num.next_page_number(), page_num.next_page_number() + count): - if i <= page_num.paginator.num_pages: - next_page_numbers.append(i) - - return next_page_numbers +import json +import re +from datetime import date, datetime + +import requests +from dbca_utils.utils import env +from django.apps import apps +from django.conf import settings +from django.contrib import admin +from django.core.mail import EmailMultiAlternatives +from django.db.models import Q +from django.db.models.base import ModelBase +from django.utils.encoding import smart_str +from django.utils.safestring import mark_safe +from reversion.models import Version +from unidecode import unidecode + + +def is_model_or_string(model): + """This function checks if we passed in a Model, or the name of a model as + a case-insensitive string. The string may also be plural to some extent + (i.e. ending with "s"). If we passed in a string, return the named Model + instead using get_model(). + + Example:: + + from referral.util import is_model_or_string + is_model_or_string('region') + is_model_or_string(Region) + + >>> from referral.models import Region + >>> from django.db.models.base import ModelBase + >>> from referral.util import is_model_or_string + >>> isinstance(is_model_or_string('region'), ModelBase) + True + >>> isinstance(is_model_or_string(Region), ModelBase) + True + """ + if not isinstance(model, ModelBase): + # Hack: if the last character is "s", remove it before calling get_model + x = len(model) - 1 + if model[x] == "s": + model = model[0:x] + try: + model = apps.get_model("referral", model) + except LookupError: + model = None + return model + + +def smart_truncate(content, length=100, suffix="....(more)"): + """Small function to truncate a string in a sensible way, sourced from: + http://stackoverflow.com/questions/250357/smart-truncate-in-python + """ + content = smart_str(content) + if len(content) <= length: + return content + else: + return " ".join(content[: length + 1].split(" ")[0:-1]) + suffix + + +def dewordify_text(txt): + """Function to strip some of the crufty HTML that results from copy-pasting + MS Word documents/HTML emails into the RTF text fields in this application. + Should always return a unicode string. + + Source: + http://stackoverflow.com/questions/1175540/iterative-find-replace-from-a-list-of-tuples-in-python + """ + REPLACEMENTS = { + " ": " ", + "<": "<", + ">": ">", + ' class="MsoNormal"': "", + '': "", + "": "", + "": "", + } + + def replacer(m): + return REPLACEMENTS[m.group(0)] + + if txt: + # Whatever string encoding is passed in, + # use unidecode to replace non-ASCII characters. + txt = unidecode(txt) # Replaces odd characters. + r = re.compile("|".join(REPLACEMENTS.keys())) + r = r.sub(replacer, txt) + return r + else: + return "" + + +def breadcrumbs_li(links): + """Returns HTML: an unordered list of URLs (no surrounding
      tags). + ``links`` should be a iterable of tuples (URL, text). + Reference: https://getbootstrap.com/docs/4.1/components/breadcrumb/ + """ + crumbs = "" + li_str = '' + li_str_active = '' + # Iterate over the list, except for the last item. + if len(links) > 1: + for i in links[:-1]: + crumbs += li_str.format(i[0], i[1]) + # Add the final "active" item. + crumbs += li_str_active.format(links[-1][1]) + return crumbs + + +def get_query(query_string, search_fields): + """Returns a query which is a combination of Q objects. That combination + aims to search keywords within a model by testing the given search fields. + + Splits the query string into individual keywords, getting rid of unecessary + spaces and grouping quoted words together. + """ + findterms = re.compile(r'"([^"]+)"|(\S+)').findall + normspace = re.compile(r"\s{2,}").sub + query = None # Query to search for every search term + terms = [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] + for term in terms: + or_query = None # Query to search for a given term in each field + for field_name in search_fields: + q = Q(**{"%s__icontains" % field_name: term}) + if or_query is None: + or_query = q + else: + or_query = or_query | q + if query is None: + query = or_query + else: + query = query & or_query + return query + + +def as_row_subtract_referral_cell(html_row): + """Function to take some HTML of a table row and then remove the cell + containing the Referral ID (we don't need to display this on the referral details page). + """ + # Use regex to remove the tag of class "referral-id-cell". + html_row = re.sub(r'.+', r"", html_row) + return mark_safe(html_row) + + +def user_referral_history(user, referral): + # Retrieve user profile (create it if it doesn't exist) + try: + profile = user.get_profile() + except Exception: + profile = user.userprofile + # If the user has no history, create an empty list + if not profile.referral_history: + ref_history = [] + else: + try: + # Deserialise the list of lists from the user profile + ref_history = json.loads(profile.referral_history) + except Exception: + # If that failed, assume that the user still has "old style" history in their profile. + ref_history = profile.referral_history.split(",") + # Edge-case: single-ref history profiles only. + if isinstance(ref_history, int): + ref = ref_history + ref_history = [] + ref_history.append([ref, datetime.strftime(datetime.today(), "%d-%m-%Y")]) + # We're going to replace the existing list with a new one. + new_ref_history = [] + # Iterate through the list; it's either a list of unicode strings (old-style) + # or a list of lists (new-style). + for i in ref_history: + # Firstly if the item is a string, convert that to a list ([val, DATE]). + if isinstance(i, str): + i = [int(i), datetime.strftime(datetime.today(), "%d-%m-%Y")] + # If the referral that was passed in exists in the current list, pass (don't append it). + if referral.id == i[0]: + pass + else: + new_ref_history.append(i) + # Add the passed-in referral to the end of the new list. + new_ref_history.append([referral.id, datetime.strftime(datetime.today(), "%d-%m-%Y")]) + # History can be a maximum of 20 referrals; slice the new list accordingly. + if len(new_ref_history) > 20: + new_ref_history = new_ref_history[-20:] + # Save the updated user profile; serialise the new list of lists. + profile.referral_history = json.dumps(new_ref_history) + profile.save() + + +def user_task_history(user, task, comment=None): + """Utility function to update the task history in a user's profile.""" + profile = user.userprofile + if not profile.task_history: + task_history = [] + else: + task_history = json.loads(profile.task_history) + task_history.append([task.pk, datetime.strftime(datetime.today(), "%d-%m-%Y"), comment]) + profile.task_history = json.dumps(task_history) + profile.save() + + +def filter_queryset(request, model, queryset): + """ + Function to dynamically filter a model queryset, based upon the search_fields defined in + admin.py for that model. If search_fields is not defined, the queryset is returned unchanged. + """ + search_string = request.GET["q"] + # Replace single-quotes with double-quotes + search_string = search_string.replace("'", r'"') + if admin.site._registry[model].search_fields: + search_fields = admin.site._registry[model].search_fields + entry_query = get_query(search_string, search_fields) + queryset = queryset.filter(entry_query) + return queryset, search_string + + +def is_prs_user(request): + if "PRS user" not in [group.name for group in request.user.groups.all()]: + return False + return True + + +def is_prs_power_user(request): + if "PRS power user" not in [group.name for group in request.user.groups.all()]: + return False + return True + + +def prs_user(request): + return is_prs_user(request) or is_prs_power_user(request) or request.user.is_superuser + + +def update_revision_history(app_model): + """Function to bulk-update Version objects where the data model + is changed. This function is for reference, as these change will tend to + be one-off and customised. + + Example: the order_date field was added the the Record model, then later + changed from DateTime to Date. This change caused the deserialisation step + to fail for Record versions with a serialised DateTime. + """ + for v in Version.objects.all(): + # Deserialise the object version. + data = json.loads(v.serialized_data)[0] + if data["model"] == app_model: # Example: referral.record + pass + """ + # Do something to the deserialised data here, e.g.: + if 'order_date' in data['fields']: + if data['fields']['order_date']: + data['fields']['order_date'] = data['fields']['order_date'][:10] + v.serialized_data = json.dumps([data]) + v.save() + else: + data['fields']['order_date'] = '' + v.serialized_data = json.dumps([data]) + v.save() + """ + + +def overdue_task_email(): + """A utility function to send an email to each user with tasks that are overdue.""" + from django.contrib.auth.models import Group + + from .models import Task, TaskState + + prs_grp = Group.objects.get(name=settings.PRS_USER_GROUP) + users = prs_grp.user_set.filter(is_active=True) + ongoing_states = TaskState.objects.current().filter(is_ongoing=True) + + # For each user, send an email if they have any incomplete tasks that + # are in an 'ongoing' state (i.e. not stopped). + subject = "PRS overdue task notification" + from_email = settings.APPLICATION_ALERTS_EMAIL + + for user in users: + ongoing_tasks = Task.objects.current().filter( + complete_date=None, + state__in=ongoing_states, + due_date__lt=date.today(), + assigned_user=user, + ) + if ongoing_tasks.exists(): + # Send a single email to this user containing the list of tasks + to_email = [user.email] + text_content = """This is an automated message to let you know that the following tasks + assigned to you within PRS are currently overdue:\n""" + html_content = """

      This is an automated message to let you know that the following tasks + assigned to you within PRS are currently overdue:

      +
        """ + for t in ongoing_tasks: + text_content += "* Referral ID {} - {}\n".format(t.referral.pk, t.type.name) + html_content += '
      • Referral ID {} - {}
      • '.format( + settings.SITE_URL + t.referral.get_absolute_url(), + t.referral.pk, + t.type.name, + ) + text_content += "This is an automatically-generated email - please do not reply.\n" + html_content += "

      This is an automatically-generated email - please do not reply.

      " + msg = EmailMultiAlternatives(subject, text_content, from_email, to_email) + msg.attach_alternative(html_content, "text/html") + # Email should fail gracefully - ie no Exception raised on failure. + msg.send(fail_silently=True) + + return True + + +def wfs_getfeature(type_name, crs="EPSG:4326", cql_filter=None, max_features=50): + """A utility function to perform a GetFeature request on a WFS endpoint + and return results as GeoJSON. + """ + url = env("GEOSERVER_URL", None) + auth = (env("GEOSERVER_SSO_USER", None), env("GEOSERVER_SSO_PASS", None)) + params = { + "service": "WFS", + "version": "1.1.0", + "typeName": type_name, + "request": "getFeature", + "outputFormat": "json", + "SRSName": f"urn:x-ogc:def:crs:{crs}", + "maxFeatures": max_features, + } + if cql_filter: + params["cql_filter"] = cql_filter + resp = requests.get(url, auth=auth, params=params) + try: + resp.raise_for_status() + response = resp.json() + except: + # On exception, return an empty dict. + return {} + + return response + + +def query_caddy(q): + """Utility function to proxy queries to the Caddy geocoder service.""" + url = env("GEOCODER_URL", None) + auth = (env("GEOSERVER_SSO_USER", None), env("GEOSERVER_SSO_PASS", None)) + params = {"q": q} + resp = requests.get(url, auth=auth, params=params) + try: + resp.raise_for_status() + response = resp.json() + except: + # On exception, return an empty list. + return [] + + return response + + +def get_previous_pages(page_num, count=5): + """Convenience function to take a Paginator page object and return the previous `count` + page numbers, to a minimum of 1. + """ + prev_page_numbers = [] + + if page_num and page_num.has_previous(): + for i in range(page_num.previous_page_number(), page_num.previous_page_number() - count, -1): + if i >= 1: + prev_page_numbers.append(i) + + prev_page_numbers.reverse() + return prev_page_numbers + + +def get_next_pages(page_num, count=5): + """Convenience function to take a Paginator page object and return the next `count` + page numbers, to a maximum of the paginator page count. + """ + next_page_numbers = [] + + if page_num and page_num.has_next(): + for i in range(page_num.next_page_number(), page_num.next_page_number() + count): + if i <= page_num.paginator.num_pages: + next_page_numbers.append(i) + + return next_page_numbers diff --git a/prs2/referral/views.py b/prs2/referral/views.py index a3ab4951..96c7d695 100644 --- a/prs2/referral/views.py +++ b/prs2/referral/views.py @@ -10,8 +10,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import Group from django.contrib.gis.geos import GEOSGeometry, MultiPolygon -from django.core.cache import cache -from django.core.cache.utils import make_template_fragment_key from django.core.mail import EmailMultiAlternatives from django.core.paginator import Paginator from django.core.serializers import serialize @@ -713,11 +711,6 @@ def form_valid(self, form): if not messages.get_messages(self.request): messages.success(self.request, "{} has been created.".format(self.object)) - # Invalidate any cached referral detail fragment. - if settings.REDIS_CACHE_HOST: - key = make_template_fragment_key("referral_detail", [self.object.referral.pk]) - cache.delete(key) - redirect_url = redirect_url if redirect_url else self.get_success_url() return HttpResponseRedirect(redirect_url) @@ -1020,11 +1013,6 @@ def post(self, request, *args, **kwargs): messages.success(request, "{} location(s) created.".format(len(forms))) - # Invalidate any cached referral detail fragment. - if settings.REDIS_CACHE_HOST: - key = make_template_fragment_key("referral_detail", [ref.pk]) - cache.delete(key) - # Test for intersecting locations. intersecting_locations = self.polygon_intersects(locations) if intersecting_locations: @@ -1169,11 +1157,6 @@ def post(self, request, *args, **kargs): rec.order_date = date.today() rec.save() - # Invalidate the cached referral detail fragment. - if settings.REDIS_CACHE_HOST: - referral = rec.referral - key = make_template_fragment_key("referral_detail", [referral.pk]) - cache.delete(key) return JsonResponse( { @@ -1680,13 +1663,6 @@ def post(self, request, *args, **kwargs): ref1.remove_relationship(ref2) messages.success(request, "Referral relation removed") - # Invalidate the cached referral detail fragments. - if settings.REDIS_CACHE_HOST: - key = make_template_fragment_key("referral_detail", [ref1.pk]) - cache.delete(key) - key = make_template_fragment_key("referral_detail", [ref2.pk]) - cache.delete(key) - return redirect(ref1.get_absolute_url()) @@ -1741,11 +1717,6 @@ def post(self, request, *args, **kwargs): if request.POST.get("cancel"): return HttpResponseRedirect(reverse("prs_object_detail", kwargs={"pk": obj.pk, "model": "conditions"})) - # Invalidate any cached referral detail fragment. - if settings.REDIS_CACHE_HOST: - key = make_template_fragment_key("referral_detail", [obj.referral.pk]) - cache.delete(key) - return super().post(request, *args, **kwargs) def form_valid(self, form): diff --git a/prs2/referral/views_base.py b/prs2/referral/views_base.py index 155f33c6..00dea5c7 100644 --- a/prs2/referral/views_base.py +++ b/prs2/referral/views_base.py @@ -1,45 +1,24 @@ +import json +import logging + from django.conf import settings from django.contrib import messages from django.contrib.admin import site from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.cache import cache -from django.core.cache.utils import make_template_fragment_key from django.core.serializers import serialize from django.db.models import F -from django.urls import reverse -from django.http import ( - HttpResponse, - HttpResponseBadRequest, - HttpResponseRedirect, - Http404, -) +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import redirect from django.template.defaultfilters import slugify +from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import ( - View, - ListView, - DetailView, - CreateView, - UpdateView, - DeleteView, -) -import json -import logging +from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View +from referral.forms import FORMS_MAP +from referral.utils import breadcrumbs_li, get_next_pages, get_previous_pages, get_query, is_model_or_string, prs_user from reversion.models import Version from taggit.models import Tag -from referral.forms import FORMS_MAP -from referral.utils import ( - is_model_or_string, - breadcrumbs_li, - get_query, - prs_user, - get_previous_pages, - get_next_pages, -) - logger = logging.getLogger("prs") @@ -47,6 +26,7 @@ class PrsObjectList(LoginRequiredMixin, ListView): """A general-purpose view class to use for listing and searching PRS objects. Extend this class to customise the view for each model type. """ + paginate_by = 20 template_name = "referral/prs_object_list.html" @@ -60,8 +40,7 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_queryset(self): - """Define the queryset of objects to return. - """ + """Define the queryset of objects to return.""" qs = super().get_queryset() # By default, filter out "inactive" objects. if "effective_to" in [f.name for f in self.model._meta.get_fields()]: @@ -105,6 +84,7 @@ class to customise the 'create' view for each model type. Note that most PRS objects are associated with Referral models, thus they use the ``ReferralChildObjectCreate`` view instead. """ + template_name = "referral/change_form.html" def dispatch(self, request, *args, **kwargs): @@ -112,9 +92,7 @@ def dispatch(self, request, *args, **kwargs): messages.warning( request, """You do not have permission to edit data. - Please contact the application owner(s): {}""".format( - ", ".join([i[0] for i in settings.MANAGERS]) - ), + Please contact the application owner(s): {}""".format(", ".join([i[0] for i in settings.MANAGERS])), ) return HttpResponseRedirect(reverse("site_home")) @@ -130,9 +108,7 @@ def get_context_data(self, **kwargs): model_type = m.verbose_name.capitalize() context["model_type"] = model_type context["title"] = "CREATE {}".format(self.model._meta.object_name.upper()) - context["page_title"] = " | ".join( - [settings.APPLICATION_ACRONYM, "Create " + model_type] - ) + context["page_title"] = " | ".join([settings.APPLICATION_ACRONYM, "Create " + model_type]) context["page_heading"] = "CREATE " + model_type.upper() model_list_url = reverse( "prs_object_list", @@ -180,19 +156,6 @@ def form_valid(self, form): self.object.save() messages.success(self.request, "{0} has been created.".format(self.object)) - # Find the referral associated with this object (may be the object itself) - # and invalidate any cached detail fragment. - if settings.REDIS_CACHE_HOST: - if self.object._meta.model_name == "referral": - referral = self.object - elif hasattr(self.object, "referral"): - referral = self.object.referral - else: - referral = None - if referral: - key = make_template_fragment_key("referral_detail", [referral.pk]) - cache.delete(key) - return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): @@ -206,8 +169,8 @@ def get_success_url(self): class PrsObjectDetail(LoginRequiredMixin, DetailView): - """A general-purpose view class to use for displaying a single PRS object. - """ + """A general-purpose view class to use for displaying a single PRS object.""" + template_name = "referral/prs_object_detail.html" def dispatch(self, request, *args, **kwargs): @@ -217,7 +180,7 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): - from referral.models import Task, Record, Note, Location + from referral.models import Location, Note, Record, Task context = super().get_context_data(**kwargs) context["object_type"] = self.model._meta.verbose_name @@ -287,9 +250,7 @@ def get_context_data(self, **kwargs): if self.model == Location: # Add child locations serialised as GeoJSON (if geometry exists). if obj: - context["geojson_locations"] = serialize( - "geojson", [obj], geometry_field="poly", srid=4283 - ) + context["geojson_locations"] = serialize("geojson", [obj], geometry_field="poly", srid=4283) return context @@ -298,6 +259,7 @@ class PrsObjectUpdate(LoginRequiredMixin, UpdateView): using a form. Extend this class to customise the view for each model type. """ + template_name = "referral/change_form.html" def dispatch(self, request, *args, **kwargs): @@ -305,9 +267,7 @@ def dispatch(self, request, *args, **kwargs): messages.warning( request, """You do not have permission to edit data. - Please contact the application owner(s): {}""".format( - ", ".join([i[0] for i in settings.MANAGERS]) - ), + Please contact the application owner(s): {}""".format(", ".join([i[0] for i in settings.MANAGERS])), ) return HttpResponseRedirect(reverse("site_home")) @@ -326,9 +286,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) obj = self.get_object() context["title"] = "UPDATE {}".format(obj._meta.object_name).upper() - context["page_title"] = "PRS | {} | {} | Update".format( - obj._meta.verbose_name_plural.capitalize(), obj.pk - ) + context["page_title"] = "PRS | {} | {} | Update".format(obj._meta.verbose_name_plural.capitalize(), obj.pk) context["page_heading"] = "UPDATE " + obj._meta.verbose_name.upper() # Create a breadcrumb trail: Home[URL] > Model[URL] > ID > History context["breadcrumb_trail"] = breadcrumbs_li( @@ -358,21 +316,7 @@ def post(self, request, *args, **kwargs): def form_valid(self, form): self.object = form.save() - - # Find the referral associated with this object (may be the object itself) - # and invalidate any cached detail fragment. - if settings.REDIS_CACHE_HOST: - if self.object._meta.model_name == 'referral': - referral = self.object - else: - referral = self.object.referral - if referral: - key = make_template_fragment_key('referral_detail', [referral.pk]) - cache.delete(key) - - messages.success( - self.request, "{0} has been updated.".format(self.get_object()) - ) + messages.success(self.request, "{0} has been updated.".format(self.get_object())) return HttpResponseRedirect(self.get_success_url()) @@ -381,6 +325,7 @@ class PrsObjectHistory(PrsObjectDetail): history of a PRS object. Extend this class to customise the view for each model type. """ + template_name = "referral/prs_object_history.html" def dispatch(self, request, *args, **kwargs): @@ -393,12 +338,8 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) obj = self.get_object() context["title"] = "CHANGE HISTORY: {}".format(obj) - context["page_title"] = " | ".join( - [settings.APPLICATION_ACRONYM, self.get_object().__str__(), "History"] - ) - context["page_heading"] = ( - self.model._meta.verbose_name.upper() + " CHANGE HISTORY" - ) + context["page_title"] = " | ".join([settings.APPLICATION_ACRONYM, self.get_object().__str__(), "History"]) + context["page_heading"] = self.model._meta.verbose_name.upper() + " CHANGE HISTORY" # Create a breadcrumb trail: Home[URL] > Model[URL] > ID > History context["breadcrumb_trail"] = breadcrumbs_li( [ @@ -425,6 +366,7 @@ class PrsObjectDelete(LoginRequiredMixin, DeleteView): """A general-purpose view for confirming the deletion of a PRS object. Extend this class to customise the view for each model type. """ + template_name = "referral/prs_object_delete.html" def dispatch(self, request, *args, **kwargs): @@ -432,9 +374,7 @@ def dispatch(self, request, *args, **kwargs): messages.warning( request, """You do not have permission to edit data. - Please contact the application owner(s): {}""".format( - ", ".join([i[0] for i in settings.MANAGERS]) - ), + Please contact the application owner(s): {}""".format(", ".join([i[0] for i in settings.MANAGERS])), ) return HttpResponseRedirect(reverse("site_home")) @@ -498,17 +438,6 @@ def form_valid(self, form): else: success_url = self.get_success_url() - # Find the referral associated with this object (may be the object itself) - # and invalidate any cached detail fragment. - if settings.REDIS_CACHE_HOST: - if obj._meta.model_name == "referral": - referral = obj - else: - referral = obj.referral - if referral: - key = make_template_fragment_key("referral_detail", [referral.pk]) - cache.delete(key) - messages.success(self.request, f"{obj} has been deleted.") return HttpResponseRedirect(success_url) @@ -521,6 +450,7 @@ class PrsObjectTag(LoginRequiredMixin, View): Default to adding the tag, unless the ``delete`` query param is also passed in. """ + http_method_names = ["post"] model = None pk_url_kwarg = "pk" @@ -532,10 +462,7 @@ def dispatch(self, request, *args, **kwargs): self.model = is_model_or_string(kwargs["model"]) # is_model_or_string() returns None if the model doesn't exist. if not self.model: - raise AttributeError( - "Object tag view {} must be called with an " - "model.".format(self.__class__.__name__) - ) + raise AttributeError("Object tag view {} must be called with an " "model.".format(self.__class__.__name__)) return super().dispatch(request, *args, **kwargs) def get_queryset(self): @@ -550,22 +477,16 @@ def get_object(self, queryset=None): queryset = queryset.filter(pk=pk) else: raise AttributeError( - "Object tag view {} must be called with an " - "object pk.".format(self.__class__.__name__) + "Object tag view {} must be called with an " "object pk.".format(self.__class__.__name__) ) try: obj = queryset.get() except queryset.model.DoesNotExist: - raise Http404( - "No {} found matching the query".format( - queryset.model._meta.verbose_name_plural - ) - ) + raise Http404("No {} found matching the query".format(queryset.model._meta.verbose_name_plural)) return obj def post(self, request, *args, **kwargs): - """Handles POST requests, and adds or remove a tag on an object. - """ + """Handles POST requests, and adds or remove a tag on an object.""" obj = self.get_object() tag = request.POST.get("tag", None) if not tag: diff --git a/prs2/settings.py b/prs2/settings.py index f7197eda..cd0a3d15 100644 --- a/prs2/settings.py +++ b/prs2/settings.py @@ -176,7 +176,7 @@ } } API_RESPONSE_CACHE_SECONDS = env("API_RESPONSE_CACHE_SECONDS", 60) -CACHE_MIDDLEWARE_SECONDS = env("CACHE_MIDDLEWARE_SECONDS", 60) +CACHE_MIDDLEWARE_SECONDS = env("CACHE_MIDDLEWARE_SECONDS", 0) # Email settings EMAIL_HOST = env("EMAIL_HOST", "email.host") diff --git a/pyproject.toml b/pyproject.toml index dfad5e11..df9c8dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "prs" -version = "2.5.58" +version = "2.5.59" description = "Planning Referral System corporate application" authors = ["Ashley Felton "] license = "Apache-2.0" @@ -37,14 +37,14 @@ whitenoise = { version = "6.7.0", extras = ["brotli"] } django-crum = "0.7.9" sentry-sdk = { version = "2.14.0", extras = ["django"] } crispy-bootstrap5 = "2024.2" -redis = "5.1.0" +redis = "5.1.1" xlsxwriter = "3.2.0" django-storages = { version = "1.14.4", extras = ["azure"] } [tool.poetry.group.dev.dependencies] -ipython = "^8.27.0" +ipython = "^8.28.0" ipdb = "^0.13.13" -pre-commit = "^3.8.0" +pre-commit = "^4.0.0" coverage = "^7.5.4" [tool.ruff]