From e0553425982b5ba9085086bf69e80aa5a40f47a5 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Mon, 25 Mar 2024 14:08:44 +0800 Subject: [PATCH 01/40] Added SENTRY_PROFILES_SAMPLE_RATE env var to Kustomize overlays. --- kustomize/overlays/prod/deployment_patch.yaml | 5 +++++ kustomize/overlays/uat/deployment_patch.yaml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/kustomize/overlays/prod/deployment_patch.yaml b/kustomize/overlays/prod/deployment_patch.yaml index fb3a5c39f..383f70c85 100644 --- a/kustomize/overlays/prod/deployment_patch.yaml +++ b/kustomize/overlays/prod/deployment_patch.yaml @@ -116,5 +116,10 @@ spec: secretKeyRef: name: turtles-env-prod key: SENTRY_TRANSACTION_SAMPLE_RATE + - name: SENTRY_PROFILES_SAMPLE_RATE + valueFrom: + secretKeyRef: + name: turtles-env-prod + key: SENTRY_PROFILES_SAMPLE_RATE - name: SENTRY_ENVIRONMENT value: "prod" diff --git a/kustomize/overlays/uat/deployment_patch.yaml b/kustomize/overlays/uat/deployment_patch.yaml index 0ceb40af2..8f419659e 100644 --- a/kustomize/overlays/uat/deployment_patch.yaml +++ b/kustomize/overlays/uat/deployment_patch.yaml @@ -116,5 +116,10 @@ spec: secretKeyRef: name: turtles-env-uat key: SENTRY_TRANSACTION_SAMPLE_RATE + - name: SENTRY_PROFILES_SAMPLE_RATE + valueFrom: + secretKeyRef: + name: turtles-env-uat + key: SENTRY_PROFILES_SAMPLE_RATE - name: SENTRY_ENVIRONMENT value: "uat" From cc0bbf8cbcdb82101e615d61e5434f61d609c9d8 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 28 Mar 2024 09:49:17 +0800 Subject: [PATCH 02/40] Bugfix MAPPROXY_URL for prod overlay. --- kustomize/overlays/prod/deployment_patch.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kustomize/overlays/prod/deployment_patch.yaml b/kustomize/overlays/prod/deployment_patch.yaml index 383f70c85..b8b9b161d 100644 --- a/kustomize/overlays/prod/deployment_patch.yaml +++ b/kustomize/overlays/prod/deployment_patch.yaml @@ -9,7 +9,7 @@ spec: - name: turtles env: - name: MAPPROXY_URL - value: "https://mapproxy.dbca.wa.gov.au" + value: "https://mapproxy.dbca.wa.gov.au/service" - name: SITE_CODE value: "Turtles" - name: SITE_NAME From 0fda59f21205ffecb3c6072752b38faa67f5a107 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 28 Mar 2024 09:52:35 +0800 Subject: [PATCH 03/40] Tweak cronjob activeDeadlineSeconds values. --- kustomize/overlays/prod/cronjobs/automated-qa/patch.yaml | 1 + kustomize/overlays/prod/cronjobs/download-odk/patch.yaml | 2 -- .../prod/cronjobs/reconstruct-missing-surveys/patch.yaml | 1 + kustomize/overlays/uat/cronjobs/automated-qa/patch.yaml | 1 + kustomize/overlays/uat/cronjobs/download-odk/patch.yaml | 2 -- .../uat/cronjobs/reconstruct-missing-surveys/patch.yaml | 1 + kustomize/template/cronjob.yaml | 1 - 7 files changed, 4 insertions(+), 5 deletions(-) diff --git a/kustomize/overlays/prod/cronjobs/automated-qa/patch.yaml b/kustomize/overlays/prod/cronjobs/automated-qa/patch.yaml index f4ad7b49c..f30c59d68 100644 --- a/kustomize/overlays/prod/cronjobs/automated-qa/patch.yaml +++ b/kustomize/overlays/prod/cronjobs/automated-qa/patch.yaml @@ -7,6 +7,7 @@ spec: schedule: "30 4 * * *" jobTemplate: spec: + activeDeadlineSeconds: 300 template: spec: containers: diff --git a/kustomize/overlays/prod/cronjobs/download-odk/patch.yaml b/kustomize/overlays/prod/cronjobs/download-odk/patch.yaml index 5343cda0f..af07186a0 100644 --- a/kustomize/overlays/prod/cronjobs/download-odk/patch.yaml +++ b/kustomize/overlays/prod/cronjobs/download-odk/patch.yaml @@ -7,8 +7,6 @@ spec: schedule: "0 4 * * *" jobTemplate: spec: - # Allow up to 15 minutes for completion. - activeDeadlineSeconds: 900 template: spec: containers: diff --git a/kustomize/overlays/prod/cronjobs/reconstruct-missing-surveys/patch.yaml b/kustomize/overlays/prod/cronjobs/reconstruct-missing-surveys/patch.yaml index 7433faa93..eb160432a 100644 --- a/kustomize/overlays/prod/cronjobs/reconstruct-missing-surveys/patch.yaml +++ b/kustomize/overlays/prod/cronjobs/reconstruct-missing-surveys/patch.yaml @@ -7,6 +7,7 @@ spec: schedule: "0 14 * * *" jobTemplate: spec: + activeDeadlineSeconds: 300 template: spec: containers: diff --git a/kustomize/overlays/uat/cronjobs/automated-qa/patch.yaml b/kustomize/overlays/uat/cronjobs/automated-qa/patch.yaml index f378cfe85..afe06abd3 100644 --- a/kustomize/overlays/uat/cronjobs/automated-qa/patch.yaml +++ b/kustomize/overlays/uat/cronjobs/automated-qa/patch.yaml @@ -7,6 +7,7 @@ spec: schedule: "30 4 * * *" jobTemplate: spec: + activeDeadlineSeconds: 300 template: spec: containers: diff --git a/kustomize/overlays/uat/cronjobs/download-odk/patch.yaml b/kustomize/overlays/uat/cronjobs/download-odk/patch.yaml index 39cf27968..478347278 100644 --- a/kustomize/overlays/uat/cronjobs/download-odk/patch.yaml +++ b/kustomize/overlays/uat/cronjobs/download-odk/patch.yaml @@ -7,8 +7,6 @@ spec: schedule: "0 4 * * *" jobTemplate: spec: - # Allow up to 15 minutes for completion. - activeDeadlineSeconds: 900 template: spec: containers: diff --git a/kustomize/overlays/uat/cronjobs/reconstruct-missing-surveys/patch.yaml b/kustomize/overlays/uat/cronjobs/reconstruct-missing-surveys/patch.yaml index 89f68c837..b6c8c0407 100644 --- a/kustomize/overlays/uat/cronjobs/reconstruct-missing-surveys/patch.yaml +++ b/kustomize/overlays/uat/cronjobs/reconstruct-missing-surveys/patch.yaml @@ -7,6 +7,7 @@ spec: schedule: "0 14 * * *" jobTemplate: spec: + activeDeadlineSeconds: 300 template: spec: containers: diff --git a/kustomize/template/cronjob.yaml b/kustomize/template/cronjob.yaml index 34a0a646f..0f619fee5 100644 --- a/kustomize/template/cronjob.yaml +++ b/kustomize/template/cronjob.yaml @@ -10,7 +10,6 @@ spec: jobTemplate: spec: parallelism: 1 - activeDeadlineSeconds: 300 template: spec: restartPolicy: Never From 8d329364ceafeb1f0323221b4e393f2f54207d02 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 17 Apr 2024 11:38:21 +0800 Subject: [PATCH 04/40] Increment project dependency versions. --- kustomize/overlays/prod/kustomization.yaml | 2 +- poetry.lock | 350 +++++++++++---------- pyproject.toml | 12 +- 3 files changed, 184 insertions(+), 180 deletions(-) diff --git a/kustomize/overlays/prod/kustomization.yaml b/kustomize/overlays/prod/kustomization.yaml index 67d7004b8..03cdeb1db 100644 --- a/kustomize/overlays/prod/kustomization.yaml +++ b/kustomize/overlays/prod/kustomization.yaml @@ -22,4 +22,4 @@ patches: - path: service_patch.yaml images: - name: ghcr.io/dbca-wa/wastd - newTag: 2.0.1 + newTag: 2.0.2 diff --git a/poetry.lock b/poetry.lock index 9fb678a5f..98efce239 100644 --- a/poetry.lock +++ b/poetry.lock @@ -119,33 +119,33 @@ lxml = ["lxml"] [[package]] name = "black" -version = "24.3.0" +version = "24.4.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"}, - {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"}, - {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"}, - {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"}, - {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"}, - {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"}, - {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"}, - {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"}, - {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"}, - {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"}, - {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"}, - {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"}, - {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"}, - {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"}, - {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"}, - {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"}, - {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"}, - {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"}, - {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"}, - {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"}, - {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"}, - {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"}, + {file = "black-24.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436"}, + {file = "black-24.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf"}, + {file = "black-24.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad"}, + {file = "black-24.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb"}, + {file = "black-24.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8"}, + {file = "black-24.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745"}, + {file = "black-24.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070"}, + {file = "black-24.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397"}, + {file = "black-24.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"}, + {file = "black-24.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33"}, + {file = "black-24.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965"}, + {file = "black-24.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd"}, + {file = "black-24.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1"}, + {file = "black-24.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8"}, + {file = "black-24.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d"}, + {file = "black-24.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3"}, + {file = "black-24.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665"}, + {file = "black-24.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6"}, + {file = "black-24.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e"}, + {file = "black-24.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702"}, + {file = "black-24.4.0-py3-none-any.whl", hash = "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e"}, + {file = "black-24.4.0.tar.gz", hash = "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641"}, ] [package.dependencies] @@ -952,13 +952,13 @@ tablib = ["tablib"] [[package]] name = "docutils" -version = "0.20.1" +version = "0.21.1" description = "Docutils -- Python Documentation Utilities" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, - {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, + {file = "docutils-0.21.1-py3-none-any.whl", hash = "sha256:14c8d34a55b46c88f9f714adb29cefbdd69fb82f3fef825e59c5faab935390d8"}, + {file = "docutils-0.21.1.tar.gz", hash = "sha256:65249d8a5345bc95e0f40f280ba63c98eb24de35c6c8f5b662e3e8948adea83f"}, ] [[package]] @@ -988,38 +988,39 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth [[package]] name = "filelock" -version = "3.13.1" +version = "3.13.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, + {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, + {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +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-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] name = "gunicorn" -version = "21.2.0" +version = "22.0.0" description = "WSGI HTTP Server for UNIX" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" files = [ - {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, - {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, ] [package.dependencies] packaging = "*" [package.extras] -eventlet = ["eventlet (>=0.24.1)"] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] [[package]] @@ -1038,13 +1039,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -1075,13 +1076,13 @@ ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""} [[package]] name = "ipython" -version = "8.22.2" +version = "8.23.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.10" files = [ - {file = "ipython-8.22.2-py3-none-any.whl", hash = "sha256:3c86f284c8f3d8f2b6c662f885c4889a91df7cd52056fd02b7d8d6195d7f56e9"}, - {file = "ipython-8.22.2.tar.gz", hash = "sha256:2dcaad9049f9056f1fef63514f176c7d41f930daa78d05b82a176202818f2c14"}, + {file = "ipython-8.23.0-py3-none-any.whl", hash = "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1"}, + {file = "ipython-8.23.0.tar.gz", hash = "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d"}, ] [package.dependencies] @@ -1094,12 +1095,14 @@ prompt-toolkit = ">=3.0.41,<3.1.0" pygments = ">=2.4.0" stack-data = "*" traitlets = ">=5.13.0" +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} [package.extras] -all = ["ipython[black,doc,kernel,nbconvert,nbformat,notebook,parallel,qtconsole,terminal]", "ipython[test,test-extra]"] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] black = ["black"] doc = ["docrepr", "exceptiongroup", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "stack-data", "typing-extensions"] kernel = ["ipykernel"] +matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] notebook = ["ipywidgets", "notebook"] @@ -1239,13 +1242,13 @@ files = [ [[package]] name = "matplotlib-inline" -version = "0.1.6" +version = "0.1.7" description = "Inline Matplotlib backend for Jupyter" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, - {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, ] [package.dependencies] @@ -1421,18 +1424,18 @@ test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"] [[package]] name = "parso" -version = "0.8.3" +version = "0.8.4" description = "A Python Parser" optional = false python-versions = ">=3.6" files = [ - {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, - {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, ] [package.extras] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["docopt", "pytest (<6.0.0)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] [[package]] name = "pathspec" @@ -1472,79 +1475,80 @@ files = [ [[package]] name = "pillow" -version = "10.2.0" +version = "10.3.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" files = [ - {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"}, - {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"}, - {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"}, - {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"}, - {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"}, - {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"}, - {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"}, - {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"}, - {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"}, - {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"}, - {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"}, - {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"}, - {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"}, - {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"}, - {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"}, - {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"}, - {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"}, - {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"}, - {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"}, - {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"}, - {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"}, - {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"}, - {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"}, - {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"}, - {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"}, - {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"}, - {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"}, - {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"}, - {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"}, - {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"}, - {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"}, - {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"}, - {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"}, - {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"}, - {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"}, - {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, + {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, + {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, + {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, + {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, + {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, + {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, + {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, + {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, + {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, + {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, + {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, + {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, + {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, + {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, + {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, ] [package.extras] @@ -1651,13 +1655,13 @@ tests = ["pytest"] [[package]] name = "pycparser" -version = "2.21" +version = "2.22" description = "C parser in Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] [[package]] @@ -1857,39 +1861,39 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.3.4" +version = "0.3.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, - {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, - {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, - {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, - {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, - {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, + {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, + {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, + {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, + {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] [[package]] name = "sentry-sdk" -version = "1.42.0" +version = "1.45.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.42.0.tar.gz", hash = "sha256:4a8364b8f7edbf47f95f7163e48334c96100d9c098f0ae6606e2e18183c223e6"}, - {file = "sentry_sdk-1.42.0-py2.py3-none-any.whl", hash = "sha256:a654ee7e497a3f5f6368b36d4f04baeab1fe92b3105f7f6965d6ef0de35a9ba4"}, + {file = "sentry-sdk-1.45.0.tar.gz", hash = "sha256:509aa9678c0512344ca886281766c2e538682f8acfa50fd8d405f8c417ad0625"}, + {file = "sentry_sdk-1.45.0-py2.py3-none-any.whl", hash = "sha256:1ce29e30240cc289a027011103a8c83885b15ef2f316a60bcc7c5300afa144f1"}, ] [package.dependencies] @@ -1904,6 +1908,7 @@ asyncpg = ["asyncpg (>=0.23)"] beam = ["apache-beam (>=2.12)"] bottle = ["bottle (>=0.12.13)"] celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] chalice = ["chalice (>=1.16.0)"] clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] django = ["django (>=1.8)"] @@ -1930,18 +1935,18 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "69.2.0" +version = "69.5.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, - {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, + {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, + {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1979,20 +1984,20 @@ files = [ [[package]] name = "sphinx" -version = "7.2.6" +version = "7.3.4" description = "Python documentation generator" optional = false python-versions = ">=3.9" files = [ - {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"}, - {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"}, + {file = "sphinx-7.3.4-py3-none-any.whl", hash = "sha256:f67400c457599a6cfd967b681e093b43892b8df71596774b3128e31b6f0899eb"}, + {file = "sphinx-7.3.4.tar.gz", hash = "sha256:614826a7cf76f0a4525875c3ed55e2c3618f906897cb7ad53511c5fedcbb35aa"}, ] [package.dependencies] -alabaster = ">=0.7,<0.8" +alabaster = ">=0.7.14,<0.8.0" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.18.1,<0.21" +docutils = ">=0.18.1,<0.22" imagesize = ">=1.3" Jinja2 = ">=3.0" packaging = ">=21.0" @@ -2008,8 +2013,8 @@ sphinxcontrib-serializinghtml = ">=1.1.9" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] -test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"] +lint = ["flake8 (>=3.5.0)", "importlib_metadata", "mypy (==1.9.0)", "pytest (>=6.0)", "ruff (==0.3.7)", "sphinx-lint", "tomli", "types-docutils", "types-requests"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=6.0)", "setuptools (>=67.0)"] [[package]] name = "sphinxcontrib-applehelp" @@ -2107,19 +2112,18 @@ test = ["pytest"] [[package]] name = "sqlparse" -version = "0.4.4" +version = "0.5.0" description = "A non-validating SQL parser." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, - {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, + {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, + {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, ] [package.extras] -dev = ["build", "flake8"] +dev = ["build", "hatch"] doc = ["sphinx"] -test = ["pytest", "pytest-cov"] [[package]] name = "stack-data" @@ -2197,13 +2201,13 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0, [[package]] name = "typing-extensions" -version = "4.10.0" +version = "4.11.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, - {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] [[package]] @@ -2236,13 +2240,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.1" +version = "20.25.2" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, - {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, + {file = "virtualenv-20.25.2-py3-none-any.whl", hash = "sha256:6e1281a57849c8a54da89ba82e5eb7c8937b9d057ff01aaf5bc9afaa3552e90f"}, + {file = "virtualenv-20.25.2.tar.gz", hash = "sha256:fa7edb8428620518010928242ec17aa7132ae435319c29c1651d1cf4c4173aad"}, ] [package.dependencies] @@ -2251,7 +2255,7 @@ filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] @@ -2347,4 +2351,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1b1c8720e9a5ea43768962bed39362947e8aa02543f2898ec5c15606623d9a6b" +content-hash = "760f7ffec987936b17c13841610ef4b879b3d520bbb3679df212db5e2966e00a" diff --git a/pyproject.toml b/pyproject.toml index 24e418fda..5bb3b905b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wastd" -version = "2.0.1" +version = "2.0.2" description = "Western Australian Sea Turtles Database" authors = ["Florian Mayer ", "Ashley Felton ","Evan Hallein "] license = "MIT" @@ -12,7 +12,7 @@ django = "4.2.11" django-extensions = "3.2.3" psycopg2 = "2.9.9" dj-database-url = "2.1.0" -gunicorn = "21.2.0" +gunicorn = "22.0.0" python-dotenv = "1.0.1" dbca-utils = "2.0.2" django-fsm = "2.8.1" @@ -31,7 +31,7 @@ django-select2 = "8.0.0" django-geojson = "4.0.0" django-import-export = "3.3.7" django-export-download = "0.2.3" -pillow = "10.2.0" +pillow = "10.3.0" whitenoise = {version = "6.6.0", extras = ["brotli"]} phonenumbers = "8.13.32" django-phonenumber-field = "7.3.0" @@ -43,17 +43,17 @@ django-nested-admin = "^4.0.2" django-grappelli = "3.0.8" django-bootstrap4 = "24.1" django-easy-select2 = "1.5.8" -sentry-sdk = {version = "1.42.0", extras = ["django"]} +sentry-sdk = {version = "1.45.0", extras = ["django"]} [tool.poetry.group.dev.dependencies] -ipython = "^8.22.2" +ipython = "^8.23.0" ipdb = "^0.13.13" black = "^24.3.0" ruff = "^0.3.3" sphinx = "^7.0.1" vulture = "^2.7" django-debug-toolbar = "^4.2.0" -pre-commit = "^3.6.2" +pre-commit = "^3.7.0" [build-system] requires = ["poetry-core"] From f06b9f4f1caab8b10ddb2476fff4bf954cbb0e8c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 17 Apr 2024 15:33:27 +0800 Subject: [PATCH 05/40] Remove duplicated properties on Encounter model. --- observations/models.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/observations/models.py b/observations/models.py index e74f55914..afaa09260 100644 --- a/observations/models.py +++ b/observations/models.py @@ -1076,7 +1076,7 @@ def __str__(self): def status_colour(self): """Return a Bootstrap4 CSS colour class for each status.""" return self.STATUS_LABELS[self.status] - + @property def latitude(self): """Return the WGS 84 DD latitude.""" @@ -1198,8 +1198,6 @@ def get_curate_url(self): def get_flag_url(self): return reverse("observations:animalencounter-flag", kwargs={"pk": self.pk}) - # ------------------------------------------------------------------------- - # Derived properties def can_change(self): # Returns True if editing this object is permitted, False otherwise. # Determined by the object's QA status. @@ -1412,9 +1410,6 @@ def is_new_capture(self): """ return False - # HTML popup -------------------------------------------------------------# - - def get_popup(self): """Generate HTML popup content.""" t = loader.get_template("popup/{}.html".format(self._meta.model_name)) @@ -1424,27 +1419,17 @@ def get_report(self): """Generate an HTML report of the Encounter.""" t = loader.get_template("reports/{}.html".format(self._meta.model_name)) return mark_safe(t.render({"original": self})) - + @property def wkt(self): """Return the point coordinates as Well Known Text (WKT).""" return self.where.wkt - + @property def observation_set(self): """Manually implement the backwards relation to the Observation model.""" return Observation.objects.filter(encounter=self) - @property - def latitude(self): - """Return the WGS 84 DD latitude.""" - return self.where.y - - @property - def longitude(self): - """Return the WGS 84 DD longitude.""" - return self.where.x - @property def crs(self): """Return the location CRS.""" @@ -1697,7 +1682,6 @@ def short_name(self): nameparts.append(self.name) return slugify("-".join(nameparts)) - @property def is_stranding(self): """Return whether the Encounters is stranding or tagging. @@ -1911,7 +1895,7 @@ def get_nesttag_observation(self): """A turtle nest encounter should be associated with 0-1 NestTagObservation objects. Returns the related NestTagObservation or None. """ - + observation = self.observation_set.instance_of(NestTagObservation).first() return observation @@ -2730,10 +2714,10 @@ class TurtleNestDisturbanceObservation(Observation): """ NEST_VIABILITY_CHOICES = ( - ("negligible", "negligible disturbance"), - ("partly", "nest partly destroyed"), - ("completely", "nest completely destroyed"), - (lookups.NA_VALUE, "nest in indeterminate condition"), + ("negligible", "Negligible disturbance"), + ("partly", "Nest partly destroyed"), + ("completely", "Nest completely destroyed"), + (lookups.NA_VALUE, "Nest in indeterminate condition"), ) disturbance_cause = models.CharField( From a8154410c67a2f152ed2bd389fff996f334abac8 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 17 Apr 2024 15:37:41 +0800 Subject: [PATCH 06/40] Created ObservationResource and TurtleNestDisturbanceObservationResource classes. --- observations/admin.py | 4 +- observations/resources.py | 89 +++++++++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/observations/admin.py b/observations/admin.py index f3888194b..f59ce47f5 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -45,6 +45,7 @@ AnimalEncounterResource, TurtleNestEncounterResource, LineTransectEncounterResource, + TurtleNestDisturbanceObservationResource, ) @@ -509,7 +510,7 @@ def get_queryset(self, request): @register(TurtleNestDisturbanceObservation) -class TurtleNestDisturbanceObservationAdmin(ObservationAdminMixin): +class TurtleNestDisturbanceObservationAdmin(ExportActionMixin, ObservationAdminMixin): list_display = ( ObservationAdminMixin.LIST_FIRST @@ -525,6 +526,7 @@ class TurtleNestDisturbanceObservationAdmin(ObservationAdminMixin): "disturbance_cause_confidence", "disturbance_severity", ) + resource_classes = [TurtleNestDisturbanceObservationResource] def get_queryset(self, request): return ( diff --git a/observations/resources.py b/observations/resources.py index 36e154b3d..f37752778 100644 --- a/observations/resources.py +++ b/observations/resources.py @@ -1,5 +1,5 @@ from datetime import timedelta -from dateutil import tz +from django.conf import settings from import_export.fields import Field from import_export.resources import ModelResource @@ -10,6 +10,8 @@ TurtleNestEncounter, LineTransectEncounter, Survey, + Observation, + TurtleNestDisturbanceObservation, ) @@ -137,36 +139,36 @@ def dehydrate_longitude(self, obj): #excel can't deal with timezone objects so convert to a string. #Note this displayed using the Django set timezone NOT the timezone the encouter happened - this is potetially bad if it is different. Really should be showing the timezone of collection def dehydrate_when(self, obj): - return obj.when.astimezone(tz.tzlocal()).strftime("%d-%b-%Y %H:%M:%S") + return obj.when.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M:%S") #split the date def dehydrate_day(self, obj): if obj.when: - return obj.when.astimezone(tz.tzlocal()).day + return obj.when.astimezone(settings.TZ).day return '' def dehydrate_month(self, obj): if obj.when: - return obj.when.astimezone(tz.tzlocal()).month + return obj.when.astimezone(settings.TZ).month return '' def dehydrate_year(self, obj): if obj.when: - return obj.when.astimezone(tz.tzlocal()).year + return obj.when.astimezone(settings.TZ).year return '' #Note this displayed using the Django set timezone NOT the timezone the encouter happened - this is potetially bad if it is different. Really should be showing the timezone of collection def dehydrate_time(self, obj): if obj.when: - return obj.when.astimezone(tz.tzlocal()).strftime("%H:%M:%S") + return obj.when.astimezone(settings.TZ).strftime("%H:%M:%S") return '' #from 12pm to 12pm then next day, the date stays the same i.e 11:59am on 3/12/23 is 2/12/23 #Note this displayed using the Django set timezone NOT the timezone the encouter happened - this is potetially bad if it is different. Really should be showing the timezone of collection def dehydrate_turtle_time_day(self, obj): if obj.when: - if obj.when.astimezone(tz.tzlocal()).hour < 12: - adjusted_date = obj.when.astimezone(tz.tzlocal()) - timedelta(days=1) + if obj.when.astimezone(settings.TZ).hour < 12: + adjusted_date = obj.when.astimezone(settings.TZ) - timedelta(days=1) return adjusted_date.strftime("%d-%b-%Y") return obj.when.strftime("%d-%b-%Y") return '' @@ -479,7 +481,7 @@ def dehydrate_hatchling_emergence_time(self, encounter): if obs: atime = self.get_child_observation_output(obs, 'hatchling_emergence_time') if atime: - return atime.astimezone(tz.tzlocal()).strftime("%d-%b-%Y %H:%M:%S") + return atime.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M:%S") else: return '' @@ -562,3 +564,72 @@ class Meta: "encounter_type", "transect", ] + + +class ObservationResource(ModelResource): + + encounter_id = Field(column_name="encounter_id") + area = Field(column_name="area") + site = Field(column_name="site") + date = Field(column_name="date_awst") + latitude = Field(column_name="latitude") + longitude = Field(column_name="longitude") + encounter_status = Field(column_name="qa_status") + + class Meta: + model = Observation + fields = [ + "id", + "encounter_id", + "area", + "site", + "date", + "latitude", + "longitude", + "encounter_status", + ] + + def get_export_order(self): + return self._meta.fields + + def dehydrate_encounter_id(self, obj): + return obj.encounter_id + + def dehydrate_area(self, obj): + return obj.encounter.area.name if obj.encounter.area else "" + + def dehydrate_date(self, obj): + return obj.encounter.when.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M:%S") + + def dehydrate_site(self, obj): + return obj.encounter.site.name if obj.encounter.site else "" + + def dehydrate_latitude(self, obj): + return obj.encounter.latitude + + def dehydrate_longitude(self, obj): + return obj.encounter.longitude + + def dehydrate_encounter_status(self, obj): + return obj.encounter.get_status_display() + + +class TurtleNestDisturbanceObservationResource(ObservationResource): + + class Meta: + model = TurtleNestDisturbanceObservation + fields = ObservationResource.Meta.fields + [ + "disturbance_cause", + "disturbance_cause_confidence", + "disturbance_severity", + "comments", + ] + + def dehydrate_disturbance_cause(self, obj): + return obj.get_disturbance_cause_display() + + def dehydrate_disturbance_cause_confidence(self, obj): + return obj.get_disturbance_cause_confidence_display() + + def dehydrate_disturbance_severity(self, obj): + return obj.get_disturbance_severity_display() From d33a3b70bf4585a22a5e80662fd987bee1d8c021 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 18 Apr 2024 08:49:43 +0800 Subject: [PATCH 07/40] Replace dateutil.tz with settings.TZ --- observations/models.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/observations/models.py b/observations/models.py index afaa09260..7afd9ffc5 100644 --- a/observations/models.py +++ b/observations/models.py @@ -14,7 +14,6 @@ the turtle's morphometrics, physical damage, and nesting success). """ from datetime import timedelta -from dateutil import tz from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.gis.db import models @@ -274,10 +273,10 @@ def __str__(self): "-" if not self.destination else self.destination.name, "na" if not self.start_time - else self.start_time.astimezone(tz.tzlocal()).strftime("%Y-%m-%d"), + else self.start_time.astimezone(settings.TZ).strftime("%Y-%m-%d"), "na" if not self.end_time - else self.end_time.astimezone(tz.tzlocal()).strftime("%Y-%m-%d"), + else self.end_time.astimezone(settings.TZ).strftime("%Y-%m-%d"), ) @property @@ -528,9 +527,9 @@ def make_label(self): return "Survey {} of {} on {} from {} to {}".format( self.pk, "unknown site" if not self.site else self.site.name, - "NA" if not self.start_time else self.start_time.astimezone(tz.tzlocal()).strftime("%d-%b-%Y"), - "" if not self.start_time else self.start_time.astimezone(tz.tzlocal()).strftime("%H:%M"), - "" if not self.end_time else self.end_time.astimezone(tz.tzlocal()).strftime("%H:%M %Z"), + "NA" if not self.start_time else self.start_time.astimezone(settings.TZ).strftime("%d-%b-%Y"), + "" if not self.start_time else self.start_time.astimezone(settings.TZ).strftime("%H:%M"), + "" if not self.end_time else self.end_time.astimezone(settings.TZ).strftime("%H:%M %Z"), ) def label_short(self): @@ -553,7 +552,7 @@ def card_template(self): @property def start_date(self): """The calendar date of the survey's start time in the local timezone.""" - return self.start_time.astimezone(tz.tzlocal()).date() + return self.start_time.astimezone(settings.TZ).date() @property def duplicate_surveys(self): @@ -622,25 +621,25 @@ def close_duplicates(self, actor=None): msg += " {0} combined Encounters were found from duplicates between {1} and {2}.".format( all_encounters.count(), - earliest_enc.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), - latest_enc.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + earliest_enc.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), + latest_enc.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), ) if earliest_enc < self.start_time: msg += " Adjusted Survey start time from {0} to 30 mins before earliest Encounter, {1}.".format( - self.start_time.astimezone(tz.tzlocal()).strftime( + self.start_time.astimezone(settings.TZ).strftime( "%Y-%m-%d %H:%M %Z" ), - earliest_buffered.astimezone(tz.tzlocal()).strftime( + earliest_buffered.astimezone(settings.TZ).strftime( "%Y-%m-%d %H:%M %Z" ), ) self.start_time = earliest_buffered if latest_enc > self.end_time: msg += " Adjusted Survey end time from {0} to 30 mins after latest Encounter, {1}.".format( - self.end_time.astimezone(tz.tzlocal()).strftime( + self.end_time.astimezone(settings.TZ).strftime( "%Y-%m-%d %H:%M %Z" ), - latest_buffered.astimezone(tz.tzlocal()).strftime( + latest_buffered.astimezone(settings.TZ).strftime( "%Y-%m-%d %H:%M %Z" ), ) @@ -1217,7 +1216,7 @@ def card_template(self): def leaflet_title(self): """A string for Leaflet map marker titles. Cache me as field.""" return "{} {} {}".format( - self.when.astimezone(tz.tzlocal()).strftime("%d-%b-%Y %H:%M:%S") if self.when else "", + self.when.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M:%S") if self.when else "", self.get_encounter_type_display(), self.name or "", ).strip() @@ -1268,7 +1267,7 @@ def short_name(self): return slugify( "-".join( [ - self.when.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + self.when.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), force_str(round(self.longitude, 4)).replace(".", "-"), force_str(round(self.latitude, 4)).replace(".", "-"), ] @@ -1616,7 +1615,7 @@ def __str__(self): tpl = "AnimalEncounter {} on {} by {} of {}, {} {} {} on {}" return tpl.format( self.pk, - self.when.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + self.when.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), self.observer.name, self.get_species_display(), self.get_health_display(), @@ -1670,7 +1669,7 @@ def short_name(self): animals of the same species and deadness. """ nameparts = [ - self.when.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + self.when.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), force_str(round(self.longitude, 4)).replace(".", "-"), force_str(round(self.latitude, 4)).replace(".", "-"), self.health, @@ -1851,7 +1850,7 @@ def short_name(self): The short_name could be non-unique. """ nameparts = [ - self.when.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + self.when.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), force_str(round(self.longitude, 4)).replace(".", "-"), force_str(round(self.latitude, 4)).replace(".", "-"), self.nest_age, @@ -3100,7 +3099,7 @@ def short_name(self): The short_name could be non-unique. """ nameparts = [ - self.when.astimezone(tz.tzlocal()).strftime("%Y-%m-%d %H:%M %Z"), + self.when.astimezone(settings.TZ).strftime("%Y-%m-%d %H:%M %Z"), force_str(round(self.longitude, 4)).replace(".", "-"), force_str(round(self.latitude, 4)).replace(".", "-"), ] From 0a12ef1897c7a234af463a27e07018b2b9c6376b Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 18 Apr 2024 09:37:20 +0800 Subject: [PATCH 08/40] Added TurtleTrackObservationResource class. --- observations/admin.py | 5 ++++- observations/resources.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/observations/admin.py b/observations/admin.py index f59ce47f5..c234ec2a3 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -46,6 +46,7 @@ TurtleNestEncounterResource, LineTransectEncounterResource, TurtleNestDisturbanceObservationResource, + TurtleTrackObservationResource, ) @@ -483,7 +484,7 @@ def get_queryset(self, request): @register(TurtleTrackObservation) -class TurtleTrackObservationAdmin(ObservationAdminMixin): +class TurtleTrackObservationAdmin(ExportActionMixin, ObservationAdminMixin): list_display = ( ObservationAdminMixin.LIST_FIRST + ( @@ -491,9 +492,11 @@ class TurtleTrackObservationAdmin(ObservationAdminMixin): "max_track_width_rear", "carapace_drag_width", "step_length", + "tail_pokes", ) + ObservationAdminMixin.LIST_LAST ) + resource_classes = [TurtleTrackObservationResource] def get_queryset(self, request): return ( diff --git a/observations/resources.py b/observations/resources.py index f37752778..96f623e73 100644 --- a/observations/resources.py +++ b/observations/resources.py @@ -12,6 +12,7 @@ Survey, Observation, TurtleNestDisturbanceObservation, + TurtleTrackObservation, ) @@ -633,3 +634,31 @@ def dehydrate_disturbance_cause_confidence(self, obj): def dehydrate_disturbance_severity(self, obj): return obj.get_disturbance_severity_display() + + +class TurtleTrackObservationResource(ObservationResource): + + class Meta: + model = TurtleTrackObservation + fields = ObservationResource.Meta.fields + [ + "max_track_width_front", + "max_track_width_rear", + "carapace_drag_width", + "step_length", + "tail_pokes", + ] + + def dehydrate_max_track_width_front(self, obj): + return obj.max_track_width_front + + def dehydrate_max_track_width_rear(self, obj): + return obj.max_track_width_rear + + def dehydrate_carapace_drag_width(self, obj): + return obj.carapace_drag_width + + def dehydrate_step_length(self, obj): + return obj.step_length + + def dehydrate_tail_pokes(self, obj): + return obj.get_tail_pokes_display() if obj.tail_pokes else "" From afb6d7d2fdcb6f5e916dac877abd540e3c80aee1 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 18 Apr 2024 10:43:28 +0800 Subject: [PATCH 09/40] Remove settings.ADMINS variable. --- wastd/settings.py | 4 +--- wastd/urls.py | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/wastd/settings.py b/wastd/settings.py index c48076702..c9791319e 100644 --- a/wastd/settings.py +++ b/wastd/settings.py @@ -50,7 +50,7 @@ "polymorphic", # Before django.contrib.contenttypes "django.contrib.contenttypes", "grappelli.dashboard", - "grappelli", + "grappelli", # Before django.contrib.admin "django.contrib.admin", "nested_admin", "django.contrib.auth", @@ -219,8 +219,6 @@ SERVER_EMAIL = DEFAULT_FROM_EMAIL EMAIL_SUBJECT_PREFIX = os.environ.get("EMAIL_SUBJECT_PREFIX", "[Turtles DB] ") ADMIN_EMAILS = os.environ.get("ADMIN_EMAILS", "").split(",") -if not DEBUG: - ADMINS = [("Admin",os.environ.get("ADMIN_EMAILS", ""))] # Static files (CSS, JavaScript, Images) diff --git a/wastd/urls.py b/wastd/urls.py index a8dbeb9e9..5d8475efe 100644 --- a/wastd/urls.py +++ b/wastd/urls.py @@ -16,10 +16,8 @@ path("login/", LoginView.as_view(template_name="login.html"), name="login"), path("logout/", LogoutView.as_view(template_name="logged_out.html"), name="logout"), path("grappelli/", include("grappelli.urls")), - #path("grappelli-docs/", include('grappelli.urls_docs')), path('_nested_admin/', include('nested_admin.urls')), path("admin/", admin.site.urls), - #path("ajax_select/", include("ajax_select.urls")), path("select2/", include("django_select2.urls")), path("users/", include(("users.urls", "users"), namespace="users")), path("observations/", include(("observations.urls"), namespace="observations")), From adcf43709143463d53e90ae69f6869510606659f Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 18 Apr 2024 10:44:54 +0800 Subject: [PATCH 10/40] Add TurtleNestDisturbanceObservation and TurtleTrackObservation to dashboard. --- observations/views.py | 10 ++++------ wastd/dashboard.py | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/observations/views.py b/observations/views.py index f0297ebc8..1bbd870d0 100644 --- a/observations/views.py +++ b/observations/views.py @@ -104,7 +104,7 @@ class EncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): paginate_by = 20 filter_class = EncounterFilter resource_class = EncounterResource - resource_formats = ['csv', 'xlsx'] + resource_formats = ["csv", "xlsx"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -237,10 +237,8 @@ class TurtleNestEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, Li template_name = "default_list.html" paginate_by = 20 filter_class = TurtleNestEncounterFilter - resource_class = [ - TurtleNestEncounterResource, - ] - resource_formats = ['csv', 'xlsx'] + resource_class = TurtleNestEncounterResource + resource_formats = ["csv", "xlsx"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -287,7 +285,7 @@ class LineTransectEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, paginate_by = 20 filter_class = LineTransectEncounterFilter resource_class = LineTransectEncounterResource - resource_formats = ['csv', 'xlsx'] + resource_formats = ["csv", "xlsx"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/wastd/dashboard.py b/wastd/dashboard.py index 9fc017e6d..8b23f375c 100644 --- a/wastd/dashboard.py +++ b/wastd/dashboard.py @@ -22,6 +22,8 @@ def init_with_context(self, context): models=( "observations.models.AnimalEncounter", "observations.models.TurtleNestEncounter", + "observations.models.TurtleNestDisturbanceObservation", + "observations.models.TurtleTrackObservation", ), ), ], From d64b1349d7247cc6e710729ceb93b2c473c8d04c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Thu, 18 Apr 2024 15:38:07 +0800 Subject: [PATCH 11/40] Added TurtleNestDisturbanceObservationList view, navbar link. --- observations/admin.py | 36 +++++-------- observations/filters.py | 54 ++++++++++++++++++- observations/lookups.py | 2 +- observations/models.py | 5 +- observations/resources.py | 3 ++ ...tle_nest_disturbance_observation_card.html | 28 ++++++++++ observations/templatetags/observations.py | 2 +- observations/urls.py | 1 + observations/views.py | 26 +++++++++ wastd/templates/navbar.html | 13 ++++- 10 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 observations/templates/observations/turtle_nest_disturbance_observation_card.html diff --git a/observations/admin.py b/observations/admin.py index c234ec2a3..fb4e982b7 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -4,7 +4,6 @@ from django.contrib.admin import register, ModelAdmin, StackedInline, SimpleListFilter from django.contrib.admin.filters import RelatedFieldListFilter from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ from django.urls import reverse from django_select2.forms import ModelSelect2Widget from easy_select2 import select2_modelform as s2form @@ -247,43 +246,36 @@ class ObservationAdminMixin(VersionAdmin, ModelAdmin): formfield_overrides = FORMFIELD_OVERRIDES def area(self, obj): - """Make data source readable.""" return obj.encounter.area area.short_description = "Area" def site(self, obj): - """Make data source readable.""" return obj.encounter.site site.short_description = "Site" def status(self, obj): - """Make health status human readable.""" return obj.encounter.get_status_display() status.short_description = "Status" def latitude(self, obj): - """Make data source readable.""" return obj.encounter.latitude latitude.short_description = "Latitude" def longitude(self, obj): - """Make data source readable.""" return obj.encounter.longitude longitude.short_description = "Longitude" def date(self, obj): - """Make data source readable.""" return obj.encounter.when date.short_description = "Date" def encounter_link(self, obj): - """A link to the encounter.""" return mark_safe( '{1}'.format( obj.encounter.absolute_admin_url, obj.encounter.__str__() @@ -294,7 +286,6 @@ def encounter_link(self, obj): encounter_link.allow_tags = True def encounter_status(self, obj): - """A link to the encounter.""" return obj.encounter.get_status_display() encounter_status.short_description = "QA status" @@ -432,19 +423,16 @@ class TagObservationAdmin(ObservationAdminMixin): form = s2form(TagObservation, attrs=S2ATTRS) def type_display(self, obj): - """Make tag type human readable.""" return obj.get_tag_type_display() type_display.short_description = "Tag Type" def tag_location_display(self, obj): - """Make tag side human readable.""" return obj.get_tag_location_display() tag_location_display.short_description = "Tag Location" def animal_name(self, obj): - """Animal name.""" return obj.encounter.name animal_name.short_description = "Animal Name" @@ -526,6 +514,7 @@ class TurtleNestDisturbanceObservationAdmin(ExportActionMixin, ObservationAdminM + ObservationAdminMixin.LIST_LAST ) list_filter = ObservationAdminMixin.LIST_FILTER + ( + "disturbance_cause", "disturbance_cause_confidence", "disturbance_severity", ) @@ -606,7 +595,6 @@ class NestTagObservationAdmin(ObservationAdminMixin): search_fields = ("flipper_tag_id", "date_nest_laid", "tag_label", "comments") def tag_name(self, obj): - """Nest tag name.""" return obj.name tag_name.short_description = "Complete Nest Tag" @@ -897,7 +885,7 @@ class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): resource_classes = [SurveyResource] fieldsets = ( ( - _("Device"), + "Device", { "classes": ("grp-collapse", "grp-open", "wide", "extrapretty"), "fields": ( @@ -911,7 +899,7 @@ class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): }, ), ( - _("Location"), + "Location", { "classes": ("grp-collapse", "grp-open", "wide", "extrapretty"), "fields": ( @@ -924,7 +912,7 @@ class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): }, ), ( - _("Time"), + "Time", { "classes": ("grp-collapse", "grp-open", "wide", "extrapretty"), "fields": ( @@ -934,14 +922,14 @@ class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): }, ), ( - _("Campaign"), + "Campaign", { "classes": ("grp-collapse", "grp-open", "wide", "extrapretty"), "fields": ("campaign",), }, ), ( - _("Team"), + "Team", { "classes": ("grp-collapse", "grp-open", "wide", "extrapretty"), "fields": ( @@ -1061,12 +1049,12 @@ class QAStatusFilter(SimpleListFilter): def lookups(self, request, model_admin): return ( - (Encounter.STATUS_NEW, _("New")), - (Encounter.STATUS_IMPORTED, _("Imported")), - (Encounter.STATUS_MANUAL_INPUT, _("Manual input")), - (Encounter.STATUS_CURATED, _("Curated")), - (Encounter.STATUS_FLAGGED, _("Flagged")), - (Encounter.STATUS_REJECTED, _("Rejected")), + (Encounter.STATUS_NEW, "New"), + (Encounter.STATUS_IMPORTED, "Imported"), + (Encounter.STATUS_MANUAL_INPUT, "Manual input"), + (Encounter.STATUS_CURATED, "Curated"), + (Encounter.STATUS_FLAGGED, "Flagged"), + (Encounter.STATUS_REJECTED, "Rejected"), ) def queryset(self, request, queryset): diff --git a/observations/filters.py b/observations/filters.py index 2f00dc49f..5861f8fc2 100644 --- a/observations/filters.py +++ b/observations/filters.py @@ -14,6 +14,7 @@ AnimalEncounter, TurtleNestEncounter, LineTransectEncounter, + TurtleNestDisturbanceObservation, ) from .lookups import ( HEALTH_CHOICES, @@ -168,7 +169,6 @@ class Meta: "site", "species", "health", - #"checked_for_flipper_tags", ] @@ -194,7 +194,7 @@ class TurtleNestEncounterFilter(FilterSet): survey = ChoiceFilter( field_name="survey", choices=( - ("null",""), + ("null",""), ), label="Survey", ) @@ -270,3 +270,53 @@ class Meta(EncounterFilter.Meta): fields = EncounterFilter._meta.fields + [ "transect", ] + + +class TurtleNestDisturbanceObservationFilter(FilterSet): + date_from = DateFilter( + field_name="encounter__when", + lookup_expr="date__gte", + label="Date from", + input_formats=settings.DATE_INPUT_FORMATS, + ) + date_to = DateFilter( + field_name="encounter__when", + lookup_expr="date__lte", + label="Date to", + input_formats=settings.DATE_INPUT_FORMATS, + ) + area = ModelChoiceFilter( + field_name="encounter__area", + label="Locality", + queryset=Area.objects.filter(area_type__in=[Area.AREATYPE_LOCALITY]).order_by("name"), + ) + site = ModelChoiceFilter( + field_name="encounter__site", + label="Site", + queryset=Area.objects.filter(area_type__in=[Area.AREATYPE_SITE]).order_by("name"), + null_label="", + ) + status = ChoiceFilter( + field_name="encounter__status", + choices=( + (Encounter.STATUS_IMPORTED, "Imported"), + (Encounter.STATUS_MANUAL_INPUT, "Manual input"), + (Encounter.STATUS_CURATED, "Curated"), + (Encounter.STATUS_FLAGGED, "Flagged"), + (Encounter.STATUS_REJECTED, "Rejected"), + ), + label="QA status", + ) + + class Meta: + model = TurtleNestDisturbanceObservation + fields = [ + "date_from", + "date_to", + "area", + "site", + "status", + "disturbance_cause", + "disturbance_cause_confidence", + "disturbance_severity", + ] diff --git a/observations/lookups.py b/observations/lookups.py index f41dc3618..9f55bfdc7 100644 --- a/observations/lookups.py +++ b/observations/lookups.py @@ -313,7 +313,7 @@ ) CONFIDENCE_CHOICES = NA + ( - ("guess", "Guess based on insuffient evidence"), + ("guess", "Guess based on insufficient evidence"), ("expert-opinion", "Expert opinion based on available evidence"), ("validated", "Validated by authoritative source"), ) diff --git a/observations/models.py b/observations/models.py index 7afd9ffc5..47f7baa68 100644 --- a/observations/models.py +++ b/observations/models.py @@ -2744,7 +2744,10 @@ class TurtleNestDisturbanceObservation(Observation): ) def __str__(self): - return f"{self.pk}: Nest disturbance {self.disturbance_severity} by {self.disturbance_cause}" + return f"{self.pk}: Nest disturbance by {self.get_disturbance_cause_display().lower()}, {self.get_disturbance_severity_display().lower()}" + + def card_template(self): + return "observations/turtle_nest_disturbance_observation_card.html" class TurtleTrackObservation(Observation): diff --git a/observations/resources.py b/observations/resources.py index 96f623e73..25ae98272 100644 --- a/observations/resources.py +++ b/observations/resources.py @@ -662,3 +662,6 @@ def dehydrate_step_length(self, obj): def dehydrate_tail_pokes(self, obj): return obj.get_tail_pokes_display() if obj.tail_pokes else "" + + +# TODO: HatchlingMorphometricObservation, LightSourceObservation, LoggerObservation diff --git a/observations/templates/observations/turtle_nest_disturbance_observation_card.html b/observations/templates/observations/turtle_nest_disturbance_observation_card.html new file mode 100644 index 000000000..a8eea9e85 --- /dev/null +++ b/observations/templates/observations/turtle_nest_disturbance_observation_card.html @@ -0,0 +1,28 @@ +{% load observations bootstrap4 fsm_admin %} +
+
+
+ + QA status: {{ object.encounter.get_status_display }} + +
{{ object }}
+
+
+
+ + {{ object.encounter.when|date:'d M Y H:i' }} + {% if object.encounter.site %} + + {{ object.encounter.site.name }} + {% endif %} + + + Turtle nest encounter {{ object.encounter.pk }} + + +
+ + {{ object.get_disturbance_cause_confidence_display }} +
+
+
diff --git a/observations/templatetags/observations.py b/observations/templatetags/observations.py index 4d72ea950..eced57062 100644 --- a/observations/templatetags/observations.py +++ b/observations/templatetags/observations.py @@ -11,7 +11,7 @@ @register.simple_tag def get_verbose_name(object): - return object._meta.verbose_name + return object._meta.verbose_name.capitalize() @register.inclusion_tag("tx_logs.html", takes_context=False) diff --git a/observations/urls.py b/observations/urls.py index 7fc2b0d5c..95815dd33 100644 --- a/observations/urls.py +++ b/observations/urls.py @@ -21,4 +21,5 @@ path("turtle-nest-encounters//reject/", views.TurtleNestEncounterReject.as_view(), name="turtlenestencounter-reject"), path("line-transect-encounters/", views.LineTransectEncounterList.as_view(), name="linetransectencounter-list"), path("line-transect-encounters//", views.LineTransectEncounterDetail.as_view(), name="linetransectencounter-detail"), + path("turtle-nest-disturbance-observations/", views.TurtleNestDisturbanceObservationList.as_view(), name="turtlenestdisturbanceobservation-list"), ] diff --git a/observations/views.py b/observations/views.py index 1bbd870d0..b8ec6af48 100644 --- a/observations/views.py +++ b/observations/views.py @@ -19,6 +19,7 @@ AnimalEncounterAdmin, TurtleNestEncounterAdmin, LineTransectEncounterAdmin, + TurtleNestDisturbanceObservationAdmin, ) from .filters import ( SurveyFilter, @@ -26,6 +27,7 @@ AnimalEncounterFilter, TurtleNestEncounterFilter, LineTransectEncounterFilter, + TurtleNestDisturbanceObservationFilter, ) from .models import ( Survey, @@ -34,6 +36,7 @@ TurtleNestEncounter, LineTransectEncounter, TagObservation, + TurtleNestDisturbanceObservation, ) from .resources import ( SurveyResource, @@ -41,6 +44,7 @@ AnimalEncounterResource, TurtleNestEncounterResource, LineTransectEncounterResource, + TurtleNestDisturbanceObservationResource, ) @@ -279,6 +283,28 @@ class TurtleNestEncounterReject(EncounterReject): model = TurtleNestEncounter +class TurtleNestDisturbanceObservationList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): + model = TurtleNestDisturbanceObservation + template_name = "default_list.html" + paginate_by = 20 + filter_class = TurtleNestDisturbanceObservationFilter + resource_class = TurtleNestDisturbanceObservationResource + resource_formats = ["csv", "xlsx"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + qs = self.get_queryset() + context["list_filter"] = TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=qs) + context["model_admin"] = TurtleNestDisturbanceObservationAdmin + context["object_count"] = qs.count() + context["page_title"] = f"{settings.SITE_CODE} | Turtle nest disturbances" + return context + + def get_queryset(self): + qs = super().get_queryset() + return TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=qs).qs + + class LineTransectEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): model = LineTransectEncounter template_name = "default_list.html" diff --git a/wastd/templates/navbar.html b/wastd/templates/navbar.html index 3bad77ffa..7f717c7d4 100644 --- a/wastd/templates/navbar.html +++ b/wastd/templates/navbar.html @@ -51,8 +51,10 @@ Nests with fan angles + + @@ -83,6 +85,15 @@ Track/nest encounters with no survey + + + + + + + Turtle nest disturbance observations + @@ -95,7 +106,7 @@ Tagged turtles - + From d5dde25064d0f87521f6bf755e1c719d70fde075 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 19 Apr 2024 09:55:27 +0800 Subject: [PATCH 12/40] Set ordering on Observation model class, update lookup values. --- observations/lookups.py | 1 + ...0014_alter_observation_options_and_more.py | 65 +++++++++++++++++++ observations/models.py | 3 + ...tions_alter_trtpersons_options_and_more.py | 42 ++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 observations/migrations/0014_alter_observation_options_and_more.py create mode 100644 wamtram2/migrations/0004_alter_trtdataentry_options_alter_trtpersons_options_and_more.py diff --git a/observations/lookups.py b/observations/lookups.py index 9f55bfdc7..4a28d0a45 100644 --- a/observations/lookups.py +++ b/observations/lookups.py @@ -314,6 +314,7 @@ CONFIDENCE_CHOICES = NA + ( ("guess", "Guess based on insufficient evidence"), + ("certain", "Certainty based on local evidence"), ("expert-opinion", "Expert opinion based on available evidence"), ("validated", "Validated by authoritative source"), ) diff --git a/observations/migrations/0014_alter_observation_options_and_more.py b/observations/migrations/0014_alter_observation_options_and_more.py new file mode 100644 index 000000000..4b9da7562 --- /dev/null +++ b/observations/migrations/0014_alter_observation_options_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.11 on 2024-04-19 01:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("observations", "0013_alter_encounter_polymorphic_ctype_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="observation", + options={"ordering": ("-pk",)}, + ), + migrations.AlterField( + model_name="animalencounter", + name="cause_of_death_confidence", + field=models.CharField( + choices=[ + ("na", "Not applicable"), + ("guess", "Guess based on insufficient evidence"), + ("certain", "Certainty based on local evidence"), + ("expert-opinion", "Expert opinion based on available evidence"), + ("validated", "Validated by authoritative source"), + ], + default="na", + help_text="What is the cause of death, if known, based on?", + max_length=300, + ), + ), + migrations.AlterField( + model_name="turtlenestdisturbanceobservation", + name="disturbance_cause_confidence", + field=models.CharField( + choices=[ + ("na", "Not applicable"), + ("guess", "Guess based on insufficient evidence"), + ("certain", "Certainty based on local evidence"), + ("expert-opinion", "Expert opinion based on available evidence"), + ("validated", "Validated by authoritative source"), + ], + default="na", + help_text="What is the choice of disturbance cause based on?", + max_length=300, + verbose_name="Disturbance cause choice confidence", + ), + ), + migrations.AlterField( + model_name="turtlenestdisturbanceobservation", + name="disturbance_severity", + field=models.CharField( + choices=[ + ("negligible", "Negligible disturbance"), + ("partly", "Nest partly destroyed"), + ("completely", "Nest completely destroyed"), + ("na", "Nest in indeterminate condition"), + ], + default="na", + help_text="The impact of the disturbance on nest viability.", + max_length=300, + ), + ), + ] diff --git a/observations/models.py b/observations/models.py index 47f7baa68..91d748355 100644 --- a/observations/models.py +++ b/observations/models.py @@ -1920,6 +1920,9 @@ class Observation(PolymorphicModel, LegacySourceMixin, models.Model): help_text="The Encounter during which the observation was made", ) + class Meta: + ordering = ("-pk",) + def __str__(self): return f"Observation {self.pk} for {self.encounter}" diff --git a/wamtram2/migrations/0004_alter_trtdataentry_options_alter_trtpersons_options_and_more.py b/wamtram2/migrations/0004_alter_trtdataentry_options_alter_trtpersons_options_and_more.py new file mode 100644 index 000000000..01e8c1ea9 --- /dev/null +++ b/wamtram2/migrations/0004_alter_trtdataentry_options_alter_trtpersons_options_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.11 on 2024-04-19 01:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("wamtram2", "0003_alter_trtmeasurements_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="trtdataentry", + options={"managed": False, "ordering": ["-data_entry_id"]}, + ), + migrations.AlterModelOptions( + name="trtpersons", + options={ + "managed": False, + "ordering": ["first_name", "surname"], + "verbose_name": "Person", + "verbose_name_plural": "People", + }, + ), + migrations.AlterModelOptions( + name="trtpittags", + options={ + "managed": False, + "ordering": ["pittag_id"], + "verbose_name": "Pit tag", + }, + ), + migrations.AlterModelOptions( + name="trttagorders", + options={ + "managed": False, + "verbose_name": "Tag Order", + "verbose_name_plural": "Tag Orders", + }, + ), + ] From 191cb3119e8d6fe0f6c4849714508c7dd49c9475 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 19 Apr 2024 10:28:45 +0800 Subject: [PATCH 13/40] Tweaks to turtle nest disturbance observation card template. --- observations/filters.py | 2 +- observations/models.py | 2 +- ...rtle_nest_disturbance_observation_card.html | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/observations/filters.py b/observations/filters.py index 5861f8fc2..044894e6f 100644 --- a/observations/filters.py +++ b/observations/filters.py @@ -316,7 +316,7 @@ class Meta: "area", "site", "status", + "disturbance_severity", "disturbance_cause", "disturbance_cause_confidence", - "disturbance_severity", ] diff --git a/observations/models.py b/observations/models.py index 91d748355..ef99cfd58 100644 --- a/observations/models.py +++ b/observations/models.py @@ -2005,7 +2005,7 @@ class MediaAttachment(Observation): ) def __str__(self): - return f"Media attachment {self.pk} for encounter {self.encounter.pk}: {self.attachment.name}" + return f"Media attachment {self.pk} for encounter {self.encounter.pk}" @property def filepath(self): diff --git a/observations/templates/observations/turtle_nest_disturbance_observation_card.html b/observations/templates/observations/turtle_nest_disturbance_observation_card.html index a8eea9e85..62d4f4548 100644 --- a/observations/templates/observations/turtle_nest_disturbance_observation_card.html +++ b/observations/templates/observations/turtle_nest_disturbance_observation_card.html @@ -5,24 +5,32 @@ QA status: {{ object.encounter.get_status_display }} -
{{ object }}
+
{{ object.pk}}: {{ object.get_disturbance_severity_display }}
{{ object.encounter.when|date:'d M Y H:i' }} {% if object.encounter.site %} - + {{ object.encounter.site.name }} {% endif %} - Turtle nest encounter {{ object.encounter.pk }} + Turtle nest encounter {{ object.encounter.pk }}
- - {{ object.get_disturbance_cause_confidence_display }}
+
+
+ + {{ object.get_disturbance_cause_display }} +
+
+ + {{ object.get_disturbance_cause_confidence_display }} +
+
From f8d9e7521cfe2086e5e1f605a4a5f6696616b2a5 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 19 Apr 2024 13:56:03 +0800 Subject: [PATCH 14/40] Added Kustomize resources: HorizontalPodAutoscaler for deployment workloads. --- kustomize/base/deployment.yaml | 1 - kustomize/base/deployment_hpa.yaml | 17 +++++++++++++++++ kustomize/base/kustomization.yaml | 1 + .../overlays/prod/deployment_hpa_patch.yaml | 7 +++++++ kustomize/overlays/prod/kustomization.yaml | 1 + .../overlays/uat/deployment_hpa_patch.yaml | 7 +++++++ kustomize/overlays/uat/kustomization.yaml | 1 + 7 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 kustomize/base/deployment_hpa.yaml create mode 100644 kustomize/overlays/prod/deployment_hpa_patch.yaml create mode 100644 kustomize/overlays/uat/deployment_hpa_patch.yaml diff --git a/kustomize/base/deployment.yaml b/kustomize/base/deployment.yaml index 244e8fe6b..3764e728c 100644 --- a/kustomize/base/deployment.yaml +++ b/kustomize/base/deployment.yaml @@ -8,7 +8,6 @@ spec: selector: matchLabels: app: turtles-deployment - replicas: 2 strategy: type: RollingUpdate template: diff --git a/kustomize/base/deployment_hpa.yaml b/kustomize/base/deployment_hpa.yaml new file mode 100644 index 000000000..a311edf5a --- /dev/null +++ b/kustomize/base/deployment_hpa.yaml @@ -0,0 +1,17 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: turtles-deployment-hpa +spec: + minReplicas: 1 + maxReplicas: 3 + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + metrics: + - resource: + name: cpu + target: + type: Utilization + averageUtilization: 250 + type: Resource diff --git a/kustomize/base/kustomization.yaml b/kustomize/base/kustomization.yaml index a33121c38..415d84cd2 100644 --- a/kustomize/base/kustomization.yaml +++ b/kustomize/base/kustomization.yaml @@ -2,4 +2,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml + - deployment_hpa.yaml - service.yaml diff --git a/kustomize/overlays/prod/deployment_hpa_patch.yaml b/kustomize/overlays/prod/deployment_hpa_patch.yaml new file mode 100644 index 000000000..59d9b9c64 --- /dev/null +++ b/kustomize/overlays/prod/deployment_hpa_patch.yaml @@ -0,0 +1,7 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: turtles-deployment-hpa +spec: + scaleTargetRef: + name: turtles-deployment-prod diff --git a/kustomize/overlays/prod/kustomization.yaml b/kustomize/overlays/prod/kustomization.yaml index 03cdeb1db..fbceb56f4 100644 --- a/kustomize/overlays/prod/kustomization.yaml +++ b/kustomize/overlays/prod/kustomization.yaml @@ -19,6 +19,7 @@ labels: includeSelectors: true patches: - path: deployment_patch.yaml + - path: deployment_hpa_patch.yaml - path: service_patch.yaml images: - name: ghcr.io/dbca-wa/wastd diff --git a/kustomize/overlays/uat/deployment_hpa_patch.yaml b/kustomize/overlays/uat/deployment_hpa_patch.yaml new file mode 100644 index 000000000..baa8ff408 --- /dev/null +++ b/kustomize/overlays/uat/deployment_hpa_patch.yaml @@ -0,0 +1,7 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: turtles-deployment-hpa +spec: + scaleTargetRef: + name: turtles-deployment-uat diff --git a/kustomize/overlays/uat/kustomization.yaml b/kustomize/overlays/uat/kustomization.yaml index 63f8ed1d7..0ca0067f2 100644 --- a/kustomize/overlays/uat/kustomization.yaml +++ b/kustomize/overlays/uat/kustomization.yaml @@ -19,4 +19,5 @@ labels: includeSelectors: true patches: - path: deployment_patch.yaml + - path: deployment_hpa_patch.yaml - path: service_patch.yaml From 060d115b83d1417ba0822149e3bc2a9d4a2cd57c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 19 Apr 2024 13:58:32 +0800 Subject: [PATCH 15/40] Override export_download template to restyle. --- wastd/templates/default_list.html | 17 ++++++------- .../export_download/export_download_menu.html | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 wastd/templates/export_download/export_download_menu.html diff --git a/wastd/templates/default_list.html b/wastd/templates/default_list.html index 73a3ffb8e..e787d88c4 100644 --- a/wastd/templates/default_list.html +++ b/wastd/templates/default_list.html @@ -16,7 +16,7 @@ {% block page_content_inner %} {% block pagination_row %} -
+
{% if is_paginated %} {% load proper_paginate %} @@ -50,15 +50,14 @@
  • {% endif %} -{% endif %} + {% endif %} - {% if object_list %} - {% resource_download_menu %} - {% if object_count %}
    Found {{ object_count }} records
    {% endif %} - - {% endif %} -
    -
    + {% if object_list %} + {% resource_download_menu 'btn-secondary' %} + {% if object_count %}
    Found {{ object_count }} records
    {% endif %} + {% endif %} +
    + {% endblock pagination_row %}
    diff --git a/wastd/templates/export_download/export_download_menu.html b/wastd/templates/export_download/export_download_menu.html new file mode 100644 index 000000000..b034e7ea5 --- /dev/null +++ b/wastd/templates/export_download/export_download_menu.html @@ -0,0 +1,25 @@ +{% if resources %} +
    + {% for format_name, format in resources.items %} + {% for f in format %} + {% if forloop.first %} + + + {{ format_name|upper }} + + {% else %} + {% if forloop.counter0 == 1 %} + + + {% endif %} + {% endif %} + {% endfor %} + {% endfor %} +
    +{% endif %} From 1c2625f46033faff673315cdd5b334358ff84a0f Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Mon, 22 Apr 2024 09:02:28 +0800 Subject: [PATCH 16/40] Remove Present/Absent from OBSERVATION_CHOICES. --- observations/lookups.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/observations/lookups.py b/observations/lookups.py index 4a28d0a45..296d1de8a 100644 --- a/observations/lookups.py +++ b/observations/lookups.py @@ -427,8 +427,8 @@ OBSERVATION_CHOICES = ( (NA_VALUE, "Not applicable"), - ("absent", "Absent"), - ("present", "Present"), + #("absent", "Absent"), + #("present", "Present"), ("yes", "Yes"), ("no", "No"), ) From cac97e2522074d785ed1512497f2ec982b9f0988 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 23 Apr 2024 10:30:10 +0800 Subject: [PATCH 17/40] Add MAPBOX_TOKEN env var to deployment overlays. --- kustomize/overlays/prod/deployment_patch.yaml | 12 ++++++++++-- kustomize/overlays/uat/deployment_patch.yaml | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/kustomize/overlays/prod/deployment_patch.yaml b/kustomize/overlays/prod/deployment_patch.yaml index b8b9b161d..db8f218a9 100644 --- a/kustomize/overlays/prod/deployment_patch.yaml +++ b/kustomize/overlays/prod/deployment_patch.yaml @@ -8,8 +8,6 @@ spec: containers: - name: turtles env: - - name: MAPPROXY_URL - value: "https://mapproxy.dbca.wa.gov.au/service" - name: SITE_CODE value: "Turtles" - name: SITE_NAME @@ -31,6 +29,16 @@ spec: secretKeyRef: name: turtles-env-prod key: SECRET_KEY + - name: MAPPROXY_URL + valueFrom: + secretKeyRef: + name: turtles-env-prod + key: MAPPROXY_URL + - name: MAPBOX_TOKEN + valueFrom: + secretKeyRef: + name: turtles-env-prod + key: MAPBOX_TOKEN - name: AZURE_ACCOUNT_NAME valueFrom: secretKeyRef: diff --git a/kustomize/overlays/uat/deployment_patch.yaml b/kustomize/overlays/uat/deployment_patch.yaml index 8f419659e..098612bf9 100644 --- a/kustomize/overlays/uat/deployment_patch.yaml +++ b/kustomize/overlays/uat/deployment_patch.yaml @@ -8,8 +8,6 @@ spec: containers: - name: turtles env: - - name: MAPPROXY_URL - value: "https://mapproxy.dbca.wa.gov.au/service" - name: SITE_CODE value: "Turtles UAT" - name: SITE_NAME @@ -31,6 +29,16 @@ spec: secretKeyRef: name: turtles-env-uat key: SECRET_KEY + - name: MAPPROXY_URL + valueFrom: + secretKeyRef: + name: turtles-env-uat + key: MAPPROXY_URL + - name: MAPBOX_TOKEN + valueFrom: + secretKeyRef: + name: turtles-env-uat + key: MAPBOX_TOKEN - name: AZURE_ACCOUNT_NAME valueFrom: secretKeyRef: From 933bc14c18a33f88e7f6caee1a3848409394b0cc Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 23 Apr 2024 10:30:40 +0800 Subject: [PATCH 18/40] Rename marine mammal incidents app. --- marine_mammal_incidents/apps.py | 2 +- observations/lookups.py | 2 +- poetry.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/marine_mammal_incidents/apps.py b/marine_mammal_incidents/apps.py index 5f9f4ffd9..1acc99f7d 100644 --- a/marine_mammal_incidents/apps.py +++ b/marine_mammal_incidents/apps.py @@ -3,4 +3,4 @@ class MarineMammalIncidentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'marine_mammal_incidents' + name = 'marine mammal incidents' diff --git a/observations/lookups.py b/observations/lookups.py index 296d1de8a..16b8f7c1f 100644 --- a/observations/lookups.py +++ b/observations/lookups.py @@ -426,11 +426,11 @@ ) OBSERVATION_CHOICES = ( - (NA_VALUE, "Not applicable"), #("absent", "Absent"), #("present", "Present"), ("yes", "Yes"), ("no", "No"), + (NA_VALUE, "NA (not applicable)"), ) OBSERVATION_ICONS = { diff --git a/poetry.lock b/poetry.lock index 98efce239..c8c143d16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1984,13 +1984,13 @@ files = [ [[package]] name = "sphinx" -version = "7.3.4" +version = "7.3.6" description = "Python documentation generator" optional = false python-versions = ">=3.9" files = [ - {file = "sphinx-7.3.4-py3-none-any.whl", hash = "sha256:f67400c457599a6cfd967b681e093b43892b8df71596774b3128e31b6f0899eb"}, - {file = "sphinx-7.3.4.tar.gz", hash = "sha256:614826a7cf76f0a4525875c3ed55e2c3618f906897cb7ad53511c5fedcbb35aa"}, + {file = "sphinx-7.3.6-py3-none-any.whl", hash = "sha256:d6c09acd42094fcd96a9299c1b32b2dafe82d667fdd6e532e5978443ad074c2a"}, + {file = "sphinx-7.3.6.tar.gz", hash = "sha256:fc9f3d13fed5c9a0e677d368090e209899ce5d0081eb552b657e2923e57517f0"}, ] [package.dependencies] @@ -2240,13 +2240,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.2" +version = "20.25.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.2-py3-none-any.whl", hash = "sha256:6e1281a57849c8a54da89ba82e5eb7c8937b9d057ff01aaf5bc9afaa3552e90f"}, - {file = "virtualenv-20.25.2.tar.gz", hash = "sha256:fa7edb8428620518010928242ec17aa7132ae435319c29c1651d1cf4c4173aad"}, + {file = "virtualenv-20.25.3-py3-none-any.whl", hash = "sha256:8aac4332f2ea6ef519c648d0bc48a5b1d324994753519919bddbb1aff25a104e"}, + {file = "virtualenv-20.25.3.tar.gz", hash = "sha256:7bb554bbdfeaacc3349fa614ea5bff6ac300fc7c335e9facf3a3bcfc703f45be"}, ] [package.dependencies] From 8ba6f3c52afc1b96da7f4eca4d0b80bad83c2422 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 23 Apr 2024 10:57:39 +0800 Subject: [PATCH 19/40] Fix mistyped app name. --- marine_mammal_incidents/apps.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/marine_mammal_incidents/apps.py b/marine_mammal_incidents/apps.py index 1acc99f7d..91e886720 100644 --- a/marine_mammal_incidents/apps.py +++ b/marine_mammal_incidents/apps.py @@ -3,4 +3,5 @@ class MarineMammalIncidentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'marine mammal incidents' + name = 'marine_mammal_incidents' + verbose_name = 'marine mammal incidents' From 1ecf82d3a68a873ee0c9aaf614317bfa0ab4093b Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 23 Apr 2024 10:59:36 +0800 Subject: [PATCH 20/40] Use django-map-widget for observations admin forms. --- wastd/settings.py | 1 + wastd/utils.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/wastd/settings.py b/wastd/settings.py index c9791319e..24f299f29 100644 --- a/wastd/settings.py +++ b/wastd/settings.py @@ -134,6 +134,7 @@ "zoom": 10, "center": (-31.996226, 115.883947), "scrollZoom": True, + "style": "mapbox://styles/dpawasi/ckigwmxrx606g19msw0g882gj", }, "geocoderOptions": { "zoom": 7, diff --git a/wastd/utils.py b/wastd/utils.py index 1c4b250a2..73590b2a9 100644 --- a/wastd/utils.py +++ b/wastd/utils.py @@ -18,6 +18,7 @@ from functools import reduce from import_export.formats import base_formats from import_export.resources import Resource +from mapwidgets.widgets import MapboxPointFieldWidget import re from urllib import parse import uuid @@ -883,6 +884,7 @@ def reject(self, by=None, description=None): FORMFIELD_OVERRIDES = { models.ImageField: {"widget": AdminImageWidget}, models.FileField: {"widget": AdminImageWidget}, + models.PointField: {"widget": MapboxPointFieldWidget}, } FILTER_OVERRIDES = { models.CharField: { From bce689714efb9fd1ec346b0bfffd663e23ff0265 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 23 Apr 2024 13:42:56 +0800 Subject: [PATCH 21/40] App name --- marine_mammal_incidents/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/marine_mammal_incidents/apps.py b/marine_mammal_incidents/apps.py index 91e886720..91964a56d 100644 --- a/marine_mammal_incidents/apps.py +++ b/marine_mammal_incidents/apps.py @@ -4,4 +4,4 @@ class MarineMammalIncidentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'marine_mammal_incidents' - verbose_name = 'marine mammal incidents' + verbose_name = 'Marine mammal incidents' From 8f3f52de330e9ce2be25302b043f17e5eaa86531 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 26 Apr 2024 12:22:47 +0800 Subject: [PATCH 22/40] Update map JS to add localities layer to all map widgets. --- .../observations/encounter_detail.html | 2 +- observations/templates/observations/map.html | 3 --- .../templates/observations/survey_detail.html | 2 +- wastd/static/js/turtles_map.js | 19 ++++++++++++++++++- wastd/templates/base_wastd.html | 3 +++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/observations/templates/observations/encounter_detail.html b/observations/templates/observations/encounter_detail.html index b5f9d7c7e..a48d3427a 100644 --- a/observations/templates/observations/encounter_detail.html +++ b/observations/templates/observations/encounter_detail.html @@ -219,7 +219,7 @@

    QA/QC logs

    {% endblock %} diff --git a/observations/templates/observations/survey_detail.html b/observations/templates/observations/survey_detail.html index 8b9be320e..2040dcc12 100644 --- a/observations/templates/observations/survey_detail.html +++ b/observations/templates/observations/survey_detail.html @@ -175,7 +175,7 @@

    {% endblock extra_js %} From 2f7b0554e6f5253ba66aa46b756e9a471249982c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 26 Apr 2024 12:23:37 +0800 Subject: [PATCH 23/40] Update Survey.duplicate_surveys property (production only). --- observations/models.py | 4 ++-- observations/views.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/observations/models.py b/observations/models.py index ef99cfd58..bee2e6963 100644 --- a/observations/models.py +++ b/observations/models.py @@ -556,9 +556,9 @@ def start_date(self): @property def duplicate_surveys(self): - """A queryset of other surveys on the same date and site with intersecting durations.""" + """A queryset of other production surveys on the same date and site with intersecting durations.""" return ( - Survey.objects.filter(site=self.site, start_time__date=self.start_date) + Survey.objects.filter(site=self.site, start_time__date=self.start_date, production=True) .exclude(pk=self.pk) .exclude(start_time__gte=self.end_time) # surveys starting after self .exclude(end_time__lte=self.start_time) # surveys ending before self diff --git a/observations/views.py b/observations/views.py index b8ec6af48..9d83f9cce 100644 --- a/observations/views.py +++ b/observations/views.py @@ -54,6 +54,7 @@ class MapView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["mapproxy_url"] = settings.MAPPROXY_URL + context["page_title"] = f"{settings.SITE_CODE} | Map" return context From 85f05ecd3a693835a03942f1e5bf810d73a48046 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 26 Apr 2024 12:49:25 +0800 Subject: [PATCH 24/40] Light blue -> cornflower blue. --- wastd/static/js/turtles_map.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/wastd/static/js/turtles_map.js b/wastd/static/js/turtles_map.js index 010f14479..b4c9db47d 100644 --- a/wastd/static/js/turtles_map.js +++ b/wastd/static/js/turtles_map.js @@ -6,13 +6,14 @@ const pointstyle = { /* Polygon style */ const polystyle = { - "color": "#0009ff", + "color": "#0000ff", "weight": 1, "opacity": 0.65 }; -const polystyle_lightblue = { - "color": "#add8e6", +const polystyle_cornflower_blue = { + "color": "#6495ed", + "fillColor": "#6495ed", "weight": 1, "opacity": 0.65 }; @@ -197,7 +198,7 @@ var map = L.map('map', { // Add the (initially) empty Turtles DB localities layer to the map. const turtlesLocalities = L.geoJSON(null, { - style: polystyle_lightblue, + style: polystyle_cornflower_blue, onEachFeature: oef }); // Query the API endpoint for localities data. From 6e582c8ced6381773ec59b840663f553c5f9ca3c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 26 Apr 2024 14:07:37 +0800 Subject: [PATCH 25/40] Added SurveyMergeView to allow duplicate surveys to be merged together. --- observations/forms.py | 39 +++++++++++ .../templates/observations/survey_detail.html | 17 +++-- .../observations/survey_is_production.html | 2 +- observations/urls.py | 1 + observations/views.py | 65 ++++++++++++++++++- 5 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 observations/forms.py diff --git a/observations/forms.py b/observations/forms.py new file mode 100644 index 000000000..f0cc33dd1 --- /dev/null +++ b/observations/forms.py @@ -0,0 +1,39 @@ +from django import forms +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Div, Fieldset, Field, Submit, HTML + +from .models import Survey + + +class SurveyChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + return obj.make_label() + + +class SurveyMergeForm(forms.Form): + survey_duplicates = SurveyChoiceField( + label="Duplicate surveys", + queryset=Survey.objects.none(), + required=False, + ) + save_button = Submit("save", "Merge duplicate survey", css_class="btn-lg") + cancel_button = Submit("cancel", "Cancel", css_class="btn-secondary") + + def __init__(self, survey, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["survey_duplicates"].queryset = survey.duplicate_surveys + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Fieldset( + "Merge duplicate surveys", + HTML(f"

    {survey.label_short()}

    "), + Field("survey_duplicates"), + ), + Div( + self.save_button, + self.cancel_button, + ), + ), + ) diff --git a/observations/templates/observations/survey_detail.html b/observations/templates/observations/survey_detail.html index 2040dcc12..febe1ae0f 100644 --- a/observations/templates/observations/survey_detail.html +++ b/observations/templates/observations/survey_detail.html @@ -25,9 +25,9 @@
    {% block subject_details %} -

    - {% block heading %}{{ object.label_short }}{% endblock %} -

    + {% block heading %} +

    {{ object.label_short }}

    + {% endblock %} {% block title_labels %}{% endblock title_labels %} {% include "observations/survey_is_production.html" with survey=object %} {% include "observations/survey_site_visit_start_end.html" with survey=object %} @@ -97,9 +97,16 @@

    Duplicate surveys - {% for svy in object.duplicate_surveys.all %} - + {% if object.production and object.has_duplicates %} + {% for survey in object.duplicate_surveys.all %} + {% endfor %} + + + Merge duplicates + + + {% endif %} diff --git a/observations/templates/observations/survey_is_production.html b/observations/templates/observations/survey_is_production.html index de1a8aa37..4df335b9e 100644 --- a/observations/templates/observations/survey_is_production.html +++ b/observations/templates/observations/survey_is_production.html @@ -3,6 +3,6 @@ {% if survey.production %} Production {% else %} - Training + Training / merged {% endif %} diff --git a/observations/urls.py b/observations/urls.py index 95815dd33..32dcfcdc1 100644 --- a/observations/urls.py +++ b/observations/urls.py @@ -13,6 +13,7 @@ path("animal-encounters//reject/", views.AnimalEncounterReject.as_view(), name="animalencounter-reject"), path("surveys/", views.SurveyList.as_view(), name="survey-list"), path("surveys//", views.SurveyDetail.as_view(), name="survey-detail"), + path("surveys//merge/", views.SurveyMergeView.as_view(), name="survey-merge"), path("surveys//close_duplicates", views.close_survey_duplicates, name="survey-close-duplicates"), path("turtle-nest-encounters/", views.TurtleNestEncounterList.as_view(), name="turtlenestencounter-list"), path("turtle-nest-encounters//", views.TurtleNestEncounterDetail.as_view(), name="turtlenestencounter-detail"), diff --git a/observations/views.py b/observations/views.py index 9d83f9cce..b38a1c0f3 100644 --- a/observations/views.py +++ b/observations/views.py @@ -2,18 +2,25 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.shortcuts import redirect +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, TemplateView, ListView, DetailView +from django.views.generic import View, TemplateView, ListView, DetailView, FormView from django.views.generic.detail import SingleObjectMixin from django_fsm_log.models import StateLog -from wastd.utils import ListViewBreadcrumbMixin, DetailViewBreadcrumbMixin, ResourceDownloadMixin from django.db import connection from django.http import StreamingHttpResponse import json import datetime - +from wastd.utils import ( + ListViewBreadcrumbMixin, + BreadcrumbContextMixin, + DetailViewBreadcrumbMixin, + ResourceDownloadMixin, + Breadcrumb, +) from .admin import ( EncounterAdmin, AnimalEncounterAdmin, @@ -29,6 +36,9 @@ LineTransectEncounterFilter, TurtleNestDisturbanceObservationFilter, ) +from .forms import ( + SurveyMergeForm, +) from .models import ( Survey, Encounter, @@ -88,6 +98,55 @@ def get_context_data(self, **kwargs): return context +class SurveyMergeView(BreadcrumbContextMixin, FormView): + """Merge a survey into another. + """ + template_name = "users/user_form.html" + form_class = SurveyMergeForm + + def get_object(self): + return Survey.objects.get(pk=self.kwargs["pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["survey"] = self.get_object() + return kwargs + + def get_breadcrumbs(self, request, obj=None, add=False): + return ( + Breadcrumb("Home", reverse("home")), + Breadcrumb("Surveys", reverse("observations:survey-list")), + Breadcrumb(self.get_object().pk, self.get_object().get_absolute_url()), + Breadcrumb("Merge duplicates", None), + ) + + def get_success_url(self): + return self.get_object().get_absolute_url() + + def post(self, request, *args, **kwargs): + # If the user clicked Cancel, redirect back to the survey detail. + if request.POST.get("cancel"): + return redirect(self.get_success_url()) + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + """Update encounters for the merged survey, display success message, return to survey detail. + """ + survey = self.get_object() + survey_to_merge = form.cleaned_data["survey_duplicates"] + encounters = Encounter.objects.filter(survey=survey_to_merge) + for encounter in encounters: + encounter.survey = survey + encounter.save() + + # Update the merged survey to be non-production. + survey_to_merge.production = False + survey_to_merge.save() + + messages.success(self.request, f"Merged encounters for survey {survey_to_merge.pk} to survey {survey.pk}") + return super().form_valid(form) + + def close_survey_duplicates(request, pk): """Close duplicates for a given Survey PK with the request user as actor. From 00e324d4613dee86823bac4cea0df732d0488ffb Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 26 Apr 2024 14:52:42 +0800 Subject: [PATCH 26/40] Also update SurveyMediaAttachment objects for merged surveys. --- observations/views.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/observations/views.py b/observations/views.py index b38a1c0f3..30cebe518 100644 --- a/observations/views.py +++ b/observations/views.py @@ -41,6 +41,7 @@ ) from .models import ( Survey, + SurveyMediaAttachment, Encounter, AnimalEncounter, TurtleNestEncounter, @@ -134,16 +135,24 @@ def form_valid(self, form): """ survey = self.get_object() survey_to_merge = form.cleaned_data["survey_duplicates"] + + # Merge any Encounters on the old survey. encounters = Encounter.objects.filter(survey=survey_to_merge) for encounter in encounters: encounter.survey = survey encounter.save() + # Merge any media attachments on the old survey. + attachments = SurveyMediaAttachment.objects.filter(survey=survey_to_merge) + for media in attachments: + media.survey = survey + media.save() + # Update the merged survey to be non-production. survey_to_merge.production = False survey_to_merge.save() - messages.success(self.request, f"Merged encounters for survey {survey_to_merge.pk} to survey {survey.pk}") + messages.success(self.request, f"Merged encounters and attachments for survey {survey_to_merge.pk} to survey {survey.pk}") return super().form_valid(form) From 71d0e9d0537513bb168226a4d3c4c69c44ca71c4 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Mon, 29 Apr 2024 08:11:35 +0800 Subject: [PATCH 27/40] Added pagination.html snippet, PaginateMixin class, refactor list views. --- observations/views.py | 26 +++---- .../templates/wamtram2/trtturtles_list.html | 61 +++------------ wamtram2/views.py | 44 ++++------- wastd/templates/default_list.html | 44 +++-------- wastd/templates/pagination.html | 75 +++++++++++++++++++ wastd/utils.py | 43 ++++++++++- 6 files changed, 161 insertions(+), 132 deletions(-) create mode 100644 wastd/templates/pagination.html diff --git a/observations/views.py b/observations/views.py index 30cebe518..baffd789a 100644 --- a/observations/views.py +++ b/observations/views.py @@ -19,6 +19,7 @@ BreadcrumbContextMixin, DetailViewBreadcrumbMixin, ResourceDownloadMixin, + PaginateMixin, Breadcrumb, ) from .admin import ( @@ -69,7 +70,7 @@ def get_context_data(self, **kwargs): return context -class SurveyList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class SurveyList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = Survey template_name = "default_list.html" paginate_by = 20 @@ -80,7 +81,6 @@ class SurveyList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["list_filter"] = SurveyFilter(self.request.GET, queryset=self.get_queryset()) - context["object_count"] = self.get_queryset().count() context["page_title"] = f"{settings.SITE_CODE} | Surveys" return context @@ -171,7 +171,7 @@ def close_survey_duplicates(request, pk): return HttpResponseRedirect(s.get_absolute_url()) -class EncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class EncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = Encounter template_name = "default_list.html" paginate_by = 20 @@ -259,7 +259,7 @@ def get(self, request, *args, **kwargs): return HttpResponseRedirect(obj.get_absolute_url()) -class AnimalEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class AnimalEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = AnimalEncounter template_name = "default_list.html" paginate_by = 20 @@ -269,10 +269,8 @@ class AnimalEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListVi def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - qs = self.get_queryset() - context["list_filter"] = AnimalEncounterFilter(self.request.GET, queryset=qs) + context["list_filter"] = AnimalEncounterFilter(self.request.GET, queryset=self.get_queryset()) context["model_admin"] = AnimalEncounterAdmin - context["object_count"] = qs.count() context["page_title"] = f"{settings.SITE_CODE} | Animal encounters" return context @@ -305,7 +303,7 @@ class AnimalEncounterReject(EncounterReject): model = AnimalEncounter -class TurtleNestEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class TurtleNestEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = TurtleNestEncounter template_name = "default_list.html" paginate_by = 20 @@ -315,10 +313,8 @@ class TurtleNestEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, Li def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - qs = self.get_queryset() - context["list_filter"] = TurtleNestEncounterFilter(self.request.GET, queryset=qs) + context["list_filter"] = TurtleNestEncounterFilter(self.request.GET, queryset=self.get_queryset()) context["model_admin"] = TurtleNestEncounterAdmin - context["object_count"] = qs.count() context["page_title"] = f"{settings.SITE_CODE} | Turtle nest encounters" return context @@ -352,7 +348,7 @@ class TurtleNestEncounterReject(EncounterReject): model = TurtleNestEncounter -class TurtleNestDisturbanceObservationList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class TurtleNestDisturbanceObservationList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = TurtleNestDisturbanceObservation template_name = "default_list.html" paginate_by = 20 @@ -362,10 +358,8 @@ class TurtleNestDisturbanceObservationList(ListViewBreadcrumbMixin, ResourceDown def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - qs = self.get_queryset() - context["list_filter"] = TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=qs) + context["list_filter"] = TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=self.get_queryset()) context["model_admin"] = TurtleNestDisturbanceObservationAdmin - context["object_count"] = qs.count() context["page_title"] = f"{settings.SITE_CODE} | Turtle nest disturbances" return context @@ -374,7 +368,7 @@ def get_queryset(self): return TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=qs).qs -class LineTransectEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, ListView): +class LineTransectEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = LineTransectEncounter template_name = "default_list.html" paginate_by = 20 diff --git a/wamtram2/templates/wamtram2/trtturtles_list.html b/wamtram2/templates/wamtram2/trtturtles_list.html index c82da1e46..c157e772d 100644 --- a/wamtram2/templates/wamtram2/trtturtles_list.html +++ b/wamtram2/templates/wamtram2/trtturtles_list.html @@ -3,55 +3,24 @@ {% block page_content_inner %}
    -
    +
    + {% if is_paginated %} + {% include "pagination.html" %} + {% endif %} +
    +
    +
    +
    {% block pagination_row %}
    -
    - {% if is_paginated %} - {% load proper_paginate %} - {% load url_replace %} -
      - {% if page_obj.number == 1 %} -
    • - {% else %} -
    • - {% endif %} - {% if page_obj.has_previous %} -
    • «
    • - {% else %} -
    • «
    • - {% endif %} - {% for i in paginator|proper_paginate:page_obj.number %} - {% if page_obj.number == i %} -
    • {{ i }} (current)
    • - {% else %} -
    • {{ i }}
    • - {% endif %} - {% endfor %} - {% if page_obj.has_next %} -
    • »
    • - {% else %} -
    • »
    • - {% endif %} - {% if page_obj.number == paginator.num_pages %} -
    • - {% else %} -
    • - {% endif %} -
    -{% endif %} - - {% if object_list %} - +
    {% if object_count %}
    Found {{ object_count }} records
    {% endif %} - - {% endif %}
    {% endblock pagination_row %} @@ -68,11 +37,6 @@ Species Sex Tags - {% comment %} - Observation count - Most recent observation - Most recent site - {% endcomment %} @@ -84,11 +48,6 @@ {{ obj.species_code }} {{ obj.sex}} {{ obj.get_tags_description }} - {% comment %} - {{ obj.get_tag_observations.count }} - {{ obj.get_newest_encounter.when|date:"D, j M Y H:i"|default_if_none:"" }} - {{ obj.get_newest_site|default_if_none:"" }} - {% endcomment %} {% endfor %} @@ -97,6 +56,4 @@
    {% endif %} - {% endblock page_content_inner %} - diff --git a/wamtram2/views.py b/wamtram2/views.py index 17f3e37a4..46916b143 100644 --- a/wamtram2/views.py +++ b/wamtram2/views.py @@ -1,25 +1,18 @@ -from django.shortcuts import render -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic.edit import FormMixin -from django.urls import reverse -from django.views import generic from django.conf import settings -from wastd.utils import Breadcrumb +from django.contrib import messages +from django.db import connections, DatabaseError +from django.db.models import Q, Exists, OuterRef from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import render, redirect, get_object_or_404 +from django.urls import reverse +from django.views import View +from django.views.generic.edit import FormMixin +from django.views.generic import TemplateView, ListView, DetailView, FormView +from wastd.utils import Breadcrumb, PaginateMixin from .models import TrtTurtles,TrtTags,TrtPitTags, TrtEntryBatches,TrtDataEntry,TrtPersons,TrtObservations from .forms import TrtDataEntryForm, SearchForm, TrtEntryBatchesForm -from django.shortcuts import get_object_or_404 - -from django.views import View -from django.shortcuts import redirect -from django.db import connections -from django.views.generic import ListView -from django.contrib import messages -from django.db import DatabaseError -from django.db.models import Q, Exists, OuterRef - -from django.views.generic import TemplateView class HomePageView(LoginRequiredMixin,TemplateView): @@ -90,7 +83,7 @@ def get_context_data(self, **kwargs): return context -class EntryBatchDetailView(LoginRequiredMixin,FormMixin,generic.ListView): +class EntryBatchDetailView(LoginRequiredMixin, FormMixin, ListView): """ A view for displaying list of a batch of TrtDataEntry objects. @@ -176,9 +169,6 @@ def get_context_data(self, **kwargs): context['form'] = TrtEntryBatchesForm(instance=batch) # Add the form to the context data return context - """ - FormMixin provides the following methods: - """ def post(self, request, *args, **kwargs): form = self.get_form() form.instance.entry_batch_id = self.kwargs.get('batch_id') @@ -211,13 +201,10 @@ def get_success_url(self): return reverse('wamtram2:entry_batch_detail', args=[batch_id]) -class TrtDataEntryForm(LoginRequiredMixin, generic.FormView): +class TrtDataEntryForm(LoginRequiredMixin, FormView): """ A form view for entering TRT data. - - Inherits from LoginRequiredMixin and generic.FormView. """ - template_name = 'wamtram2/trtdataentry_form.html' form_class = TrtDataEntryForm @@ -491,7 +478,7 @@ def post(self, request, *args, **kwargs): return render(request, 'wamtram2/find_turtle.html', {'form': form, 'no_turtle_found': no_turtle_found}) -class ObservationDetailView(LoginRequiredMixin, generic.DetailView): +class ObservationDetailView(LoginRequiredMixin, DetailView): model = TrtObservations template_name = 'wamtram2/observation_detail.html' @@ -505,7 +492,7 @@ def get_context_data(self, **kwargs): return context -class TurtleListView(LoginRequiredMixin, generic.ListView): +class TurtleListView(LoginRequiredMixin, PaginateMixin, ListView): """ View class for displaying a list of turtles. @@ -525,7 +512,6 @@ def get_context_data(self, **kwargs): dict: The context data. """ context = super().get_context_data(**kwargs) - context["object_count"] = self.get_queryset().count() context["page_title"] = f"{settings.SITE_CODE} | WAMTRAM2" # Pass in any query string if "q" in self.request.GET: @@ -552,7 +538,7 @@ def get_queryset(self): return qs.order_by("pk") -class TurtleDetailView(LoginRequiredMixin,generic.DetailView): +class TurtleDetailView(LoginRequiredMixin, DetailView): """ View class for displaying the details of a turtle. diff --git a/wastd/templates/default_list.html b/wastd/templates/default_list.html index e787d88c4..c69693aae 100644 --- a/wastd/templates/default_list.html +++ b/wastd/templates/default_list.html @@ -16,40 +16,11 @@ {% block page_content_inner %} {% block pagination_row %} +
    {% if is_paginated %} - {% load proper_paginate %} - {% load url_replace %} -
      - {% if page_obj.number == 1 %} -
    • - {% else %} -
    • - {% endif %} - {% if page_obj.has_previous %} -
    • «
    • - {% else %} -
    • «
    • - {% endif %} - {% for i in paginator|proper_paginate:page_obj.number %} - {% if page_obj.number == i %} -
    • {{ i }} (current)
    • - {% else %} -
    • {{ i }}
    • - {% endif %} - {% endfor %} - {% if page_obj.has_next %} -
    • »
    • - {% else %} -
    • »
    • - {% endif %} - {% if page_obj.number == paginator.num_pages %} -
    • - {% else %} -
    • - {% endif %} -
    + {% include "pagination.html" %} {% endif %} {% if object_list %} @@ -91,10 +62,17 @@
    -
    - +{% block pagination_row_bottom %} +{% if is_paginated %} +
    +
    + {% include "pagination.html" %} +
    +
    +{% endif %} +{% endblock pagination_row_bottom %} {% endblock page_content_inner %} diff --git a/wastd/templates/pagination.html b/wastd/templates/pagination.html new file mode 100644 index 000000000..5dec3eaf3 --- /dev/null +++ b/wastd/templates/pagination.html @@ -0,0 +1,75 @@ +{% load url_replace %} + diff --git a/wastd/utils.py b/wastd/utils.py index 73590b2a9..56b6d63ac 100644 --- a/wastd/utils.py +++ b/wastd/utils.py @@ -90,7 +90,7 @@ class BreadcrumbContextMixin(ContextMixin): def get_context_data(self, **kwargs): """Custom context.""" - context = super(BreadcrumbContextMixin, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context["breadcrumbs"] = self.get_breadcrumbs(self.request) return context @@ -365,7 +365,7 @@ def render(self, name, value, attrs=None, renderer=None): f'style="object-fit: cover;"/> ' ) - output.append(super(AdminFileWidget, self).render(name, value, attrs, renderer)) + output.append(super().render(name, value, attrs, renderer)) return mark_safe("".join(output)) @@ -956,3 +956,42 @@ def get_query(query_string, search_fields): else: query = query & or_query return query + + +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 + + +class PaginateMixin(ListView): + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["object_count"] = self.get_queryset().count() + context["previous_pages"] = get_previous_pages(context["page_obj"]) + context["next_pages"] = get_next_pages(context["page_obj"]) + return context From 4523efbd652109e65940ddb3007566f66ee1b9ff Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Mon, 29 Apr 2024 13:59:58 +0800 Subject: [PATCH 28/40] Added DisturbanceObservation model. --- observations/lookups.py | 2 +- .../0015_disturbanceobservation_and_more.py | 288 ++++++++++++++++++ observations/models.py | 30 +- 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 observations/migrations/0015_disturbanceobservation_and_more.py diff --git a/observations/lookups.py b/observations/lookups.py index 16b8f7c1f..732732690 100644 --- a/observations/lookups.py +++ b/observations/lookups.py @@ -507,12 +507,12 @@ (NEST_DAMAGE_DEFAULT, "Other turtle"), ("bandicoot", "Bandicoot predation"), ("bird", "Bird predation"), + ("cat", "Cat predation"), ("crab", "Crab predation"), ("croc", "Croc predation"), ("cyclone", "Cyclone disturbance"), ("dingo", "Dingo predation"), ("dog", "Dog predation"), - ("cat", "Cat predation"), ("fox", "Fox predation"), ("goanna", "Goanna predation"), ("human", "Human"), diff --git a/observations/migrations/0015_disturbanceobservation_and_more.py b/observations/migrations/0015_disturbanceobservation_and_more.py new file mode 100644 index 000000000..129981d27 --- /dev/null +++ b/observations/migrations/0015_disturbanceobservation_and_more.py @@ -0,0 +1,288 @@ +# Generated by Django 4.2.11 on 2024-04-29 05:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("observations", "0014_alter_observation_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DisturbanceObservation", + fields=[ + ( + "observation_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="observations.observation", + ), + ), + ( + "disturbance_cause", + models.CharField( + choices=[ + ("turtle", "Other turtle"), + ("bandicoot", "Bandicoot predation"), + ("bird", "Bird predation"), + ("cat", "Cat predation"), + ("crab", "Crab predation"), + ("croc", "Croc predation"), + ("cyclone", "Cyclone disturbance"), + ("dingo", "Dingo predation"), + ("dog", "Dog predation"), + ("fox", "Fox predation"), + ("goanna", "Goanna predation"), + ("human", "Human"), + ("pig", "Pig predation"), + ("tide", "Tidal disturbance"), + ("vehicle", "Vehicle damage"), + ("unknown", "Unknown"), + ("other", "Other identifiable (see comments)"), + ], + help_text="The cause of the disturbance.", + max_length=300, + ), + ), + ( + "disturbance_cause_confidence", + models.CharField( + choices=[ + ("na", "Not applicable"), + ("guess", "Guess based on insufficient evidence"), + ("certain", "Certainty based on local evidence"), + ( + "expert-opinion", + "Expert opinion based on available evidence", + ), + ("validated", "Validated by authoritative source"), + ], + default="na", + help_text="What is the choice of disturbance cause based on?", + max_length=300, + verbose_name="Disturbance cause choice confidence", + ), + ), + ( + "comments", + models.TextField( + blank=True, help_text="Any other comments or notes.", null=True + ), + ), + ], + options={ + "abstract": False, + "base_manager_name": "objects", + }, + bases=("observations.observation",), + ), + migrations.AlterField( + model_name="animalencounter", + name="checked_for_flipper_tags", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was the animal checked for flipper tags, were any found?", + max_length=300, + ), + ), + migrations.AlterField( + model_name="animalencounter", + name="checked_for_injuries", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was the animal checked for injuries, were any found?", + max_length=300, + ), + ), + migrations.AlterField( + model_name="animalencounter", + name="nesting_disturbed", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was the nesting interrupted? If so, specify disturbance in comments.", + max_length=300, + ), + ), + migrations.AlterField( + model_name="animalencounter", + name="scanned_for_pit_tags", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was the animal scanned for PIT tags, were any found?", + max_length=300, + verbose_name="Scanned for PIT tags", + ), + ), + migrations.AlterField( + model_name="encounter", + name="encounter_type", + field=models.CharField( + blank=True, + choices=[ + ("stranding", "Stranding"), + ("tagging", "Tagging"), + ("nest", "Nest"), + ("tracks", "Tracks"), + ("inwater", "In water"), + ("tag-management", "Tag management"), + ("logger", "Logger"), + ("disturbance", "Disturbance/predator"), + ("other", "Other"), + ], + default="other", + help_text="The primary concern of this encounter.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="turtlehatchlingemergenceobservation", + name="light_sources_present", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + max_length=300, + verbose_name="Light sources present during emergence", + ), + ), + migrations.AlterField( + model_name="turtlehatchlingemergenceobservation", + name="outlier_tracks_present", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + max_length=300, + ), + ), + migrations.AlterField( + model_name="turtlenestdisturbanceobservation", + name="disturbance_cause", + field=models.CharField( + choices=[ + ("turtle", "Other turtle"), + ("bandicoot", "Bandicoot predation"), + ("bird", "Bird predation"), + ("cat", "Cat predation"), + ("crab", "Crab predation"), + ("croc", "Croc predation"), + ("cyclone", "Cyclone disturbance"), + ("dingo", "Dingo predation"), + ("dog", "Dog predation"), + ("fox", "Fox predation"), + ("goanna", "Goanna predation"), + ("human", "Human"), + ("pig", "Pig predation"), + ("tide", "Tidal disturbance"), + ("vehicle", "Vehicle damage"), + ("unknown", "Unknown"), + ("other", "Other identifiable (see comments)"), + ], + help_text="The cause of the disturbance.", + max_length=300, + ), + ), + migrations.AlterField( + model_name="turtlenestdisturbancetallyobservation", + name="disturbance_cause", + field=models.CharField( + choices=[ + ("turtle", "Other turtle"), + ("bandicoot", "Bandicoot predation"), + ("bird", "Bird predation"), + ("cat", "Cat predation"), + ("crab", "Crab predation"), + ("croc", "Croc predation"), + ("cyclone", "Cyclone disturbance"), + ("dingo", "Dingo predation"), + ("dog", "Dog predation"), + ("fox", "Fox predation"), + ("goanna", "Goanna predation"), + ("human", "Human"), + ("pig", "Pig predation"), + ("tide", "Tidal disturbance"), + ("vehicle", "Vehicle damage"), + ("unknown", "Unknown"), + ("other", "Other identifiable (see comments)"), + ], + default="unknown", + help_text="The cause of the disturbance.", + max_length=300, + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="disturbance", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Is there evidence of predation or other disturbance?", + max_length=300, + verbose_name="Evidence of predation or disturbance", + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="eggs_counted", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was the nest excavated and were turtle eggs counted?", + max_length=300, + verbose_name="Nest excavated and eggs counted", + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="fan_angles_measured", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Were hatchling emergence track fan angles recorded?", + max_length=300, + verbose_name="Hatchling emergence recorded", + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="hatchlings_measured", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Were turtle hatchlings encountered and their morphometrics measured?", + max_length=300, + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="logger_found", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was a data logger deployed, retrieved, or otherwise encountered?", + max_length=300, + verbose_name="Logger present", + ), + ), + migrations.AlterField( + model_name="turtlenestencounter", + name="nest_tagged", + field=models.CharField( + choices=[("yes", "Yes"), ("no", "No"), ("na", "NA (not applicable)")], + default="na", + help_text="Was a nest tag applied, re-sighted, or otherwise encountered?", + max_length=300, + verbose_name="Nest tag present", + ), + ), + ] diff --git a/observations/models.py b/observations/models.py index bee2e6963..ecb2bedde 100644 --- a/observations/models.py +++ b/observations/models.py @@ -875,6 +875,7 @@ class Encounter(PolymorphicModel, UrlsMixin, models.Model): ENCOUNTER_TRACKS = "tracks" ENCOUNTER_TAG = "tag-management" ENCOUNTER_LOGGER = "logger" + ENCOUNTER_DISTURBANCE = "disturbance" ENCOUNTER_OTHER = "other" ENCOUNTER_TYPES = ( @@ -883,8 +884,9 @@ class Encounter(PolymorphicModel, UrlsMixin, models.Model): (ENCOUNTER_NEST, "Nest"), (ENCOUNTER_TRACKS, "Tracks"), (ENCOUNTER_INWATER, "In water"), - (ENCOUNTER_TAG, "Tag Management"), + (ENCOUNTER_TAG, "Tag management"), (ENCOUNTER_LOGGER, "Logger"), + (ENCOUNTER_DISTURBANCE, "Disturbance/predator"), (ENCOUNTER_OTHER, "Other"), ) @@ -3195,3 +3197,29 @@ class TurtleNestDisturbanceTallyObservation(Observation): def __str__(self): return f"Nest Damage Tally: {self.no_nests_disturbed} nests of {self.species} showing disturbance by {self.disturbance_cause}" + + +class DisturbanceObservation(Observation): + """Disturbance/predator observation, unrelated to a turtle nest. + """ + + disturbance_cause = models.CharField( + max_length=300, + choices=lookups.NEST_DAMAGE_CHOICES, + help_text="The cause of the disturbance.", + ) + disturbance_cause_confidence = models.CharField( + max_length=300, + verbose_name="Disturbance cause choice confidence", + choices=lookups.CONFIDENCE_CHOICES, + default=lookups.NA_VALUE, + help_text="What is the choice of disturbance cause based on?", + ) + comments = models.TextField( + blank=True, + null=True, + help_text="Any other comments or notes.", + ) + + def __str__(self): + return f"{self.pk}: Disturbance/predator ({self.get_disturbance_cause_display().lower()}), {self.get_disturbance_confidence_display().lower()}" From 91687691a4a1710960291a16e7c74a10e2f8e3e5 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 30 Apr 2024 08:13:54 +0800 Subject: [PATCH 29/40] Created import_predator_or_disturbance function for ODK form. --- observations/models.py | 2 +- observations/odk.py | 471 ++++++++++++++++++++++------------------- wastd/odk.py | 13 +- 3 files changed, 265 insertions(+), 221 deletions(-) diff --git a/observations/models.py b/observations/models.py index ecb2bedde..2344ff01e 100644 --- a/observations/models.py +++ b/observations/models.py @@ -3222,4 +3222,4 @@ class DisturbanceObservation(Observation): ) def __str__(self): - return f"{self.pk}: Disturbance/predator ({self.get_disturbance_cause_display().lower()}), {self.get_disturbance_confidence_display().lower()}" + return f"{self.pk}: Disturbance/predator ({self.get_disturbance_cause_display().lower()}), {self.get_disturbance_cause_confidence_display().lower()}" diff --git a/observations/odk.py b/observations/odk.py index d0760b73d..dce35b74e 100644 --- a/observations/odk.py +++ b/observations/odk.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta from dateutil import parser from django.conf import settings -from django.core.mail import EmailMultiAlternatives import logging from . import lookups @@ -19,6 +18,7 @@ Survey, SurveyMediaAttachment, MediaAttachment, + Encounter, TurtleNestEncounter, TurtleNestObservation, TurtleNestDisturbanceObservation, @@ -33,18 +33,19 @@ TurtleMorphometricObservation, TurtleDamageObservation, TagObservation, + DisturbanceObservation, ) -LOGGER = logging.getLogger('turtles') +LOGGER = logging.getLogger("turtles") def create_new_user(name): """Creates and returns a new User based on the passed-in name value. """ - username = name.lower().replace(' ', '_') + username = name.lower().replace(" ", "_") # Guarantee a unique username value by appending an underscore to the string. while User.objects.filter(username=username).exists(): - username += '_' + username += "_" user = User.objects.create(name=name, username=username) user.set_unusable_password() return user @@ -90,234 +91,233 @@ def import_turtle_track_or_nest(form_id="turtle_track_or_nest", auth_headers=Non LOGGER.info(f"Downloaded {form_id} submission data") for submission in submissions: try: - instance_id = submission['meta']['instanceID'] - if TurtleNestEncounter.objects.filter(source='odk', source_id=instance_id): + instance_id = submission["meta"]["instanceID"] + if TurtleNestEncounter.objects.filter(source="odk", source_id=instance_id): continue # Skip records already imported. # Try to match the reporter to an existing user. If not, create a new one. - reporter = submission['reporter'] + reporter = submission["reporter"] user = get_user(reporter) #check for new forms - if 'survey_start_time' in submission['details']: - startTime = parser.isoparse(submission['details']['survey_start_time']) # New forms allow editing of time in case submitted after the fact + if "survey_start_time" in submission["details"]: + startTime = parser.isoparse(submission["details"]["survey_start_time"]) # New forms allow editing of time in case submitted after the fact else: - startTime = parser.isoparse(submission['start_time']) # Old forms + startTime = parser.isoparse(submission["start_time"]) # Old forms # Confusingly, TurtleNestEncounter objects cover nest, track and nest & track encounters. encounter = TurtleNestEncounter( - status='imported', - source='odk', + status="imported", + source="odk", source_id=instance_id, - where=parse_geopoint(submission['details']['observed_at']), + where=parse_geopoint(submission["details"]["observed_at"]), when=startTime, observer=user, reporter=user, - comments=f'Device ID {submission["device_id"]}', - nest_age=submission['details']['nest_age'], - nest_type=submission['details']['nest_type'], - species=submission['details']['species'], + comments=f"Device ID {submission['device_id']}", + nest_age=submission["details"]["nest_age"], + nest_type=submission["details"]["nest_type"], + species=submission["details"]["species"], ) - encounter.encounter_type = encounter.get_encounter_type() - if 'nest' in submission: - encounter.habitat = submission['nest']['habitat'] - encounter.disturbance = submission['nest']['disturbance'] - encounter.nest_tagged = submission['nest']['nest_tagged'] - encounter.logger_found = submission['nest']['logger_found'] - encounter.eggs_counted = submission['nest']['eggs_counted'] - encounter.hatchlings_measured = submission['nest']['hatchlings_measured'] + if "nest" in submission: + encounter.habitat = submission["nest"]["habitat"] + encounter.disturbance = submission["nest"]["disturbance"] + encounter.nest_tagged = submission["nest"]["nest_tagged"] + encounter.logger_found = submission["nest"]["logger_found"] + encounter.eggs_counted = submission["nest"]["eggs_counted"] + encounter.hatchlings_measured = submission["nest"]["hatchlings_measured"] # Try to determine the encounter site & area. encounter.area = encounter.guess_area encounter.site = encounter.guess_site encounter.save() - LOGGER.info(f'Created TurtleNestEncounter: {encounter}') + LOGGER.info(f"Created TurtleNestEncounter: {encounter}") #get any nest photos - if 'nest_photos' in submission: - nest_photos = submission['nest_photos'] - if nest_photos['photo_nest_1']: - filename = nest_photos['photo_nest_1'] + if "nest_photos" in submission: + nest_photos = submission["nest_photos"] + if nest_photos["photo_nest_1"]: + filename = nest_photos["photo_nest_1"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest {filename}', + media_type="photograph", + title=f"Photo of nest {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") - if nest_photos['photo_nest_2']: - filename = nest_photos['photo_nest_2'] + if nest_photos["photo_nest_2"]: + filename = nest_photos["photo_nest_2"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest {filename}', + media_type="photograph", + title=f"Photo of nest {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") - if nest_photos['photo_nest_3']: - filename = nest_photos['photo_nest_3'] + if nest_photos["photo_nest_3"]: + filename = nest_photos["photo_nest_3"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest {filename}', + media_type="photograph", + title=f"Photo of nest {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # TurtleNestObservation object - if 'egg_count' in submission: - observation = submission['egg_count'] + if "egg_count" in submission: + observation = submission["egg_count"] nest_observation = TurtleNestObservation( encounter=encounter, - no_egg_shells=int(observation['no_egg_shells']), - no_live_hatchlings=int(observation['no_live_hatchlings']), - no_dead_hatchlings=int(observation['no_dead_hatchlings']), - no_undeveloped_eggs=int(observation['no_undeveloped_eggs']), - no_unhatched_eggs=int(observation['no_unhatched_eggs']), - no_unhatched_term=int(observation['no_unhatched_term']), - no_depredated_eggs=int(observation['no_depredated_eggs']), - nest_depth_top=int(observation['nest_depth_top']) if observation['nest_depth_top'] else None, - nest_depth_bottom=int(observation['nest_depth_bottom']) if observation['nest_depth_bottom'] else None, - comments=observation['nest_excavation_comments'], + no_egg_shells=int(observation["no_egg_shells"]), + no_live_hatchlings=int(observation["no_live_hatchlings"]), + no_dead_hatchlings=int(observation["no_dead_hatchlings"]), + no_undeveloped_eggs=int(observation["no_undeveloped_eggs"]), + no_unhatched_eggs=int(observation["no_unhatched_eggs"]), + no_unhatched_term=int(observation["no_unhatched_term"]), + no_depredated_eggs=int(observation["no_depredated_eggs"]), + nest_depth_top=int(observation["nest_depth_top"]) if observation["nest_depth_top"] else None, + nest_depth_bottom=int(observation["nest_depth_bottom"]) if observation["nest_depth_bottom"] else None, + comments=observation["nest_excavation_comments"], ) nest_observation.egg_count = nest_observation.no_egg_shells + nest_observation.no_undeveloped_eggs + nest_observation.no_unhatched_eggs + nest_observation.no_unhatched_term nest_observation.eggs_laid = nest_observation.egg_count and nest_observation.egg_count > 0 nest_observation.save() - LOGGER.info(f'Created TurtleNestObservation {nest_observation}') + LOGGER.info(f"Created TurtleNestObservation {nest_observation}") # Photo of eggs. - filename = observation['photo_eggs'] + filename = observation["photo_eggs"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest eggs {filename}', + media_type="photograph", + title=f"Photo of nest eggs {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # TurtleNestDisturbanceObservation objects - if 'disturbance_observations' in submission: + if "disturbance_observations" in submission: # Might be a list or a single object :| - if not isinstance(submission['disturbance_observations']['disturbance_observation'], list): - observations = [submission['disturbance_observations']['disturbance_observation']] + if not isinstance(submission["disturbance_observations"]["disturbance_observation"], list): + observations = [submission["disturbance_observations"]["disturbance_observation"]] else: - observations = submission['disturbance_observations']['disturbance_observation'] + observations = submission["disturbance_observations"]["disturbance_observation"] for observation in observations: disturbance = TurtleNestDisturbanceObservation( encounter=encounter, - disturbance_cause=observation['disturbance_cause'], - disturbance_cause_confidence=observation['disturbance_cause_confidence'], - disturbance_severity=observation['disturbance_severity'], - comments=observation['comments'], + disturbance_cause=observation["disturbance_cause"], + disturbance_cause_confidence=observation["disturbance_cause_confidence"], + disturbance_severity=observation["disturbance_severity"], + comments=observation["comments"], ) disturbance.save() - LOGGER.info(f'Created TurtleNestDisturbanceObservation: {disturbance}') + LOGGER.info(f"Created TurtleNestDisturbanceObservation: {disturbance}") # All photos are associated with the parent Encounter, as another Observation subclass. - filename = observation['photo_disturbance'] + filename = observation["photo_disturbance"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest disturbance {filename}', + media_type="photograph", + title=f"Photo of nest disturbance {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # TurtleTrackObservation object. - if 'track_photos' in submission: - track_observation = submission['track_photos'] - if track_observation['photo_track_1']: - filename = track_observation['photo_track_1'] + if "track_photos" in submission: + track_observation = submission["track_photos"] + if track_observation["photo_track_1"]: + filename = track_observation["photo_track_1"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of track {filename}', + media_type="photograph", + title=f"Photo of track {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') - if track_observation['photo_track_2']: - filename = track_observation['photo_track_2'] + LOGGER.info(f"Created MediaAttachment {photo}") + if track_observation["photo_track_2"]: + filename = track_observation["photo_track_2"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of track {filename}', + media_type="photograph", + title=f"Photo of track {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") if any([ - track_observation['max_track_width_front'], - track_observation['max_track_width_rear'], - track_observation['carapace_drag_width'], - track_observation['step_length'], - track_observation['tail_pokes'], + track_observation["max_track_width_front"], + track_observation["max_track_width_rear"], + track_observation["carapace_drag_width"], + track_observation["step_length"], + track_observation["tail_pokes"], ]): track_observation = TurtleTrackObservation( encounter=encounter, - max_track_width_front=int(track_observation['max_track_width_front']) if track_observation['max_track_width_front'] else None, - max_track_width_rear=int(track_observation['max_track_width_rear']) if track_observation['max_track_width_rear'] else None, - carapace_drag_width=int(track_observation['carapace_drag_width']) if track_observation['carapace_drag_width'] else None, - step_length=int(track_observation['step_length']) if track_observation['step_length'] else None, - tail_pokes=track_observation['tail_pokes'], + max_track_width_front=int(track_observation["max_track_width_front"]) if track_observation["max_track_width_front"] else None, + max_track_width_rear=int(track_observation["max_track_width_rear"]) if track_observation["max_track_width_rear"] else None, + carapace_drag_width=int(track_observation["carapace_drag_width"]) if track_observation["carapace_drag_width"] else None, + step_length=int(track_observation["step_length"]) if track_observation["step_length"] else None, + tail_pokes=track_observation["tail_pokes"], ) track_observation.save() - LOGGER.info(f'Created TurtleTrackObservation {track_observation}') + LOGGER.info(f"Created TurtleTrackObservation {track_observation}") # NestTagObservation object. - if 'nest_tag' in submission: + if "nest_tag" in submission: tag_observation = NestTagObservation( encounter=encounter, - status=submission['nest_tag']['tag_status'], - flipper_tag_id=submission['nest_tag']['flipper_tag_id'], - date_nest_laid=datetime.strptime(submission['nest_tag']['date_nest_laid'], '%Y-%m-%d').date() if submission['nest_tag']['date_nest_laid'] else None, - tag_label=submission['nest_tag']['tag_label'], - comments=submission['nest_tag']['tag_comments'], + status=submission["nest_tag"]["tag_status"], + flipper_tag_id=submission["nest_tag"]["flipper_tag_id"], + date_nest_laid=datetime.strptime(submission["nest_tag"]["date_nest_laid"], "%Y-%m-%d").date() if submission["nest_tag"]["date_nest_laid"] else None, + tag_label=submission["nest_tag"]["tag_label"], + comments=submission["nest_tag"]["tag_comments"], ) tag_observation.save() - LOGGER.info(f'Created NestTagObservation {tag_observation}') + LOGGER.info(f"Created NestTagObservation {tag_observation}") # Tag photo - if submission['nest_tag']['photo_tag']: - filename = submission['nest_tag']['photo_tag'] + if submission["nest_tag"]["photo_tag"]: + filename = submission["nest_tag"]["photo_tag"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of nest tag {filename}', + media_type="photograph", + title=f"Photo of nest tag {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # LoggerObservation objects - if 'loggers' in submission: - loggers = submission['loggers']['logger_details'] + if "loggers" in submission: + loggers = submission["loggers"]["logger_details"] # Might be a list or a single object :| if not isinstance(loggers, list): loggers = [loggers] @@ -325,30 +325,30 @@ def import_turtle_track_or_nest(form_id="turtle_track_or_nest", auth_headers=Non for logger in loggers: logger_observation = LoggerObservation( encounter=encounter, - logger_type=logger['logger_type'], - deployment_status=logger['logger_status'], - logger_id=logger['logger_id'], - comments=logger['logger_comments'], + logger_type=logger["logger_type"], + deployment_status=logger["logger_status"], + logger_id=logger["logger_id"], + comments=logger["logger_comments"], ) logger_observation.save() - LOGGER.info(f'Created LoggerObservation: {logger_observation}') + LOGGER.info(f"Created LoggerObservation: {logger_observation}") - if logger['photo_logger']: - filename = logger['photo_logger'] - LOGGER.info(f'Downloading {filename}') + if logger["photo_logger"]: + filename = logger["photo_logger"] + LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Photo of logger {filename}', + media_type="photograph", + title=f"Photo of logger {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # HatchlingMorphometricObservation objects - if 'hatchling_measurements' in submission: - hatchlings = submission['hatchling_measurements']['hatchling_measurement'] + if "hatchling_measurements" in submission: + hatchlings = submission["hatchling_measurements"]["hatchling_measurement"] # Might be a list or a single object :| if not isinstance(hatchlings, list): hatchlings = [hatchlings] @@ -356,128 +356,125 @@ def import_turtle_track_or_nest(form_id="turtle_track_or_nest", auth_headers=Non for hatchling in hatchlings: hatchling_measurement = HatchlingMorphometricObservation( encounter=encounter, - straight_carapace_length_mm=int(hatchling['straight_carapace_length_mm']) if hatchling['straight_carapace_length_mm'] else None, - straight_carapace_width_mm=int(hatchling['straight_carapace_width_mm']) if hatchling['straight_carapace_width_mm'] else None, - body_weight_g=int(hatchling['body_weight_g']) if hatchling['body_weight_g'] else None, + straight_carapace_length_mm=int(hatchling["straight_carapace_length_mm"]) if hatchling["straight_carapace_length_mm"] else None, + straight_carapace_width_mm=int(hatchling["straight_carapace_width_mm"]) if hatchling["straight_carapace_width_mm"] else None, + body_weight_g=int(hatchling["body_weight_g"]) if hatchling["body_weight_g"] else None, ) hatchling_measurement.save() - LOGGER.info(f'Created HatchlingMorphometricObservation: {hatchling_measurement}') + LOGGER.info(f"Created HatchlingMorphometricObservation: {hatchling_measurement}") # TurtleHatchlingEmergenceObservation objects - if 'fan_angles' in submission: - fan = submission['fan_angles'] + if "fan_angles" in submission: + fan = submission["fan_angles"] # Seawards photo - filename = fan['photo_hatchling_tracks_seawards'] - LOGGER.info(f'Downloading {filename}') + filename = fan["photo_hatchling_tracks_seawards"] + LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Seawards photo of fan angles {filename}', + media_type="photograph", + title=f"Seawards photo of fan angles {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") # Relief photo - filename = fan['photo_hatchling_tracks_relief'] - LOGGER.info(f'Downloading {filename}') + filename = fan["photo_hatchling_tracks_relief"] + LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Relief photo of fan angles {filename}', + media_type="photograph", + title=f"Relief photo of fan angles {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") emergence_obs = TurtleHatchlingEmergenceObservation( encounter=encounter, - bearing_to_water_degrees=float(fan['bearing_to_water_manual']) if fan['bearing_to_water_manual'] else None, - bearing_leftmost_track_degrees=float(fan['leftmost_track_manual']) if fan['leftmost_track_manual'] else None, - bearing_rightmost_track_degrees=float(fan['rightmost_track_manual']) if fan['rightmost_track_manual'] else None, - no_tracks_main_group=int(fan['no_tracks_main_group']) if fan['no_tracks_main_group'] else None, - no_tracks_main_group_min=int(fan['no_tracks_main_group_min']) if fan['no_tracks_main_group_min'] else None, - no_tracks_main_group_max=int(fan['no_tracks_main_group_max']) if fan['no_tracks_main_group_max'] else None, - outlier_tracks_present=fan['outlier_tracks_present'], - path_to_sea_comments=fan['path_to_sea_comments'], - hatchling_emergence_time_known=fan['hatchling_emergence_time_known'], - light_sources_present=fan['light_sources_present'], - cloud_cover_at_emergence=int(fan['cloud_cover_at_emergence']) if fan['cloud_cover_at_emergence'] else None, + bearing_to_water_degrees=float(fan["bearing_to_water_manual"]) if fan["bearing_to_water_manual"] else None, + bearing_leftmost_track_degrees=float(fan["leftmost_track_manual"]) if fan["leftmost_track_manual"] else None, + bearing_rightmost_track_degrees=float(fan["rightmost_track_manual"]) if fan["rightmost_track_manual"] else None, + no_tracks_main_group=int(fan["no_tracks_main_group"]) if fan["no_tracks_main_group"] else None, + no_tracks_main_group_min=int(fan["no_tracks_main_group_min"]) if fan["no_tracks_main_group_min"] else None, + no_tracks_main_group_max=int(fan["no_tracks_main_group_max"]) if fan["no_tracks_main_group_max"] else None, + outlier_tracks_present=fan["outlier_tracks_present"], + path_to_sea_comments=fan["path_to_sea_comments"], + hatchling_emergence_time_known=fan["hatchling_emergence_time_known"], + light_sources_present=fan["light_sources_present"], + cloud_cover_at_emergence=int(fan["cloud_cover_at_emergence"]) if fan["cloud_cover_at_emergence"] else None, ) - if 'hatchling_emergence_time_group' in submission: - emergence = submission['hatchling_emergence_time_group'] - emergence_obs.hatchling_emergence_time = parser.isoparse(emergence['hatchling_emergence_time']) - emergence_obs.hatchling_emergence_time_accuracy = emergence['hatchling_emergence_time_source'] + if "hatchling_emergence_time_group" in submission: + emergence = submission["hatchling_emergence_time_group"] + emergence_obs.hatchling_emergence_time = parser.isoparse(emergence["hatchling_emergence_time"]) + emergence_obs.hatchling_emergence_time_accuracy = emergence["hatchling_emergence_time_source"] # TODO: path to sea record. emergence_obs.save() - LOGGER.info(f'Created TurtleHatchlingEmergenceObservation {emergence_obs}') + LOGGER.info(f"Created TurtleHatchlingEmergenceObservation {emergence_obs}") - if 'outlier_tracks' in submission: + if "outlier_tracks" in submission: # Might be a list or a single object :| - outliers = submission['outlier_tracks']['outlier_track'] + outliers = submission["outlier_tracks"]["outlier_track"] if not isinstance(outliers, list): outliers = [outliers] for outlier in outliers: outlier_obs = TurtleHatchlingEmergenceOutlierObservation( encounter=encounter, - bearing_outlier_track_degrees=float(outlier['outlier_track_bearing_manual']) if outlier['outlier_track_bearing_manual'] else None, - outlier_group_size=int(outlier['outlier_group_size']) if outlier['outlier_group_size'] else None, - outlier_track_comment=outlier['outlier_track_comment'], + bearing_outlier_track_degrees=float(outlier["outlier_track_bearing_manual"]) if outlier["outlier_track_bearing_manual"] else None, + outlier_group_size=int(outlier["outlier_group_size"]) if outlier["outlier_group_size"] else None, + outlier_track_comment=outlier["outlier_track_comment"], ) outlier_obs.save() - LOGGER.info(f'Created TurtleHatchlingEmergenceOutlierObservation {outlier_obs}') + LOGGER.info(f"Created TurtleHatchlingEmergenceOutlierObservation {outlier_obs}") # Outlier photo - filename = outlier['outlier_track_photo'] - LOGGER.info(f'Downloading {filename}') + filename = outlier["outlier_track_photo"] + LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Outlier track of fan angles {filename}', + media_type="photograph", + title=f"Outlier track of fan angles {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') + LOGGER.info(f"Created MediaAttachment {photo}") - if 'light_sources' in submission: + if "light_sources" in submission: # Might be a list or a single object :| - light_sources = submission['light_sources']['light_source'] + light_sources = submission["light_sources"]["light_source"] if not isinstance(light_sources, list): light_sources = [light_sources] for source in light_sources: source_obs = LightSourceObservation( encounter=encounter, - bearing_light_degrees=int(source['light_bearing_manual']) if source['light_bearing_manual'] else None, - light_source_type=source['light_source_type'], - light_source_description=source['light_source_description'], + bearing_light_degrees=int(source["light_bearing_manual"]) if source["light_bearing_manual"] else None, + light_source_type=source["light_source_type"], + light_source_description=source["light_source_description"], ) source_obs.save() - LOGGER.info(f'Created LightSourceObservation {source_obs}') + LOGGER.info(f"Created LightSourceObservation {source_obs}") # Light source photo - filename = source['light_source_photo'] - LOGGER.info(f'Downloading {filename}') + filename = source["light_source_photo"] + LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = MediaAttachment( encounter=encounter, - media_type='photograph', - title=f'Light source photo {filename}', + media_type="photograph", + title=f"Light source photo {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created MediaAttachment {photo}') - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd turtle track or nest import failed!", f"An error occurred, a turtle track or nest record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) + LOGGER.info(f"Created MediaAttachment {photo}") + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") def import_turtle_track_or_nest_simple(form_id="beach_tracks_nest_simple", auth_headers=None): @@ -592,11 +589,8 @@ def import_turtle_track_or_nest_simple(form_id="beach_tracks_nest_simple", auth_ ) disturbance.save() LOGGER.info(f'Created TurtleNestDisturbanceObservation: {disturbance}') - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd turtle track or nest simple import failed!", f"An error occurred, a turtle track or nest simple record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") def import_site_visit_start(form_id="site_visit_start", initial_duration_hr=8, auth_headers=None): @@ -678,11 +672,8 @@ def import_site_visit_start(form_id="site_visit_start", initial_duration_hr=8, a ) photo.save() LOGGER.info(f'Created SurveyMediaAttachment {photo}') - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd survey start import failed!", f"An error occurred, a survey start record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") def import_site_visit_end(form_id="site_visit_end", duration_hr=8, auth_headers=None): @@ -765,17 +756,8 @@ def import_site_visit_end(form_id="site_visit_end", duration_hr=8, auth_headers= ) photo.save() LOGGER.info(f'Created SurveyMediaAttachment {photo}') - - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd survey end import failed!", f"An error occurred, a survey end record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) - - #send an email with errors if needed - # if emailText != None: - # msg = EmailMultiAlternatives("Wastd import Errors", emailText, settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - # msg.send(fail_silently=True) + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") def import_marine_wildlife_incident(form_id="marine_wildlife_incident", auth_headers=None): @@ -1039,11 +1021,8 @@ def import_marine_wildlife_incident(form_id="marine_wildlife_incident", auth_hea ) morphometric_obs.save() LOGGER.info(f'Created TurtleMorphometricObservation: {morphometric_obs}') - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd marine wildlife incident import failed!", f"An error occurred, a marine wildlife incident record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") def import_turtle_sighting(form_id="turtle_sighting", auth_headers=None): @@ -1089,9 +1068,73 @@ def import_turtle_sighting(form_id="turtle_sighting", auth_headers=None): encounter.save() - LOGGER.info(f'Created AnimalEncounter {encounter}') - except Exception as e: # catches all exceptions and send an email, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - LOGGER.error(f"An error occurred: {exception_message}") - msg = EmailMultiAlternatives("Wastd turtle sighting import failed!", f"An error occurred, a turtle sighting record was not imported: {exception_message}", settings.DEFAULT_FROM_EMAIL, settings.ADMIN_EMAILS) - msg.send(fail_silently=True) + LOGGER.info(f"Created AnimalEncounter {encounter}") + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") + + +def import_predator_or_disturbance(form_id="predator_or_disturbance", auth_headers=None): + """Import submissions to the Predator or Disturbance ODK form. + Each submission should create: + 1 Encounter (type: disturbance) + 1+ DisturbanceObservation (disturbance/predator observation) + """ + if not auth_headers: + LOGGER.info("Downloading auth headers") + auth_headers = get_auth_headers() + project_id = settings.ODK_API_PROJECTID + LOGGER.info(f"Downloading {form_id} submission data") + submissions = get_form_submission_data(auth_headers, project_id, form_id) + + for submission in submissions: + try: + instance_id = submission["meta"]["instanceID"] + if Encounter.objects.filter(source="odk", source_id=instance_id): + continue # Skip records already imported. + + # Try to match the reporter to an existing user. If not, create a new one. + reporter = submission["reporter"] + user = get_user(reporter) + start = parser.isoparse(submission["start_time"]) + disturbance = submission["disturbance"] + + encounter = Encounter( + status="imported", + source="odk", + source_id=instance_id, + where=parse_geopoint(disturbance["location"]), + when=start, + observer=user, + reporter=user, + encounter_type=Encounter.ENCOUNTER_DISTURBANCE, + ) + # Try to determine the encounter site & area. + encounter.area = encounter.guess_area + encounter.site = encounter.guess_site + encounter.save() + LOGGER.info(f"Created Encounter: {encounter}") + + # MediaAttachment (photo). + filename = disturbance["photo"] + LOGGER.info(f"Downloading {filename}") + attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) + photo = MediaAttachment( + encounter=encounter, + media_type="photograph", + title=f"Disturbance/predator photo {filename}", + attachment=attachment, + ) + photo.save() + LOGGER.info(f"Created MediaAttachment {photo}") + + # DisturbanceObservation object. + disturbance_observation = DisturbanceObservation( + encounter=encounter, + disturbance_cause=disturbance["cause"], + disturbance_cause_confidence=disturbance["confidence"], + comments=disturbance["comments"], + ) + disturbance_observation.save() + LOGGER.info(f"Created DisturbanceObservation {disturbance_observation}") + except: + LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") diff --git a/wastd/odk.py b/wastd/odk.py index 027d9b673..f174e1e6e 100644 --- a/wastd/odk.py +++ b/wastd/odk.py @@ -14,6 +14,7 @@ ODK_API_URL = settings.ODK_API_URL logger = logging.getLogger('turtles') + def get_auth_headers(email=None, password=None): """Returns a dict containing authorization headers for ODK. """ @@ -64,13 +65,13 @@ def get_submission(auth_headers, project_id, form_id, instance_id): """ resp = requests.get(f"{ODK_API_URL}/projects/{project_id}/forms/{form_id}/submissions/{instance_id}.xml", headers=auth_headers) resp.raise_for_status() - + try: data = xmltodict.parse(resp.content, xml_attribs=False)['data'] except Exception as e: - print(str(e)) # print the exception message + print(str(e)) return [] - + return data @@ -81,18 +82,18 @@ def get_form_submission_data(auth_headers, project_id, form_id, skip_existing=Tr """ # Get submission metadata for the form. submissions_metadata = get_submissions_metadata(auth_headers, project_id, form_id) - + # Get individual submission data records. submission_data = [] for metadata in submissions_metadata: if skip_existing: # Check to see if record is already present in the local database. if Encounter.objects.filter(source='odk', source_id=metadata["instanceId"]).exists(): continue - + if skip_rejected and metadata["reviewState"] == "rejected": logger.info("skipping rejected: " + metadata["instanceId"]) continue - + submission = get_submission(auth_headers, project_id, form_id, metadata["instanceId"]) submission_data.append(submission) From cc6778353a8ae60e435207b908aa506a2f017fe7 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 30 Apr 2024 08:46:28 +0800 Subject: [PATCH 30/40] Added import_predator_or_disturbance function to management command. --- .../commands/odk_download_turtle_forms.py | 76 +++++++++---------- observations/odk.py | 68 +++++++---------- 2 files changed, 66 insertions(+), 78 deletions(-) diff --git a/observations/management/commands/odk_download_turtle_forms.py b/observations/management/commands/odk_download_turtle_forms.py index d834d29dc..a15eb8a3e 100644 --- a/observations/management/commands/odk_download_turtle_forms.py +++ b/observations/management/commands/odk_download_turtle_forms.py @@ -6,77 +6,77 @@ import_site_visit_end, import_marine_wildlife_incident, import_turtle_sighting, - import_turtle_track_or_nest_simple + import_turtle_track_or_nest_simple, + import_predator_or_disturbance, ) from wastd.odk import get_auth_headers class Command(BaseCommand): - help = 'Runs ETL scripts to download Turtle Monitoring form submissions from ODK Central' + help = "Runs ETL scripts to download Turtle Monitoring form submissions from ODK Central" def add_arguments(self, parser): parser.add_argument( - '--initial-duration', - action='store', + "--initial-duration", + action="store", default=8, type=int, - help='Initial default duration (hours) to set as the length of new surveys', - dest='initial_duration', + help="Initial default duration (hours) to set as the length of new surveys", + dest="initial_duration", ) parser.add_argument( - '--duration', - action='store', + "--duration", + action="store", default=8, type=int, - help='Duration (hours) within which surveys should try to automatically link and claim orphan encounters', - dest='duration', + help="Duration (hours) within which surveys should try to automatically link and claim orphan encounters", + dest="duration", ) def handle(self, *args, **options): - logger = logging.getLogger('turtles') + logger = logging.getLogger("turtles") logger.info("Downloading auth headers") auth_headers = get_auth_headers() - logger.info('Downloading data from Turtle Track or Nest form') + logger.info("Downloading data from Turtle Track or Nest form") try: import_turtle_track_or_nest(auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + except: + logger.error(f"An error occurred during import of Turtle Track or Nest") - - logger.info('Downloading data from Simple Turtle Track or Nest form') + logger.info("Downloading data from Simple Turtle Track or Nest form") try: import_turtle_track_or_nest_simple(auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + except: + logger.error(f"An error occurred during import of Simple Turtle Track or Nest") - logger.info('Downloading data from Site Visit Start form') + logger.info("Downloading data from Site Visit Start form") try: - import_site_visit_start(initial_duration_hr=options['initial_duration'], auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + import_site_visit_start(initial_duration_hr=options["initial_duration"], auth_headers=auth_headers) + except: + logger.error(f"An error occurred during import of Site Visit Start") - logger.info('Downloading data from Site Visit End form, linking encounters') + logger.info("Downloading data from Site Visit End form, linking encounters") try: - import_site_visit_end(duration_hr=options['duration'], auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + import_site_visit_end(duration_hr=options["duration"], auth_headers=auth_headers) + except: + logger.error(f"An error occurred during import of Site Visit End") - logger.info('Downloading data from Marine Wildlife Incident form') + logger.info("Downloading data from Marine Wildlife Incident form") try: import_marine_wildlife_incident(auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + except: + logger.error(f"An error occurred during import of Marine Wildlife Incident") - logger.info('Downloading data from Turtle Sighting form') + logger.info("Downloading data from Turtle Sighting form") try: import_turtle_sighting(auth_headers=auth_headers) - except Exception as e: # catches all exceptions, also log the exception type - exception_message = f"{e.__class__.__name__}: {e}" - logger.error(f"An error occurred: {exception_message}") + except: + logger.error(f"An error occurred during import of Turtle Sighting") + + logger.info("Downloading data from Predator or Disturbance form") + try: + import_predator_or_disturbance(auth_headers=auth_headers) + except: + logger.error(f"An error occurred during import of Predator or Disturbance") diff --git a/observations/odk.py b/observations/odk.py index dce35b74e..24f2fe04a 100644 --- a/observations/odk.py +++ b/observations/odk.py @@ -2,7 +2,6 @@ from dateutil import parser from django.conf import settings import logging -from . import lookups from users.models import User from wastd.odk import ( @@ -12,7 +11,7 @@ parse_geopoint_accuracy, get_submission_attachment, ) -from .lookups import TURTLE_INTERACTION_CHOICES +from .lookups import NA_VALUE, TURTLE_INTERACTION_CHOICES from .models import ( Area, Survey, @@ -101,9 +100,9 @@ def import_turtle_track_or_nest(form_id="turtle_track_or_nest", auth_headers=Non #check for new forms if "survey_start_time" in submission["details"]: - startTime = parser.isoparse(submission["details"]["survey_start_time"]) # New forms allow editing of time in case submitted after the fact + start_time = parser.isoparse(submission["details"]["survey_start_time"]) # New forms allow editing of time in case submitted after the fact else: - startTime = parser.isoparse(submission["start_time"]) # Old forms + start_time = parser.isoparse(submission["start_time"]) # Old forms # Confusingly, TurtleNestEncounter objects cover nest, track and nest & track encounters. encounter = TurtleNestEncounter( @@ -111,7 +110,7 @@ def import_turtle_track_or_nest(form_id="turtle_track_or_nest", auth_headers=Non source="odk", source_id=instance_id, where=parse_geopoint(submission["details"]["observed_at"]), - when=startTime, + when=start_time, observer=user, reporter=user, comments=f"Device ID {submission['device_id']}", @@ -503,9 +502,9 @@ def import_turtle_track_or_nest_simple(form_id="beach_tracks_nest_simple", auth_ #check for new forms if 'survey_start_time' in submission['details']: - startTime = parser.isoparse(submission['details']['survey_start_time']) # New forms allow editing of time in case submitted after the fact + start_time = parser.isoparse(submission['details']['survey_start_time']) # New forms allow editing of time in case submitted after the fact else: - startTime = parser.isoparse(submission['start_time']) # Old forms + start_time = parser.isoparse(submission['start_time']) # Old forms # Confusingly, TurtleNestEncounter objects cover nest, track and nest & track encounters. encounter = TurtleNestEncounter( @@ -513,7 +512,7 @@ def import_turtle_track_or_nest_simple(form_id="beach_tracks_nest_simple", auth_ source='odk', source_id=instance_id, where=parse_geopoint(submission['details']['observed_at']), - when=startTime, + when=start_time, observer=user, reporter=user, comments=f'{submission["details"]["comments"]} (Device ID {submission["device_id"]})', @@ -615,11 +614,11 @@ def import_site_visit_start(form_id="site_visit_start", initial_duration_hr=8, a user = get_user(reporter) visit = submission['site_visit'] - #check for new forms + # Check for new forms if 'survey_start_time' in visit: - startTime = parser.isoparse(visit['survey_start_time']) # New forms allow editing of time in case submitted after the fact + start_time = parser.isoparse(visit['survey_start_time']) # New forms allow editing of time in case submitted after the fact else: - startTime = parser.isoparse(submission['start_time']) # Old forms + start_time = parser.isoparse(submission['start_time']) # Old forms survey = Survey( status='imported', @@ -629,7 +628,7 @@ def import_site_visit_start(form_id="site_visit_start", initial_duration_hr=8, a reporter=user, start_location=parse_geopoint(visit['location']), start_location_accuracy_m=parse_geopoint_accuracy(visit['location']), - start_time=startTime, + start_time=start_time, ) # Guess the area & site, and plug in an initial estimated end_time. @@ -638,7 +637,7 @@ def import_site_visit_start(form_id="site_visit_start", initial_duration_hr=8, a survey.site = survey.guess_site survey.end_time = survey.start_time + timedelta(hours=initial_duration_hr) - #set training surveys to non production + # Set training surveys to non production if survey.site is not None: if 'training' in survey.site.name.lower() or 'testing' in survey.site.name.lower(): survey.production = False @@ -687,38 +686,31 @@ def import_site_visit_end(form_id="site_visit_end", duration_hr=8, auth_headers= project_id = settings.ODK_API_PROJECTID LOGGER.info(f"Downloading {form_id} submission data") submissions = get_form_submission_data(auth_headers, project_id, form_id) - #email content if any errors - emailText = None for submission in submissions: try: - instance_id = submission['meta']['instanceID'] - if Survey.objects.filter(source='odk', end_source_id=instance_id): + instance_id = submission["meta"]["instanceID"] + if Survey.objects.filter(source="odk", end_source_id=instance_id): continue # Skip records already imported. # Try to match a site by location (just use the first one returned by the database). - visit = submission['site_visit'] - location = parse_geopoint(visit['location']) + visit = submission["site_visit"] + location = parse_geopoint(visit["location"]) site = Area.objects.filter(area_type=Area.AREATYPE_SITE, geom__covers=location).first() if not site: # Send a warning to the admins to investigate & address. log = (f"Site Visit End form: unable to match a site for survey end at {location.wkt}") LOGGER.warning(log) - if emailText is None: - emailText = log - else: - emailText = emailText + "\n\n" + log continue # Try to match one (only) existing Survey object. # Algorithm: filter Surveys in the same Site, having a start_time not before end_time by # greater than `duration_hr` hours. - #check for new forms - if 'survey_start_time' in visit: - end_time = parser.isoparse(visit['survey_end_time']) # New forms allow editing of time in case submitted after the fact + if "survey_start_time" in visit: + end_time = parser.isoparse(visit["survey_end_time"]) # New forms allow editing of time in case submitted after the fact else: - end_time = parser.isoparse(submission['end_time']) # Old forms + end_time = parser.isoparse(submission["end_time"]) # Old forms start_time_earliest = end_time - timedelta(hours=duration_hr) surveys = Survey.objects.filter( @@ -727,35 +719,31 @@ def import_site_visit_end(form_id="site_visit_end", duration_hr=8, auth_headers= if surveys.count() != 1: log = (f"Site Visit End form: unable to match a single Survey (matched {surveys.count()})") LOGGER.warning(log) - if emailText is None: - emailText = log - else: - emailText = emailText + "\n\n" + log continue else: survey = surveys.first() survey.end_source_id = instance_id survey.end_location = location - survey.end_location_accuracy_m = parse_geopoint_accuracy(visit['location']) + survey.end_location_accuracy_m = parse_geopoint_accuracy(visit["location"]) survey.end_time = end_time - survey.end_comments = visit['comments'] + survey.end_comments = visit["comments"] survey.save() - LOGGER.info(f'Updated Survey {survey}') + LOGGER.info(f"Updated Survey {survey}") - if visit['site_conditions']: - filename = visit['site_conditions'] + if visit["site_conditions"]: + filename = visit["site_conditions"] LOGGER.info(f"Downloading {filename}") attachment = get_submission_attachment(auth_headers, project_id, form_id, instance_id, filename) photo = SurveyMediaAttachment( survey=survey, - media_type='photograph', - title=f'Photo of site visit end {filename}', + media_type="photograph", + title=f"Photo of site visit end {filename}", attachment=attachment, ) photo.save() - LOGGER.info(f'Created SurveyMediaAttachment {photo}') + LOGGER.info(f"Created SurveyMediaAttachment {photo}") except: LOGGER.error(f"Exception during import of ODK {form_id} submission {instance_id}") @@ -789,7 +777,7 @@ def import_marine_wildlife_incident(form_id="marine_wildlife_incident", auth_hea if site_visit['habitat']: habitat = site_visit['habitat'] else: - habitat = lookups.NA_VALUE + habitat = NA_VALUE encounter = AnimalEncounter( status='imported', source='odk', From 554a856c67edadb4260f7d650c54775ba32b544b Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Tue, 30 Apr 2024 11:00:25 +0800 Subject: [PATCH 31/40] UI functions to view, filter & download DisturbanceObservation objects. --- observations/admin.py | 36 +++++++++++++++++- observations/filters.py | 16 ++++++++ observations/models.py | 5 +++ observations/resources.py | 18 +++++++++ .../disturbance_observation_card.html | 37 +++++++++++++++++++ .../popup/disturbanceobservation.html | 4 ++ observations/urls.py | 1 + observations/views.py | 26 ++++++++++++- wastd/templates/navbar.html | 6 +++ 9 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 observations/templates/observations/disturbance_observation_card.html create mode 100644 observations/templates/popup/disturbanceobservation.html diff --git a/observations/admin.py b/observations/admin.py index fb4e982b7..6336b252b 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -37,7 +37,8 @@ TurtleHatchlingEmergenceObservation, TurtleHatchlingEmergenceOutlierObservation, LightSourceObservation, - LoggerObservation + LoggerObservation, + DisturbanceObservation, ) from .resources import ( SurveyResource, @@ -46,6 +47,7 @@ LineTransectEncounterResource, TurtleNestDisturbanceObservationResource, TurtleTrackObservationResource, + DisturbanceObservationResource, ) @@ -534,6 +536,38 @@ def get_queryset(self, request): ) +@register(DisturbanceObservation) +class DisturbanceObservationAdmin(ExportActionMixin, ObservationAdminMixin): + + list_display = ( + ObservationAdminMixin.LIST_FIRST + + ( + "disturbance_cause", + "disturbance_cause_confidence", + "comments", + ) + + ObservationAdminMixin.LIST_LAST + ) + list_filter = ObservationAdminMixin.LIST_FILTER + ( + "disturbance_cause", + "disturbance_cause_confidence", + ) + resource_classes = [DisturbanceObservationResource] + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related( + "encounter", + "encounter__reporter", + "encounter__observer", + "encounter__area", + "encounter__site", + ) + ) + + @register(TurtleNestObservation) class TurtleNestObservationAdmin(ObservationAdminMixin): diff --git a/observations/filters.py b/observations/filters.py index 044894e6f..39fcba430 100644 --- a/observations/filters.py +++ b/observations/filters.py @@ -15,6 +15,7 @@ TurtleNestEncounter, LineTransectEncounter, TurtleNestDisturbanceObservation, + DisturbanceObservation, ) from .lookups import ( HEALTH_CHOICES, @@ -320,3 +321,18 @@ class Meta: "disturbance_cause", "disturbance_cause_confidence", ] + + +class DisturbanceObservationFilter(TurtleNestDisturbanceObservationFilter): + + class Meta: + model = DisturbanceObservation + fields = [ + "date_from", + "date_to", + "area", + "site", + "status", + "disturbance_cause", + "disturbance_cause_confidence", + ] diff --git a/observations/models.py b/observations/models.py index 2344ff01e..8485a7bd0 100644 --- a/observations/models.py +++ b/observations/models.py @@ -898,6 +898,7 @@ class Encounter(PolymorphicModel, UrlsMixin, models.Model): ENCOUNTER_TAG: "cog", ENCOUNTER_INWATER: "water", ENCOUNTER_LOGGER: "tablet", + ENCOUNTER_DISTURBANCE: "circle-exclamation", ENCOUNTER_OTHER: "circle-question", } @@ -909,6 +910,7 @@ class Encounter(PolymorphicModel, UrlsMixin, models.Model): ENCOUNTER_TRACKS: "cadetblue", ENCOUNTER_TAG: "darkpuple", ENCOUNTER_LOGGER: "orange", + ENCOUNTER_DISTURBANCE: "red", ENCOUNTER_OTHER: "purple", } @@ -3223,3 +3225,6 @@ class DisturbanceObservation(Observation): def __str__(self): return f"{self.pk}: Disturbance/predator ({self.get_disturbance_cause_display().lower()}), {self.get_disturbance_cause_confidence_display().lower()}" + + def card_template(self): + return "observations/disturbance_observation_card.html" diff --git a/observations/resources.py b/observations/resources.py index 25ae98272..47f590c9a 100644 --- a/observations/resources.py +++ b/observations/resources.py @@ -13,6 +13,7 @@ Observation, TurtleNestDisturbanceObservation, TurtleTrackObservation, + DisturbanceObservation, ) @@ -636,6 +637,23 @@ def dehydrate_disturbance_severity(self, obj): return obj.get_disturbance_severity_display() +class DisturbanceObservationResource(ObservationResource): + + class Meta: + model = DisturbanceObservation + fields = ObservationResource.Meta.fields + [ + "disturbance_cause", + "disturbance_cause_confidence", + "comments", + ] + + def dehydrate_disturbance_cause(self, obj): + return obj.get_disturbance_cause_display() + + def dehydrate_disturbance_cause_confidence(self, obj): + return obj.get_disturbance_cause_confidence_display() + + class TurtleTrackObservationResource(ObservationResource): class Meta: diff --git a/observations/templates/observations/disturbance_observation_card.html b/observations/templates/observations/disturbance_observation_card.html new file mode 100644 index 000000000..91a726226 --- /dev/null +++ b/observations/templates/observations/disturbance_observation_card.html @@ -0,0 +1,37 @@ +{% load observations bootstrap4 %} +
    +
    +
    + + QA status: {{ object.encounter.get_status_display }} + +
    {{ object.pk}}: {{ object.get_disturbance_cause_display }}
    +
    +
    +
    + + {{ object.encounter.when|date:'d M Y H:i' }} + {% if object.encounter.site %} + + {{ object.encounter.site.name }} + {% endif %} + + + Encounter {{ object.encounter.pk }} + + +
    +
    +
    +
    +
    + + {{ object.get_disturbance_cause_confidence_display }} +
    + {% if object.comments %} +
    + {{ object.comments }} +
    + {% endif %} +
    +
    diff --git a/observations/templates/popup/disturbanceobservation.html b/observations/templates/popup/disturbanceobservation.html new file mode 100644 index 000000000..107b01f5d --- /dev/null +++ b/observations/templates/popup/disturbanceobservation.html @@ -0,0 +1,4 @@ +{% with original as o %} + +{{ o.get_disturbance_cause_display }} ({{ o.get_disturbance_cause_confidence_display|lower }}) +{% endwith %} diff --git a/observations/urls.py b/observations/urls.py index 32dcfcdc1..32aa9eb1a 100644 --- a/observations/urls.py +++ b/observations/urls.py @@ -23,4 +23,5 @@ path("line-transect-encounters/", views.LineTransectEncounterList.as_view(), name="linetransectencounter-list"), path("line-transect-encounters//", views.LineTransectEncounterDetail.as_view(), name="linetransectencounter-detail"), path("turtle-nest-disturbance-observations/", views.TurtleNestDisturbanceObservationList.as_view(), name="turtlenestdisturbanceobservation-list"), + path("disturbance-observations/", views.DisturbanceObservationList.as_view(), name="disturbanceobservation-list"), ] diff --git a/observations/views.py b/observations/views.py index baffd789a..0e08b870a 100644 --- a/observations/views.py +++ b/observations/views.py @@ -28,6 +28,7 @@ TurtleNestEncounterAdmin, LineTransectEncounterAdmin, TurtleNestDisturbanceObservationAdmin, + DisturbanceObservationAdmin, ) from .filters import ( SurveyFilter, @@ -36,6 +37,7 @@ TurtleNestEncounterFilter, LineTransectEncounterFilter, TurtleNestDisturbanceObservationFilter, + DisturbanceObservationFilter, ) from .forms import ( SurveyMergeForm, @@ -49,6 +51,7 @@ LineTransectEncounter, TagObservation, TurtleNestDisturbanceObservation, + DisturbanceObservation, ) from .resources import ( SurveyResource, @@ -57,6 +60,7 @@ TurtleNestEncounterResource, LineTransectEncounterResource, TurtleNestDisturbanceObservationResource, + DisturbanceObservationResource, ) @@ -360,7 +364,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["list_filter"] = TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=self.get_queryset()) context["model_admin"] = TurtleNestDisturbanceObservationAdmin - context["page_title"] = f"{settings.SITE_CODE} | Turtle nest disturbances" + context["page_title"] = f"{settings.SITE_CODE} | Turtle nest disturbance observations" return context def get_queryset(self): @@ -368,6 +372,26 @@ def get_queryset(self): return TurtleNestDisturbanceObservationFilter(self.request.GET, queryset=qs).qs +class DisturbanceObservationList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): + model = DisturbanceObservation + template_name = "default_list.html" + paginate_by = 20 + filter_class = DisturbanceObservationFilter + resource_class = DisturbanceObservationResource + resource_formats = ["csv", "xlsx"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["list_filter"] = DisturbanceObservationFilter(self.request.GET, queryset=self.get_queryset()) + context["model_admin"] = DisturbanceObservationAdmin + context["page_title"] = f"{settings.SITE_CODE} | Predators / disturbance observations" + return context + + def get_queryset(self): + qs = super().get_queryset() + return DisturbanceObservationFilter(self.request.GET, queryset=qs).qs + + class LineTransectEncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): model = LineTransectEncounter template_name = "default_list.html" diff --git a/wastd/templates/navbar.html b/wastd/templates/navbar.html index 7f717c7d4..fa8afb08c 100644 --- a/wastd/templates/navbar.html +++ b/wastd/templates/navbar.html @@ -94,6 +94,12 @@ Turtle nest disturbance observations + + + + Predator / disturbance observations +
    From 52c0fdc23359160259b5978f526174b3d25fcbc7 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 1 May 2024 12:34:24 +0800 Subject: [PATCH 32/40] Tweak FSM transitions allowed, register TissueSampleObservation in admin. --- observations/admin.py | 33 ++++++++++++++++++- observations/models.py | 28 +++++++++++++--- .../templates/observations/survey_form.html | 17 ++++++++++ wastd/utils.py | 12 +++++-- 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 observations/templates/observations/survey_form.html diff --git a/observations/admin.py b/observations/admin.py index 6336b252b..d8b353269 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -38,6 +38,7 @@ TurtleHatchlingEmergenceOutlierObservation, LightSourceObservation, LoggerObservation, + TissueSampleObservation, DisturbanceObservation, ) from .resources import ( @@ -843,7 +844,7 @@ class LoggerObservationAdmin(ObservationAdminMixin): list_display = ( ObservationAdminMixin.LIST_FIRST - + ("logger_type", "deployment_status", "logger_id", "comments") + + ("logger_type", "deployment_status", "logger_id") + ObservationAdminMixin.LIST_LAST ) list_filter = ObservationAdminMixin.LIST_FILTER + ( @@ -869,6 +870,36 @@ def get_queryset(self, request): ) +@register(TissueSampleObservation) +class TissueSampleObservationAdmin(ObservationAdminMixin): + + list_display = ( + ObservationAdminMixin.LIST_FIRST + + ("sample_type", "serial") + + ObservationAdminMixin.LIST_LAST + ) + list_filter = ObservationAdminMixin.LIST_FILTER + ( + "sample_type", + ) + search_fields = ( + "serial", + "description", + ) + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related( + "encounter", + "encounter__reporter", + "encounter__observer", + "encounter__area", + "encounter__site", + ) + ) + + @register(Survey) class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): diff --git a/observations/models.py b/observations/models.py index 8485a7bd0..32c40b768 100644 --- a/observations/models.py +++ b/observations/models.py @@ -533,7 +533,10 @@ def make_label(self): ) def label_short(self): - return "Survey {} of {}".format(self.pk, "unknown site" if not self.site else self.site.name) + if not self.production: + return "Survey {} of {} (non-production)".format(self.pk, "unknown site" if not self.site else self.site.name) + else: + return "Survey {} of {}".format(self.pk, "unknown site" if not self.site else self.site.name) @property def as_html(self): @@ -556,9 +559,9 @@ def start_date(self): @property def duplicate_surveys(self): - """A queryset of other production surveys on the same date and site with intersecting durations.""" + """A queryset of other surveys on the same date and site with intersecting durations.""" return ( - Survey.objects.filter(site=self.site, start_time__date=self.start_date, production=True) + Survey.objects.filter(site=self.site, start_time__date=self.start_date) .exclude(pk=self.pk) .exclude(start_time__gte=self.end_time) # surveys starting after self .exclude(end_time__lte=self.start_time) # surveys ending before self @@ -572,7 +575,22 @@ def no_duplicates(self): @property def has_duplicates(self): """Whether there are duplicate surveys.""" - return self.no_duplicates > 0 + return self.duplicate_surveys.exists() + + def has_production_duplicates(self): + """Whether there are duplicate production surveys. + """ + return ( + Survey.objects.filter(site=self.site, start_time__date=self.start_date, production=True) + .exclude(pk=self.pk) + .exclude(start_time__gte=self.end_time) # surveys starting after self + .exclude(end_time__lte=self.start_time) # surveys ending before self + ).exists() + + def make_production(self): + self.production = True + self.save() + return f"Marking {self} as production" def close_duplicates(self, actor=None): """Mark this Survey as the only production survey, others as training and adopt all Encounters. @@ -663,7 +681,7 @@ def close_duplicates(self, actor=None): cuckoo_encounters.count() ) - # Post-save runs claim_encounters + # Post-save runs claim_encounters in signals. self.save() LOGGER.info(msg) return msg diff --git a/observations/templates/observations/survey_form.html b/observations/templates/observations/survey_form.html new file mode 100644 index 000000000..925937ccc --- /dev/null +++ b/observations/templates/observations/survey_form.html @@ -0,0 +1,17 @@ +{% extends "base_wastd.html" %} +{% load static crispy_forms_tags bootstrap4 %} + +{% block breadcrumbs %} +{% include 'breadcrumbs.html' %} +{% endblock breadcrumbs %} + +{% block page_content_inner %} +{% if form.errors %} + +{% endif %} +
    +
    + {% crispy form %} +
    +
    +{% endblock page_content_inner %} diff --git a/wastd/utils.py b/wastd/utils.py index 56b6d63ac..ae339ba87 100644 --- a/wastd/utils.py +++ b/wastd/utils.py @@ -561,6 +561,7 @@ def can_proofread(self): """Return true if this document can be proofread.""" return True + # New -> Proofread @fsm_log_by @transition( field=status, @@ -591,6 +592,7 @@ def can_require_proofreading(self): """Return true if this document can be proofread.""" return True + # Proofread -> New @fsm_log_by @transition( field=status, @@ -619,10 +621,11 @@ def can_curate(self): """Return true if this record can be accepted.""" return True + # New|Imported|Proofread|Manual input|Flagged -> Curated @fsm_log_by @transition( field=status, - source=[STATUS_NEW, STATUS_PROOFREAD, STATUS_FLAGGED], + source=[STATUS_NEW, STATUS_IMPORTED, STATUS_PROOFREAD, STATUS_MANUAL_INPUT, STATUS_FLAGGED], target=STATUS_CURATED, conditions=[can_curate], # permission=lambda instance, user: user in instance.all_permitted, @@ -644,10 +647,11 @@ def can_flag(self): """Return true if curated status can be revoked.""" return True + # New|Imported|Manual input|Curated -> Flagged @fsm_log_by @transition( field=status, - source=STATUS_CURATED, + source=[STATUS_NEW, STATUS_IMPORTED, STATUS_MANUAL_INPUT, STATUS_CURATED], target=STATUS_FLAGGED, conditions=[can_flag], # permission=lambda instance, user: user in instance.all_permitted, @@ -672,10 +676,11 @@ def can_reject(self): """Return true if the record can be rejected as entirely wrong.""" return True + # New|Imported|Manual input|Proofread|Curated|Flagged -> Rejected @fsm_log_by @transition( field=status, - source=[STATUS_PROOFREAD, STATUS_CURATED, STATUS_FLAGGED], + source=[STATUS_NEW, STATUS_IMPORTED, STATUS_MANUAL_INPUT, STATUS_PROOFREAD, STATUS_CURATED, STATUS_FLAGGED], target=STATUS_REJECTED, conditions=[can_flag], # permission=lambda instance, user: user in instance.all_permitted, @@ -693,6 +698,7 @@ def can_reset(self): """Return true if the record QA status can be reset.""" return True + # Rejected -> New @fsm_log_by @transition( field=status, From 3172e8d312837fa504019370e196201db3819a70 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 1 May 2024 12:35:11 +0800 Subject: [PATCH 33/40] Add survey views to merge & close duplicates, and make production. --- observations/forms.py | 60 ++++++++++++++++- .../templates/observations/survey_card.html | 21 +++++- .../templates/observations/survey_detail.html | 28 ++++++-- observations/urls.py | 5 +- observations/views.py | 67 +++++++++++++++---- 5 files changed, 155 insertions(+), 26 deletions(-) diff --git a/observations/forms.py b/observations/forms.py index f0cc33dd1..67f859939 100644 --- a/observations/forms.py +++ b/observations/forms.py @@ -16,7 +16,7 @@ class SurveyMergeForm(forms.Form): queryset=Survey.objects.none(), required=False, ) - save_button = Submit("save", "Merge duplicate survey", css_class="btn-lg") + save_button = Submit("save", "Merge duplicate", css_class="btn-lg") cancel_button = Submit("cancel", "Cancel", css_class="btn-secondary") def __init__(self, survey, *args, **kwargs): @@ -27,13 +27,67 @@ def __init__(self, survey, *args, **kwargs): self.helper.layout = Layout( Div( Fieldset( - "Merge duplicate surveys", - HTML(f"

    {survey.label_short()}

    "), + f"{survey.label_short()} - merge duplicate survey", + HTML(f"
    Merge the selected survey below into this one and adopt any encounters from the duplicate.
    "), Field("survey_duplicates"), ), Div( self.save_button, self.cancel_button, + css_class='pb-2', + ), + ), + ) + + +class SurveyCloseDuplicatesForm(forms.ModelForm): + + confirm_button = Submit("confirm", "Confirm", css_class="btn-lg") + cancel_button = Submit("cancel", "Cancel", css_class="btn-secondary") + + class Meta: + model = Survey + exclude = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = kwargs["instance"] + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Fieldset( + f"Survey {instance.pk} - close {instance.no_duplicates} duplicates", + HTML(f"
    Mark {instance.label_short()} as production, {instance.no_duplicates} duplicates as merged, and adopt all their encounters?
    "), + ), + Div( + self.confirm_button, + self.cancel_button, + css_class='py-2', + ), + ), + ) + + def is_valid(self): + # Bypass all form validation. + return True + + +class SurveyMakeProductionForm(SurveyCloseDuplicatesForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = kwargs["instance"] + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Fieldset( + f"Survey {instance.pk} - make production", + HTML(f"
    Mark {instance.label_short()} as production?
    "), + ), + Div( + self.confirm_button, + self.cancel_button, + css_class='py-2', ), ), ) diff --git a/observations/templates/observations/survey_card.html b/observations/templates/observations/survey_card.html index 38464ef67..5754b639c 100644 --- a/observations/templates/observations/survey_card.html +++ b/observations/templates/observations/survey_card.html @@ -28,19 +28,34 @@

    diff --git a/observations/templates/observations/survey_detail.html b/observations/templates/observations/survey_detail.html index febe1ae0f..ad17f9247 100644 --- a/observations/templates/observations/survey_detail.html +++ b/observations/templates/observations/survey_detail.html @@ -33,12 +33,16 @@

    {{ object.label_short }}

    {% include "observations/survey_site_visit_start_end.html" with survey=object %} {% if request.user.is_staff %} - + Edit {% if not object.production %} - {% include "observations/survey_make_production.html" with survey=object %} + + + Make production + + {% endif %} {% endif %}
    @@ -97,15 +101,27 @@

    {{ object.label_short }}

    Duplicate surveys - {% if object.production and object.has_duplicates %} + {% if object.has_duplicates %} + {% for survey in object.duplicate_surveys.all %} - + {% endfor %} - + + {% if object.production %} + - Merge duplicates + Merge duplicate + {% if object.has_production_duplicates %} + + + Close all duplicates + + + {% endif %} + {% endif %} + {% endif %} diff --git a/observations/urls.py b/observations/urls.py index 32aa9eb1a..c90e220e3 100644 --- a/observations/urls.py +++ b/observations/urls.py @@ -13,8 +13,9 @@ path("animal-encounters//reject/", views.AnimalEncounterReject.as_view(), name="animalencounter-reject"), path("surveys/", views.SurveyList.as_view(), name="survey-list"), path("surveys//", views.SurveyDetail.as_view(), name="survey-detail"), - path("surveys//merge/", views.SurveyMergeView.as_view(), name="survey-merge"), - path("surveys//close_duplicates", views.close_survey_duplicates, name="survey-close-duplicates"), + path("surveys//merge/", views.SurveyMerge.as_view(), name="survey-merge"), + path("surveys//close_duplicates/", views.SurveyCloseDuplicates.as_view(), name="survey-close-duplicates"), + path("surveys//make_production", views.SurveyMakeProduction.as_view(), name="survey-make-production"), path("turtle-nest-encounters/", views.TurtleNestEncounterList.as_view(), name="turtlenestencounter-list"), path("turtle-nest-encounters//", views.TurtleNestEncounterDetail.as_view(), name="turtlenestencounter-detail"), path("turtle-nest-encounters//curate/", views.TurtleNestEncounterCurate.as_view(), name="turtlenestencounter-curate"), diff --git a/observations/views.py b/observations/views.py index 0e08b870a..faae7c75f 100644 --- a/observations/views.py +++ b/observations/views.py @@ -6,7 +6,7 @@ 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, TemplateView, ListView, DetailView, FormView +from django.views.generic import View, TemplateView, ListView, DetailView, FormView, UpdateView from django.views.generic.detail import SingleObjectMixin from django_fsm_log.models import StateLog from django.db import connection @@ -41,6 +41,8 @@ ) from .forms import ( SurveyMergeForm, + SurveyCloseDuplicatesForm, + SurveyMakeProductionForm, ) from .models import ( Survey, @@ -103,10 +105,10 @@ def get_context_data(self, **kwargs): return context -class SurveyMergeView(BreadcrumbContextMixin, FormView): +class SurveyMerge(BreadcrumbContextMixin, FormView): """Merge a survey into another. """ - template_name = "users/user_form.html" + template_name = "observations/survey_form.html" form_class = SurveyMergeForm def get_object(self): @@ -160,19 +162,60 @@ def form_valid(self, form): return super().form_valid(form) -def close_survey_duplicates(request, pk): - """Close duplicates for a given Survey PK with the request user as actor. +class SurveyCloseDuplicates(BreadcrumbContextMixin, UpdateView): + model = Survey + template_name = "observations/survey_form.html" + form_class = SurveyCloseDuplicatesForm + + def get_breadcrumbs(self, request, obj=None, add=False): + return ( + Breadcrumb("Home", reverse("home")), + Breadcrumb("Surveys", reverse("observations:survey-list")), + Breadcrumb(self.get_object().pk, self.get_object().get_absolute_url()), + Breadcrumb("Close duplicates", None), + ) + + def get_success_url(self): + return self.get_object().get_absolute_url() + + def post(self, request, *args, **kwargs): + # If the user clicked Cancel, redirect back to the survey detail. + if request.POST.get("cancel"): + return redirect(self.get_success_url()) + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + """Close duplicates for a given Survey PK with the request user as actor. + + All duplicate Surveys will be curated and marked as "not production". + The given Survey will be curated and marked as "production", + adopt all Encounters from all duplicate surveys, and adjust its duration. + + See Survey.close_duplicates() for implementation details. + """ + survey = self.get_object() + msg = survey.close_duplicates(actor=self.request.user) + messages.success(self.request, msg) + return HttpResponseRedirect(self.get_success_url()) + + +class SurveyMakeProduction(SurveyCloseDuplicates): + form_class = SurveyMakeProductionForm + + def form_valid(self, form): + survey = self.get_object() + msg = survey.make_production() + messages.success(self.request, msg) + return HttpResponseRedirect(self.get_success_url()) - All duplicate Surveys will be curated and marked as "not production". - The given Survey will be curated and marked as "production", - adopt all Encounters from all duplicate surveys, and adjust its duration. - See Survey.close_duplicates() for implementation details. +def survey_make_production(request, pk): + """Update a given Survey as 'production'. """ - s = Survey.objects.get(pk=pk) - msg = s.close_duplicates(actor=request.user) + survey = Survey.objects.get(pk=pk) + msg = survey.make_production() messages.success(request, msg) - return HttpResponseRedirect(s.get_absolute_url()) + return HttpResponseRedirect(survey.get_absolute_url()) class EncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): From 38897bfce41e7ea9def5ebce42fdf28afdc34847 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 1 May 2024 13:32:38 +0800 Subject: [PATCH 34/40] Refine Survey.close_duplicates method. --- observations/models.py | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/observations/models.py b/observations/models.py index 32c40b768..b0557db1f 100644 --- a/observations/models.py +++ b/observations/models.py @@ -619,19 +619,27 @@ def close_duplicates(self, actor=None): ) # All duplicate Surveys shall be closed (not production) and own no Encounters - for d in self.duplicate_surveys.all(): - LOGGER.info("Closing Survey {0} with actor {1}".format(d.pk, curator)) - d.production = False - d.save() - if d.status != QualityControlMixin.STATUS_CURATED: - d.curate(by=curator) - d.save() - for a in d.attachments.all(): - a.survey = self - a.save() - - # From all Encounters (if any), adjust duration - if all_encounters.count() > 0: + for duplicate in self.duplicate_surveys.all(): + LOGGER.info("Closing Survey {0} with actor {1}".format(duplicate.pk, curator)) + duplicate.production = False + duplicate.save() + if duplicate.status != QualityControlMixin.STATUS_CURATED: + duplicate.curate(by=curator) + duplicate.save() + # Merge any media attachments on the duplicate survey. + attachments = SurveyMediaAttachment.objects.filter(survey=duplicate) + for media in attachments: + media.survey = self + media.save() + + # From all Encounters (if any), adjust Survey duration + if all_encounters.exists(): + + # Merge any Encounters on the old survey. + for encounter in all_encounters: + encounter.survey = self + encounter.save() + earliest_enc = min([e.when for e in all_encounters]) earliest_buffered = earliest_enc - timedelta(minutes=30) latest_enc = max([e.when for e in all_encounters]) @@ -663,7 +671,7 @@ def close_duplicates(self, actor=None): ) self.end_time = latest_buffered - # This Survey is the production survey owning all Encounters + # This Survey is the production survey, owning all Encounters. self.production = True self.save() if self.status != QualityControlMixin.STATUS_CURATED: @@ -671,13 +679,13 @@ def close_duplicates(self, actor=None): self.save() # ...except cuckoo Encounters - if all_encounters.count() > 0 and self.site is not None: + if all_encounters.exists() and self.site is not None: cuckoo_encounters = all_encounters.exclude(where__coveredby=self.site.geom) for e in cuckoo_encounters: e.site = None e.survey = None e.save() - msg += " Evicted {0} cuckoo Encounters observed outside the site.".format( + msg += " Evicted {0} cuckoo Encounters observed outside the survey site.".format( cuckoo_encounters.count() ) From d76aa7164ca98bd4003d40793b3748690450b01c Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Wed, 1 May 2024 15:18:16 +0800 Subject: [PATCH 35/40] Set EncounterAdmin survey field to readonly to improve load performance. --- observations/admin.py | 6 +----- observations/models.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/observations/admin.py b/observations/admin.py index d8b353269..af50edf89 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -1134,7 +1134,6 @@ def queryset(self, request, queryset): QAStatusFilter, "observer", "reporter", - #"location_accuracy", "encounter_type", "source", ) @@ -1145,9 +1144,6 @@ def queryset(self, request, queryset): "encounter_type", "latitude", "longitude", - #"location_accuracy", - #"location_accuracy_m", - #"name_link", ) LAST_COLS = ( "observer", @@ -1173,7 +1169,7 @@ def queryset(self, request, queryset): # UserWidget excludes inactive users observer = forms.ChoiceField(widget=UserWidget()) reporter = forms.ChoiceField(widget=UserWidget()) - readonly_fields = ("name",) + readonly_fields = ("name", "survey") # Django-fsm transitions config fsm_field = ["status"] diff --git a/observations/models.py b/observations/models.py index b0557db1f..f65b679f7 100644 --- a/observations/models.py +++ b/observations/models.py @@ -521,7 +521,7 @@ class Meta: unique_together = ("source", "source_id") def __str__(self): - return self.label or str(self.pk) + return self.label_short() def make_label(self): return "Survey {} of {} on {} from {} to {}".format( From 3cd87c1858303c88fba957e5fb27c3ee80fd2f32 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 3 May 2024 10:42:51 +0800 Subject: [PATCH 36/40] Added EncounterUpdateSurvey to allow encounters without surveys to be updated. --- observations/forms.py | 49 +++++++++++++++++-- observations/models.py | 40 +++++++-------- .../observations/encounter_card.html | 2 +- .../observations/encounter_detail.html | 22 ++++++--- .../templates/observations/survey_form.html | 2 +- observations/urls.py | 5 +- observations/views.py | 42 +++++++++++----- 7 files changed, 113 insertions(+), 49 deletions(-) diff --git a/observations/forms.py b/observations/forms.py index 67f859939..9ae502dc3 100644 --- a/observations/forms.py +++ b/observations/forms.py @@ -1,8 +1,9 @@ from django import forms +from django.conf import settings from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Div, Fieldset, Field, Submit, HTML -from .models import Survey +from .models import Survey, Encounter class SurveyChoiceField(forms.ModelChoiceField): @@ -19,15 +20,15 @@ class SurveyMergeForm(forms.Form): save_button = Submit("save", "Merge duplicate", css_class="btn-lg") cancel_button = Submit("cancel", "Cancel", css_class="btn-secondary") - def __init__(self, survey, *args, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["survey_duplicates"].queryset = survey.duplicate_surveys - + instance = kwargs["instance"] + self.fields["survey_duplicates"].queryset = instance.duplicate_surveys self.helper = FormHelper() self.helper.layout = Layout( Div( Fieldset( - f"{survey.label_short()} - merge duplicate survey", + f"{instance.label_short()} - merge duplicate survey", HTML(f"
    Merge the selected survey below into this one and adopt any encounters from the duplicate.
    "), Field("survey_duplicates"), ), @@ -91,3 +92,41 @@ def __init__(self, *args, **kwargs): ), ), ) + + +class EncounterUpdateSurveyForm(forms.ModelForm): + survey = SurveyChoiceField( + label="Survey candidates", + queryset=Survey.objects.none(), + required=False, + ) + save_button = Submit("save", "Save", css_class="btn-lg") + cancel_button = Submit("cancel", "Cancel", css_class="btn-secondary") + + class Meta: + model = Encounter + exclude = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = kwargs["instance"] + self.fields["survey"].queryset = instance.get_survey_candidates() + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Fieldset( + f"Encounter {instance.pk} - update survey", + HTML(f"
    { instance } at { instance.when.astimezone(settings.TZ).strftime('%d-%b-%Y %H:%M %Z') }
    "), + Field("survey"), + ), + Div( + self.save_button, + self.cancel_button, + css_class='pb-2', + ), + ), + ) + + def is_valid(self): + # Bypass all form validation. + return True diff --git a/observations/models.py b/observations/models.py index f65b679f7..2a0f91e35 100644 --- a/observations/models.py +++ b/observations/models.py @@ -1246,7 +1246,7 @@ def card_template(self): def leaflet_title(self): """A string for Leaflet map marker titles. Cache me as field.""" return "{} {} {}".format( - self.when.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M:%S") if self.when else "", + self.when.astimezone(settings.TZ).strftime("%d-%b-%Y %H:%M") if self.when else "", self.get_encounter_type_display(), self.name or "", ).strip() @@ -1304,21 +1304,6 @@ def short_name(self): ) ) - @property - def date(self): - """Return the date component of Encounter.when.""" - return self.when.date() - - @property - def date_string(self): - """Return the date as string.""" - return str(self.when.date()) - - @property - def datetime(self): - """Return the full datetime of the Encounter.""" - return self.when - @property def season(self): """Return the season of the Encounter, the start year of the fiscal year. @@ -1482,6 +1467,25 @@ def as_html(self): t = loader.get_template("popup/{}.html".format(self._meta.model_name)) return mark_safe(t.render({"original": self})) + def get_survey_candidates(self): + """Return the queryset of surveys that this encounter might belong to. Rules: + - Production survey + - Same area (locality) + - Survey start time >= 8h before encounter.when + - Survey end time <= 8h after encounter.when + """ + if not self.area: + return Survey.objects.none() + + earliest = self.when - timedelta(hours=8) + latest = self.when + timedelta(hours=8) + return Survey.objects.filter( + production=True, + area=self.area, + start_time__gte=earliest, + end_time__lte=latest, + ) + class AnimalEncounter(Encounter): """The encounter of an animal of a species. @@ -1990,10 +1994,6 @@ def longitude(self): """The encounter's longitude.""" return self.encounter.where.x or "" - def datetime(self): - """The encounter's timestamp.""" - return self.encounter.when or "" - @property def absolute_admin_url(self): """Return the absolute admin change URL. diff --git a/observations/templates/observations/encounter_card.html b/observations/templates/observations/encounter_card.html index 5ee1a3025..cc52b4844 100644 --- a/observations/templates/observations/encounter_card.html +++ b/observations/templates/observations/encounter_card.html @@ -19,7 +19,7 @@ diff --git a/observations/templates/observations/encounter_detail.html b/observations/templates/observations/encounter_detail.html index a48d3427a..eb50e4f37 100644 --- a/observations/templates/observations/encounter_detail.html +++ b/observations/templates/observations/encounter_detail.html @@ -95,19 +95,27 @@

    {% if object.survey %} {{ object.survey.label_short }} + {% else %} + {% if object.get_survey_candidates.exists %} + {{ object.get_survey_candidates.count }} candidate(s) + {% endif %} {% endif %} + + + Locality + + + {{ object.area.name|default_if_none:"" }} + + Site - {% if object.survey %} - {{ object.survey.site.name }} - {% else %} - {{ object.guess_site.name }} - {% endif %} + {{ object.site.name|default_if_none:"" }} @@ -115,9 +123,7 @@

    Comments - {% if object.comments %} - {{ object.comments }} - {% endif %} + {{ object.comments|default_if_none:"" }} {% block extra_encounter_details %}{% endblock %} diff --git a/observations/templates/observations/survey_form.html b/observations/templates/observations/survey_form.html index 925937ccc..4f0d34a3d 100644 --- a/observations/templates/observations/survey_form.html +++ b/observations/templates/observations/survey_form.html @@ -2,7 +2,7 @@ {% load static crispy_forms_tags bootstrap4 %} {% block breadcrumbs %} -{% include 'breadcrumbs.html' %} +{% include "breadcrumbs.html" %} {% endblock breadcrumbs %} {% block page_content_inner %} diff --git a/observations/urls.py b/observations/urls.py index c90e220e3..a2fadc925 100644 --- a/observations/urls.py +++ b/observations/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path("encounters/", views.EncounterList.as_view(), name="encounter-list"), path("encounters//", views.EncounterDetail.as_view(), name="encounter-detail"), + path("encounters//update-survey/", views.EncounterUpdateSurvey.as_view(), name="encounter-update-survey"), path("animal-encounters/", views.AnimalEncounterList.as_view(), name="animalencounter-list"), path("animal-encounters//", views.AnimalEncounterDetail.as_view(), name="animalencounter-detail"), path("animal-encounters//curate/", views.AnimalEncounterCurate.as_view(), name="animalencounter-curate"), @@ -14,8 +15,8 @@ path("surveys/", views.SurveyList.as_view(), name="survey-list"), path("surveys//", views.SurveyDetail.as_view(), name="survey-detail"), path("surveys//merge/", views.SurveyMerge.as_view(), name="survey-merge"), - path("surveys//close_duplicates/", views.SurveyCloseDuplicates.as_view(), name="survey-close-duplicates"), - path("surveys//make_production", views.SurveyMakeProduction.as_view(), name="survey-make-production"), + path("surveys//close-duplicates/", views.SurveyCloseDuplicates.as_view(), name="survey-close-duplicates"), + path("surveys//make-production", views.SurveyMakeProduction.as_view(), name="survey-make-production"), path("turtle-nest-encounters/", views.TurtleNestEncounterList.as_view(), name="turtlenestencounter-list"), path("turtle-nest-encounters//", views.TurtleNestEncounterDetail.as_view(), name="turtlenestencounter-detail"), path("turtle-nest-encounters//curate/", views.TurtleNestEncounterCurate.as_view(), name="turtlenestencounter-curate"), diff --git a/observations/views.py b/observations/views.py index faae7c75f..2bbaeb964 100644 --- a/observations/views.py +++ b/observations/views.py @@ -43,6 +43,7 @@ SurveyMergeForm, SurveyCloseDuplicatesForm, SurveyMakeProductionForm, + EncounterUpdateSurveyForm, ) from .models import ( Survey, @@ -114,11 +115,6 @@ class SurveyMerge(BreadcrumbContextMixin, FormView): def get_object(self): return Survey.objects.get(pk=self.kwargs["pk"]) - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["survey"] = self.get_object() - return kwargs - def get_breadcrumbs(self, request, obj=None, add=False): return ( Breadcrumb("Home", reverse("home")), @@ -209,13 +205,35 @@ def form_valid(self, form): return HttpResponseRedirect(self.get_success_url()) -def survey_make_production(request, pk): - """Update a given Survey as 'production'. - """ - survey = Survey.objects.get(pk=pk) - msg = survey.make_production() - messages.success(request, msg) - return HttpResponseRedirect(survey.get_absolute_url()) +class EncounterUpdateSurvey(BreadcrumbContextMixin, UpdateView): + model = Encounter + template_name = "observations/encounter_form.html" + form_class = EncounterUpdateSurveyForm + + def get_breadcrumbs(self, request, obj=None, add=False): + return ( + Breadcrumb("Home", reverse("home")), + Breadcrumb("Encounters", reverse("observations:encounter-list")), + Breadcrumb(self.get_object().pk, self.get_object().get_absolute_url()), + Breadcrumb("Update survey", None), + ) + + def get_success_url(self): + return self.get_object().get_absolute_url() + + def post(self, request, *args, **kwargs): + # If the user clicked Cancel, redirect back to the object detail view. + if request.POST.get("cancel"): + return redirect(self.get_success_url()) + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + encounter = self.get_object() + # Note that we don't have form.cleaned_data, because we bypass validation in this form. + if "survey" in form.data and form.data["survey"]: + encounter.survey = Survey.objects.get(pk=form.data["survey"]) + encounter.save() + return HttpResponseRedirect(self.get_success_url()) class EncounterList(ListViewBreadcrumbMixin, ResourceDownloadMixin, PaginateMixin, ListView): From cdd5a6455ce853b7b6a6e27000ffda7772aeecab Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 3 May 2024 11:22:17 +0800 Subject: [PATCH 37/40] Add missed form template. --- .../templates/observations/encounter_form.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 observations/templates/observations/encounter_form.html diff --git a/observations/templates/observations/encounter_form.html b/observations/templates/observations/encounter_form.html new file mode 100644 index 000000000..4f0d34a3d --- /dev/null +++ b/observations/templates/observations/encounter_form.html @@ -0,0 +1,17 @@ +{% extends "base_wastd.html" %} +{% load static crispy_forms_tags bootstrap4 %} + +{% block breadcrumbs %} +{% include "breadcrumbs.html" %} +{% endblock breadcrumbs %} + +{% block page_content_inner %} +{% if form.errors %} + +{% endif %} +
    +
    + {% crispy form %} +
    +
    +{% endblock page_content_inner %} From ddc597ffa94a4af1bdf2adebd49fe402a9149738 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 3 May 2024 14:39:32 +0800 Subject: [PATCH 38/40] Update admin permissions: disallow deletion by non-superusers. --- observations/admin.py | 108 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/observations/admin.py b/observations/admin.py index af50edf89..1fc80b580 100644 --- a/observations/admin.py +++ b/observations/admin.py @@ -248,6 +248,22 @@ class ObservationAdminMixin(VersionAdmin, ModelAdmin): ) formfield_overrides = FORMFIELD_OVERRIDES + def has_change_permission(self, request, obj=None): + """Basic authorisation model: only staff can update/change these objects. + """ + if request.user.is_staff: + return True + else: + return False + + def has_delete_permission(self, request, obj=None): + """Basic authorisation model: only superusers can delete these objects. + """ + if request.user.is_superuser: + return True + else: + return False + def area(self, obj): return obj.encounter.area @@ -1013,6 +1029,22 @@ class SurveyAdmin(ExportActionMixin, FSMTransitionMixin, VersionAdmin): CustomStateLogInline, ] + def has_change_permission(self, request, obj=None): + """Basic authorisation model: only staff can update/change these objects. + """ + if request.user.is_staff: + return True + else: + return False + + def has_delete_permission(self, request, obj=None): + """Basic authorisation model: only superusers can delete these objects. + """ + if request.user.is_superuser: + return True + else: + return False + def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == "area": kwargs["queryset"] = Area.objects.filter(area_type=Area.AREATYPE_LOCALITY) @@ -1036,8 +1068,8 @@ def owner(self, obj): class AreaAdmin(ModelAdmin): list_display = ( - "area_type", "name", + "area_type", "w2_location_code", "w2_place_code", "northern_extent", @@ -1052,6 +1084,30 @@ class AreaAdmin(ModelAdmin): form = s2form(Area, attrs=S2ATTRS) formfield_overrides = FORMFIELD_OVERRIDES + def has_add_permission(self, request): + """Basic authorisation model: only superusers can create these objects. + """ + if request.user.is_superuser: + return True + else: + return False + + def has_change_permission(self, request, obj=None): + """Basic authorisation model: only superusers can update/change these objects. + """ + if request.user.is_superuser: + return True + else: + return False + + def has_delete_permission(self, request, obj=None): + """Basic authorisation model: only superusers can delete these objects. + """ + if request.user.is_superuser: + return True + else: + return False + @register(Campaign) class CampaignAdmin(ModelAdmin): @@ -1102,6 +1158,30 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): kwargs["queryset"] = Area.objects.filter(area_type=Area.AREATYPE_LOCALITY) return super().formfield_for_foreignkey(db_field, request, **kwargs) + def has_add_permission(self, request): + """Basic authorisation model: only superusers can create these objects. + """ + if request.user.is_superuser: + return True + else: + return False + + def has_change_permission(self, request, obj=None): + """Basic authorisation model: only superusers can update/change these objects. + """ + if request.user.is_superuser: + return True + else: + return False + + def has_delete_permission(self, request, obj=None): + """Basic authorisation model: only superusers can delete these objects. + """ + if request.user.is_superuser: + return True + else: + return False + @register(Encounter) class EncounterAdmin(FSMTransitionMixin, VersionAdmin): @@ -1161,11 +1241,11 @@ def queryset(self, request, queryset): "reporter__username", "source_id", ) - list_select_related = ("area", "site", "survey", "observer", "reporter", "campaign") + list_select_related = ("area", "site", "observer", "reporter", "campaign") form = s2form(Encounter, attrs=S2ATTRS) formfield_overrides = FORMFIELD_OVERRIDES - autocomplete_fields = ["area", "site", "survey", "campaign"] + autocomplete_fields = ["area", "site", "campaign"] # UserWidget excludes inactive users observer = forms.ChoiceField(widget=UserWidget()) reporter = forms.ChoiceField(widget=UserWidget()) @@ -1220,12 +1300,28 @@ def queryset(self, request, queryset): CustomStateLogInline, ] + def has_change_permission(self, request, obj=None): + """Basic authorisation model: only staff can update/change these objects. + """ + if request.user.is_staff: + return True + else: + return False + + def has_delete_permission(self, request, obj=None): + """Basic authorisation model: only superusers can delete these objects. + """ + if request.user.is_superuser: + return True + else: + return False + def get_queryset(self, request): return ( super() .get_queryset(request) .prefetch_related( - "observer", "reporter", "area", "site", "survey", "campaign" + "observer", "reporter", "area", "site", "campaign" ) ) @@ -1359,7 +1455,6 @@ class AnimalEncounterAdmin(ExportActionMixin, EncounterAdmin): list_select_related = ( "area", "site", - "survey", "observer", "reporter", "site_of_first_sighting", @@ -1397,6 +1492,7 @@ class AnimalEncounterAdmin(ExportActionMixin, EncounterAdmin): "datetime_of_last_sighting", "site_of_first_sighting", "site_of_last_sighting", + "survey", ) fieldsets = EncounterAdmin.fieldsets + ( ( @@ -1456,7 +1552,6 @@ def get_queryset(self, request): "reporter", "area", "site", - "survey", "campaign", "site_of_first_sighting", "site_of_last_sighting", @@ -1620,7 +1715,6 @@ class LineTransectEncounterAdmin(ExportActionMixin, EncounterAdmin): list_select_related = ( "area", "site", - "survey", ) fieldsets = EncounterAdmin.fieldsets + ( ( From 2d7eb0706044461aa889447d0d6cedf267ad1cd4 Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 3 May 2024 14:39:52 +0800 Subject: [PATCH 39/40] Addes DisturbanceObservation to curation dashboard. --- wastd/dashboard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wastd/dashboard.py b/wastd/dashboard.py index 8b23f375c..5653f0360 100644 --- a/wastd/dashboard.py +++ b/wastd/dashboard.py @@ -24,6 +24,7 @@ def init_with_context(self, context): "observations.models.TurtleNestEncounter", "observations.models.TurtleNestDisturbanceObservation", "observations.models.TurtleTrackObservation", + "observations.models.DisturbanceObservation", ), ), ], From 2803d3b5ddb4fc188b653bb217ed3417fb8a244a Mon Sep 17 00:00:00 2001 From: Ashley Felton Date: Fri, 3 May 2024 15:05:08 +0800 Subject: [PATCH 40/40] Added DisturbanceObservation to API endpoints. --- observations/api.py | 12 ++++++++++++ observations/serializers.py | 18 ++++++++++++++++++ wastd/router.py | 4 ++++ 3 files changed, 34 insertions(+) diff --git a/observations/api.py b/observations/api.py index 1a7cf4926..d3118fd66 100644 --- a/observations/api.py +++ b/observations/api.py @@ -16,6 +16,7 @@ HatchlingMorphometricObservation, TurtleHatchlingEmergenceOutlierObservation, LightSourceObservation, + DisturbanceObservation, ) from .serializers import ( AreaSerializer, @@ -33,6 +34,7 @@ HatchlingMorphometricObservationSerializer, TurtleHatchlingEmergenceOutlierObservationSerializer, LightSourceObservationSerializer, + DisturbanceObservationSerializer, ) @@ -224,3 +226,13 @@ class LightSourceObservationListResource(ObservationListResource): class LightSourceObservationDetailResource(DetailResourceView): model = LightSourceObservation serializer = LightSourceObservationSerializer + + +class DisturbanceObservationListResource(ObservationListResource): + model = DisturbanceObservation + serializer = DisturbanceObservationSerializer + + +class DisturbanceObservationDetailResource(DetailResourceView): + model = DisturbanceObservation + serializer = DisturbanceObservationSerializer diff --git a/observations/serializers.py b/observations/serializers.py index 2477222d2..96ed5a662 100644 --- a/observations/serializers.py +++ b/observations/serializers.py @@ -390,3 +390,21 @@ def light_source_observation_serializer(obj) -> Dict[str, Any]: class LightSourceObservationSerializer(object): def serialize(obj): return light_source_observation_serializer(obj) + + +def disturbance_observation_serializer(obj) -> Dict[str, Any]: + d = { + 'disturbance_cause': obj.get_disturbance_cause_display(), + 'disturbance_cause_confidence': obj.get_disturbance_cause_confidence_display(), + 'comments': obj.comments, + } + obj = observation_serializer(obj) + # Extend the serialised object. + obj['properties'].update(d) + + return obj + + +class DisturbanceObservationSerializer(object): + def serialize(obj): + return disturbance_observation_serializer(obj) diff --git a/wastd/router.py b/wastd/router.py index 8f487842b..846c1cde1 100644 --- a/wastd/router.py +++ b/wastd/router.py @@ -31,6 +31,8 @@ TurtleHatchlingEmergenceOutlierObservationDetailResource, LightSourceObservationListResource, LightSourceObservationDetailResource, + DisturbanceObservationListResource, + DisturbanceObservationDetailResource, ) # from turtle_tags.api import ( # TurtleListResource, @@ -78,6 +80,8 @@ path("turtle-hatchling-emergence-outlier-observations//", TurtleHatchlingEmergenceOutlierObservationDetailResource.as_view(), name="turtle_hatchling_emergence_outlier_observation_detail_resource"), path("light-source-observations/", LightSourceObservationListResource.as_view(), name="light_source_list_resource"), path("light-source-observations//", LightSourceObservationDetailResource.as_view(), name="light_source_detail_resource"), + path("disturbance-observations/", DisturbanceObservationListResource.as_view(), name="disturbance_list_resource"), + path("disturbance-observations//", DisturbanceObservationDetailResource.as_view(), name="disturbance_detail_resource"), # turtle_tags # path("turtles/", TurtleListResource.as_view(), name="turtle_list_resource"), # path("turtles//", TurtleDetailResource.as_view(), name="turtle_detail_resource"),