diff --git a/.circleci/config.yml b/.circleci/config.yml index f3571f38a..d025271e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,7 +65,7 @@ jobs: name: Run Tests command: | pip install --upgrade tox - tox -e d22,report + tox -e d32,report - save_cache: key: deps2-{{ .Branch }}--{{ checksum "Pipfile.lock" }}-{{ checksum ".circleci/config.yml" }} paths: diff --git a/.circleci/images/primary/Dockerfile b/.circleci/images/primary/Dockerfile deleted file mode 100644 index f0baff997..000000000 --- a/.circleci/images/primary/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -FROM python:3.7.5-alpine - -RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories - -RUN apk update -RUN apk add --upgrade apk-tools - -RUN apk add \ - --update alpine-sdk - -RUN apk add openssl \ - ca-certificates \ - libressl2.7-libcrypto -RUN apk add \ - libxml2-dev \ - libxslt-dev \ - xmlsec-dev -RUN apk add postgresql-dev \ - libffi-dev\ - jpeg-dev \ - python-dev - -RUN apk add --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community/ \ - gdal \ - gdal-dev \ - geos \ - geos-dev - -RUN apk add --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \ - gcc \ - g++ - -RUN apk add bash - -RUN pip install --upgrade \ - setuptools \ - pip \ - wheel \ - pipenv \ - tox - diff --git a/Dockerfile-base b/Dockerfile-base index 65a5ed517..35be44fbb 100644 --- a/Dockerfile-base +++ b/Dockerfile-base @@ -1,10 +1,13 @@ -FROM python:3.7-alpine3.11 +FROM python:3.9.6-alpine3.14 RUN apk update + +RUN apk add \ + --update alpine-sdk + RUN apk add --upgrade apk-tools \ openssl \ ca-certificates \ - libressl3.0-libcrypto \ libmagic \ libxslt \ geos \ @@ -21,14 +24,11 @@ RUN apk add --no-cache --virtual .build-deps --update \ postgresql-dev \ libffi-dev \ jpeg-dev \ - python-dev \ geos-dev \ gdal-dev \ gcc \ g++ - -# PYTHON RUN pip install --no-cache-dir --upgrade \ setuptools \ pip \ diff --git a/Pipfile b/Pipfile index 259dbeb79..b9f4c2a06 100644 --- a/Pipfile +++ b/Pipfile @@ -22,66 +22,68 @@ azure-common = "==1.1.27" azure-storage-blob = "==2.1.0" azure-storage-common = "==2.1.0" carto = "==1.11.2" -celery = "==5.0.5" +celery = "==5.1.2" cryptography = "<3.4" dj-database-url = "==0.5" dj-static = "==0.0.6" -Django = "<3.0" +Django = "==3.2.6" +django-admin-extra-urls = "*" django-appconf = "==1.0.4" -django_celery_beat = "==2.1" +django-celery-beat = "==2.2.1" django-celery-email = "==3.0.0" -django_celery_results = "==2.0.0" +django-celery-results = "==2.2" django-contrib-comments = "==2.1.0" -django-cors-headers = "==3.6.0" -django-debug-toolbar = "==3.2" -django-extensions = "==3.1.2" -django-easy-pdf = "==0.1.1" # not maintained dj<3.0 +django-cors-headers = "==3.7.0" +django-debug-toolbar = "==3.2.1" +django-extensions = "==3.1.3" +django-easy-pdf3 = "==0.1.2" django-filter = "==2.4.0" django-fsm = "==2.7.1" django-import-export = "==2.5.0" django-js-asset = "==1.2.2" -django-leaflet = "==0.27.1" +django-leaflet = "==0.28.1" django-logentry-admin = "==1.0.6" django-model-utils = "==4.1.1" -django-ordered-model = "==3.4.1" -django-post_office = "==3.5.3" +django-ordered-model = "==3.4.3" +django-post-office = "==3.5.3" django-redis-cache = "==3.0" django-rest-swagger = "==2.2" django-storages = {extras = ["azure"],version = "==1.11.1"} -django-tenants = "==3.2.1" -django-timezone-field = "==4.1.2" -django-waffle = "==2.0.0" -djangorestframework-csv = "==2.1.0" -djangorestframework-gis = "==0.16" -djangorestframework-jwt = "==1.11.0" +django-tenants = "==3.3.2" +django-timezone-field = "==4.2.1" +django-waffle = "==2.1.0" +djangorestframework-csv = "==2.1.1" +djangorestframework-gis = "==0.17" +djangorestframework-simplejwt = "==4.8" djangorestframework-recursive = "==0.1.2" djangorestframework-xml = "==2.0.0" djangorestframework = "==3.12.4" drf-nested-routers = "==0.93.3" drf-querystringfilter = "==1.0.0" -etools-validator = "==0.3.4" +etools-validator = "==0.5.0" flower = "==0.9.5" # issue when locking GDAL = "==3.0.2" gunicorn = "<20.0" newrelic = "*" Pillow = "==8.1.0" -psycopg2-binary = "==2.8.6" +psycopg2-binary = "==2.9.1" sentry-sdk = "*" -requests = "==2.25.1" +requests = "==2.26" social-auth-app-django = "==4.0" -social-auth-core = {extras = ["azuread"],version = "==3.3.3"} +social-auth-core = {extras = ["azuread"],version = "==4.1"} tenant-schemas-celery = "==1.0.1" -unicef_attachments = "==0.9.0" +unicef-attachments = "==0.9.0" unicef-djangolib = "==0.5.4" -unicef-locations = "==1.9" -unicef_notification = "==0.2.1" -unicef_restlib = "==0.7" -unicef_snapshot = "==0.2.3" +unicef-locations = "==3.0" +unicef-notification = "==0.2.1" +unicef-restlib = "==0.7" +unicef-snapshot = "==1.1" unicef-rest-export = "==0.5.3" xhtml2pdf = "==0.2.5" unicef-vision = "==0.6" etools-offline = "==0.1.0" openpyxl = "==3.0.5" +pyyaml = "==5.4.1" [requires] -python_version = "3.7" +python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 4c3fe25f1..3036487e9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "7962955ee5bddfc6159926a22b106d68394c4af23e42cfa599a8b62225c99ba4" + "sha256": "140fe10132dac637606aa5477edc248669efc7887df0892406cc8582c12065ac" }, "pipfile-spec": 6, "requires": { - "python_version": "3.7" + "python_version": "3.9" }, "sources": [ { @@ -31,6 +31,13 @@ ], "version": "==2.1.3" }, + "asgiref": { + "hashes": [ + "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", + "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" + ], + "version": "==3.4.1" + }, "azure-common": { "hashes": [ "sha256:426673962740dbe9aab052a4b52df39c07767decd3f25fdc87c9d4c566a04934", @@ -79,67 +86,76 @@ }, "celery": { "hashes": [ - "sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13", - "sha256:f4efebe6f8629b0da2b8e529424de376494f5b7a743c321c8a2ddc2b1414921c" + "sha256:8d9a3de9162965e97f8e8cc584c67aad83b3f7a267584fa47701ed11c3e0d4b0", + "sha256:9dab2170b4038f7bf10ef2861dbf486ddf1d20592290a1040f7b7a1259705d42" ], "index": "pypi", - "version": "==5.0.5" + "version": "==5.1.2" }, "certifi": { "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], - "version": "==2020.12.5" + "version": "==2021.5.30" }, "cffi": { "hashes": [ - "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", - "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", - "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", - "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", - "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", - "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", - "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", - "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", - "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", - "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", - "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", - "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", - "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", - "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", - "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", - "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", - "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", - "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", - "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", - "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", - "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", - "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", - "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", - "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", - "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", - "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", - "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", - "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", - "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", - "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", - "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", - "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", - "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", - "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", - "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", - "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", - "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" - ], - "version": "==1.14.5" - }, - "chardet": { - "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" - ], - "version": "==4.0.0" + "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d", + "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771", + "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872", + "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c", + "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc", + "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762", + "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202", + "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5", + "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548", + "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a", + "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f", + "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20", + "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218", + "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c", + "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e", + "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56", + "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224", + "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a", + "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2", + "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a", + "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819", + "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346", + "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b", + "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e", + "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534", + "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb", + "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0", + "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156", + "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd", + "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87", + "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc", + "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195", + "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33", + "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f", + "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d", + "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd", + "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728", + "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7", + "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca", + "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99", + "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf", + "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e", + "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c", + "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5", + "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69" + ], + "version": "==1.14.6" + }, + "charset-normalizer": { + "hashes": [ + "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b", + "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3" + ], + "markers": "python_version >= '3'", + "version": "==2.0.4" }, "click": { "hashes": [ @@ -163,10 +179,10 @@ }, "click-repl": { "hashes": [ - "sha256:9c4c3d022789cae912aad8a3f5e1d7c2cdd016ee1225b5212ad3e8691563cda5", - "sha256:b9f29d52abc4d6059f8e276132a111ab8d94980afe6a5432b9d996544afa95d5" + "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", + "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8" ], - "version": "==0.1.6" + "version": "==0.2.0" }, "coreapi": { "hashes": [ @@ -207,7 +223,6 @@ "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" ], - "markers": "python_version >= '3.0'", "version": "==0.7.1" }, "diff-match-patch": { @@ -234,11 +249,18 @@ }, "django": { "hashes": [ - "sha256:2484f115891ab1a0e9ae153602a641fbc15d7894c036d79fb78662c0965d7954", - "sha256:2569f9dc5f8e458a5e988b03d6b7a02bda59b006d6782f4ea0fd590ed7336a64" + "sha256:7f92413529aa0e291f3be78ab19be31aefb1e1c9a52cd59e130f505f27a51f13", + "sha256:f27f8544c9d4c383bbe007c57e3235918e258364577373d4920e9162837be022" ], "index": "pypi", - "version": "==2.2.20" + "version": "==3.2.6" + }, + "django-admin-extra-urls": { + "hashes": [ + "sha256:fc68efd40569f2301cb329a0c445dd3517b0aa9c03c1ab7d9fc8a006d22de9b8" + ], + "index": "pypi", + "version": "==3.5.1" }, "django-appconf": { "hashes": [ @@ -256,11 +278,11 @@ }, "django-celery-beat": { "hashes": [ - "sha256:4eb0e8412e2e05ba0029912a6f80d1054731001eecbcb4d59688c4e07cf4d9d3", - "sha256:8a169e11d96faed8b72d505ddbc70e7fe0b16cdc854df43cb209c153ed08d651" + "sha256:97ae5eb309541551bdb07bf60cc57cadacf42a74287560ced2d2c06298620234", + "sha256:ab43049634fd18dc037927d7c2c7d5f67f95283a20ebbda55f42f8606412e66c" ], "index": "pypi", - "version": "==2.1" + "version": "==2.2.1" }, "django-celery-email": { "hashes": [ @@ -272,11 +294,11 @@ }, "django-celery-results": { "hashes": [ - "sha256:754e01f22f70fddee5f2ca95c18f183fccee42ad98f9803577bffa717d45ac5d", - "sha256:f82280a9a25c44048b9e64ae4d47ade7d522c8221304b0e25388080021b95468" + "sha256:cc0285090a306f97f1d4b7929ed98af0475bf6db2568976b3387de4fbe812edc", + "sha256:d5f83fad9091e52cd6dbb3ca80632153ad14b6cdac4d73258e040f92717237cb" ], "index": "pypi", - "version": "==2.0.0" + "version": "==2.2" }, "django-contrib-comments": { "hashes": [ @@ -288,34 +310,35 @@ }, "django-cors-headers": { "hashes": [ - "sha256:5665fc1b1aabf1b678885cf6f8f8bd7da36ef0a978375e767d491b48d3055d8f", - "sha256:ba898dd478cd4be3a38ebc3d8729fa4d044679f8c91b2684edee41129d7e968a" + "sha256:1ac2b1213de75a251e2ba04448da15f99bcfcbe164288ae6b5ff929dc49b372f", + "sha256:96069c4aaacace786a34ee7894ff680780ec2644e4268b31181044410fecd12e" ], "index": "pypi", - "version": "==3.6.0" + "version": "==3.7.0" }, "django-debug-toolbar": { "hashes": [ - "sha256:84e2607d900dbd571df0a2acf380b47c088efb787dce9805aefeb407341961d2", - "sha256:9e5a25d0c965f7e686f6a8ba23613ca9ca30184daa26487706d4829f5cfb697a" + "sha256:a5ff2a54f24bf88286f9872836081078f4baa843dc3735ee88524e89f8821e33", + "sha256:e759e63e3fe2d3110e0e519639c166816368701eab4a47fed75d7de7018467b9" ], "index": "pypi", - "version": "==3.2" + "version": "==3.2.1" }, - "django-easy-pdf": { + "django-easy-pdf3": { "hashes": [ - "sha256:f7cb58e896791d28718219c54d2c8930e442fa1327817037e1c480bead77cddb" + "sha256:37840d01894f66c037f687e576eaf6d838f959e3a057bd32ddee41355e187dbc", + "sha256:8bd3680e3df14e2a3409b4011ebc830a74ba0fa3cb3412b838e26bffa1bf48db" ], "index": "pypi", - "version": "==0.1.1" + "version": "==0.1.2" }, "django-extensions": { "hashes": [ - "sha256:081828e985485662f62a22340c1506e37989d14b927652079a5b7cd84a82368b", - "sha256:17f85f4dcdd5eea09b8c4f0bad8f0370bf2db6d03e61b431fa7103fee29888de" + "sha256:50de8977794a66a91575dd40f87d5053608f679561731845edbd325ceeb387e3", + "sha256:5f0fea7bf131ca303090352577a9e7f8bfbf5489bd9d9c8aea9401db28db34a0" ], "index": "pypi", - "version": "==3.1.2" + "version": "==3.1.3" }, "django-filter": { "hashes": [ @@ -351,11 +374,11 @@ }, "django-leaflet": { "hashes": [ - "sha256:3cf1bc8a0e41d22651541ad5b5e2a8f2c6a13a603728883cd332daf785b37250", - "sha256:b530960242fe7718572b23976dd6037823dd478335c22c4434931335acf88bdd" + "sha256:02c6b8d7b941cb6ee0b944f33afb668b54495a6e24b57c8ba15daa779df26455", + "sha256:0fff2322c0cc1d008e3a18f067df736a937566a1ee1a6ce2e8ad298c4c713bb8" ], "index": "pypi", - "version": "==0.27.1" + "version": "==0.28.1" }, "django-logentry-admin": { "hashes": [ @@ -375,18 +398,18 @@ }, "django-mptt": { "hashes": [ - "sha256:63b421a054bceb7406582e2be876a80b3848a5106765baea1003696348ffd628", - "sha256:8ae6c3821127b529bb2f938de27bf0771b1bcbe9dbccdfba33986af78611f13a" + "sha256:34ee8fc9462c397bb20bca5e2eeffea8c7b09bfc2302d15e7bb5e62f01f670e8", + "sha256:7db354d2b305d039c21722a13969b4023ebff1e834db2c323cd87e6b274a95b3" ], - "version": "==0.12.0" + "version": "==0.13.2" }, "django-ordered-model": { "hashes": [ - "sha256:29af6624cf3505daaf0df00e2df1d0726dd777b95e08f304d5ad0264092aa934", - "sha256:d867166ed4dd12501139e119cbbc5b4d19798a3e72740aef0af4879ba97102cf" + "sha256:3a8a0259bfd73a0c0b124932bb1fe59a6d2f4cbea096b20634d2a2d1f5d585cc", + "sha256:5aa58277b81b4ca93fb18caf15069af604bac5c5146d2c29aae56da07a86ef1b" ], "index": "pypi", - "version": "==3.4.1" + "version": "==3.4.3" }, "django-post-office": { "hashes": [ @@ -423,26 +446,26 @@ }, "django-tenants": { "hashes": [ - "sha256:ea1f875f62257c79bc82979a6659037bdb89bfc7e001ade0c107203971b2aa66" + "sha256:e3897662bb88007216ef6e3dde4467db394d4f1a4430b10aebc3ec9e58e8e20f" ], "index": "pypi", - "version": "==3.2.1" + "version": "==3.3.2" }, "django-timezone-field": { "hashes": [ - "sha256:897c06e40b619cf5731a30d6c156886a7c64cba3a90364832148da7ef32ccf36", - "sha256:cffac62452d060e365938aa9c9f7b72d70d8b26b9c60243bce227b35abd1b9df" + "sha256:6dc782e31036a58da35b553bd00c70f112d794700025270d8a6a4c1d2e5b26c6", + "sha256:97780cde658daa5094ae515bb55ca97c1352928ab554041207ad515dee3fe971" ], "index": "pypi", - "version": "==4.1.2" + "version": "==4.2.1" }, "django-waffle": { "hashes": [ - "sha256:1973801b2bfdebb2d4ac3a7e5871a51f4d4b91ef712bdf46cc65d89b9dc3c988", - "sha256:e079cb530d3c8d62e204cfc08c7494a71cb08626630c09cd00e844bed1df0044" + "sha256:a39f294f8c4e026b9e441d0e4d35ff3b26d1eb2da1d52196ba4bc04ba78b1db1", + "sha256:a96b52ed6256238b7e0dda8c5c18b6f00e38008fae380f9ff22e12fd1a702c7f" ], "index": "pypi", - "version": "==2.0.0" + "version": "==2.1.0" }, "djangorestframework": { "hashes": [ @@ -454,26 +477,18 @@ }, "djangorestframework-csv": { "hashes": [ - "sha256:2f008b20a44f2d3c37835ea5b5ddfe19f54394f07b9cb267c616a917a7f7e27c" + "sha256:aa0ee4c894fe319c68e042b05c61dace43a9fb6e6872e1abe1724ca7ea4d15f7" ], "index": "pypi", - "version": "==2.1.0" + "version": "==2.1.1" }, "djangorestframework-gis": { "hashes": [ - "sha256:19a873740bcdac5c963779a1755d0d4acf96c4f5b63915d452f4b866d50f77d7", - "sha256:1a19c9e103b3c34ed5db182cdfbc541905c5560fc4a80f86c8b976c79bdf1e6d" - ], - "index": "pypi", - "version": "==0.16" - }, - "djangorestframework-jwt": { - "hashes": [ - "sha256:5efe33032f3a4518a300dc51a51c92145ad95fb6f4b272e5aa24701db67936a7", - "sha256:ab15dfbbe535eede8e2e53adaf52ef0cf018ee27dbfad10cbc4cbec2ab63d38c" + "sha256:5be17e27cf2be33ac6119e6d2206d3d81183c62d03aaa9b814ce95a82256614f", + "sha256:d3b6aa6beef32f75811f815bda3bc7e9ea5d2f0306f9741cad5f3820d3973be7" ], "index": "pypi", - "version": "==1.11.0" + "version": "==0.17" }, "djangorestframework-recursive": { "hashes": [ @@ -483,6 +498,14 @@ "index": "pypi", "version": "==0.1.2" }, + "djangorestframework-simplejwt": { + "hashes": [ + "sha256:153c973c5c154baf566be431de8527c2bd62557fde7373ebcb0f02b73b28e07a", + "sha256:6f09f97cb015265e85d1d02dc6bfc299c72c231eecbe261c5bee5c6b2867f2b4" + ], + "index": "pypi", + "version": "==4.8" + }, "djangorestframework-xml": { "hashes": [ "sha256:35f6c811d0ab8c8466b26db234e16a2ed32d76381715257aebf4c7be2c202ca1", @@ -508,9 +531,10 @@ }, "et-xmlfile": { "hashes": [ - "sha256:614d9722d572f6246302c4491846d2c393c199cfa4edc9af593437691683335b" + "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", + "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" ], - "version": "==1.0.1" + "version": "==1.1.0" }, "etools-offline": { "hashes": [ @@ -522,11 +546,11 @@ }, "etools-validator": { "hashes": [ - "sha256:4ea79fb86d5ccc46866800bde72aaa52aeb9411916d7168fb50982b70b7763cd", - "sha256:f51e3d27ce87c8924ba42078cda34a54c920e4161eda34b9c3fea04dc693107b" + "sha256:34f1e4179464ffa6e580a190750cf784bad42d8cbbb271410817b488dd26e2e1", + "sha256:5363a245b638454b21c6fe7aa9dd086e69f48970bc9e2de67e5ae40c3ecc0105" ], "index": "pypi", - "version": "==0.3.4" + "version": "==0.5.0" }, "flower": { "hashes": [ @@ -566,25 +590,18 @@ }, "humanize": { "hashes": [ - "sha256:6e04cdd75d66074c34ff93c30a2ad6d19d91202a65c1bd400b2edeedae399bda", - "sha256:c2ccaea7f8cbcd883ec420279d6e71ad20371bb36dbf5100b178d9756563289e" + "sha256:06c79af7873473c47477840010ccb9b11b0d431f37f4a81b71edd653211936be", + "sha256:4160cdc63fcd0daac27d2e1e218a31bb396fc3fe5712d153675d89432a03778f" ], - "version": "==3.4.1" + "version": "==3.11.0" }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "version": "==2.10" - }, - "importlib-metadata": { - "hashes": [ - "sha256:2ec0faae539743ae6aaa84b49a169670a465f7f5d64e6add98388cc29fd1f2f6", - "sha256:c9356b657de65c53744046fa8f7358afe0714a1af7d570c00c3835c2d724a7c1" + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_version < '3.8'", - "version": "==3.10.1" + "markers": "python_version >= '3'", + "version": "==3.2" }, "itypes": { "hashes": [ @@ -602,10 +619,10 @@ }, "jinja2": { "hashes": [ - "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", - "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" + "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", + "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" ], - "version": "==2.11.3" + "version": "==3.0.1" }, "jsonfield": { "hashes": [ @@ -616,28 +633,35 @@ }, "kombu": { "hashes": [ - "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", - "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" + "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d", + "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a" ], - "version": "==5.0.2" + "version": "==5.1.0" }, "lxml": { "hashes": [ "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", + "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae", "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", + "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59", "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", + "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96", "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", + "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354", "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", + "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16", + "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4", "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", + "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a", "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", @@ -650,10 +674,15 @@ "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", + "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d", + "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617", "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", + "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92", "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", + "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24", "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", + "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e", "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", @@ -666,93 +695,99 @@ "hashes": [ "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f" ], + "markers": "extra == 'html'", "version": "==1.14" }, "markupsafe": { "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", - "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", - "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", - "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", - "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", - "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", - "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", - "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", - "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", + "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", + "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", + "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", + "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", + "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", + "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", + "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" ], - "version": "==1.1.1" + "version": "==2.0.1" }, "newrelic": { "hashes": [ - "sha256:242a5e901d684f7ffdd621bc58da8fe9a85d5545b4b63e1070589f5ab45c9e1e", - "sha256:3dec4647de67609570c4e305f2b6432a00e0a0940a7ac69660ee92268b49d6e7", - "sha256:489e5a450aae1a5ecf7ca488739bd274296b19049bb7927c7ef71953ad5ad437", - "sha256:503e5dfcbf215fe68e4349ea452b5b00234010122ca72d80b64c73270654916c", - "sha256:5b0a04f7acf4dafe8d3935ac8688143bc0a0c61e15e2a779b152afcc3c88ee45", - "sha256:6a87cd6102aba7c9619a6e4b9e1aa6322ef81367b1a8f24ad996a07333313c8c", - "sha256:90d2bab0a08001d84499bf11c62c49d1fc6f2835c05d12994b5a931cad48f120", - "sha256:a3b928a052be318cb0cdb56977c630f1ded1d8e391c876ed5ae4442aa7ad499a", - "sha256:adc748f633bd64e295b403448daa8416961b0f99af6787b857009d737c5e8af3", - "sha256:e767af29572a9457a5e5f13481fe735c1a9ae2d1683b7b35c8757f9df275a538", - "sha256:fede816248d0a1e5e11487ecc122f24c9d33e08a6ac1f882044ac6f3b2c90ae0" + "sha256:09a7706d32f5516059608bdc0309e014e700016184a91fdba3857eb0c6d584a4", + "sha256:0bf89d3e2866e7266ec1b444382a8216135fafcd318e74b88dcb1abbc2af4bb1", + "sha256:0eccccf768d01b1dd1d03dd3a7f74da3d207a61db6512fa677a025a5cc543288", + "sha256:3d276c1e325e4256f53c1bf5e9efa3904a57e8659068e4cec02290d94de01261", + "sha256:4aae3c862200fc9cee2198dfed43de9343bfb2576dc6ad3ef0ac8333a5ade084", + "sha256:57d39e99faccc46adeff38123844bc8b3baa23ad08fee8b245ebce6f68c5f225", + "sha256:878d0c2d1ee23b59e886dcf19db3ab45633a07cbd2ac60d06ed17818e6805417", + "sha256:91624670b54c13741217da90d438bfae6ee0c07904c4b9689bcc097f12234e22", + "sha256:9379355a2ffa980729b1765a9a45989732c9ab2e614732a306fbff4d2c3669ab", + "sha256:9917a17fb66216cbc04967cd0da78a3d532b70e25ae71b7e46e8cc341126b400", + "sha256:ac8a6dbf7244639249a8046646cbf35a603d4b0fbcfb3ae8bb2bef6f0cdef26e", + "sha256:ae4594ea9abe7f24ba24972780374d691505000d3883a7bcc94d645093357436", + "sha256:ce8fd19ab1049a2f70a11f79f27043a0d547945c7b3e0c50d0a859ddea5233a7" ], "index": "pypi", - "version": "==6.2.0.156" + "version": "==6.8.1.164" }, "oauthlib": { "hashes": [ - "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", - "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" + "sha256:42bf6354c2ed8c6acb54d971fce6f88193d97297e18602a3a886603f9d7730cc", + "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3" ], - "version": "==3.1.0" + "version": "==3.1.1" }, "odfpy": { "hashes": [ "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec" ], + "markers": "extra == 'ods'", "version": "==1.4.1" }, "openapi-codec": { @@ -816,51 +851,45 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", - "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" + "sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c", + "sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c" ], - "version": "==3.0.18" + "version": "==3.0.20" }, "psycopg2-binary": { "hashes": [ - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", - "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", - "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", - "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", - "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", - "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", - "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", - "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", - "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", - "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", - "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", - "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", - "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", - "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", - "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", - "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" - ], - "index": "pypi", - "version": "==2.8.6" + "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975", + "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd", + "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616", + "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2", + "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90", + "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a", + "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e", + "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d", + "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed", + "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a", + "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140", + "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32", + "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31", + "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a", + "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917", + "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf", + "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7", + "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0", + "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72", + "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698", + "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773", + "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68", + "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76", + "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4", + "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f", + "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34", + "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce", + "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a", + "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e" + ], + "index": "pypi", + "version": "==2.9.1" }, "pycparser": { "hashes": [ @@ -871,10 +900,10 @@ }, "pyjwt": { "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" + "sha256:934d73fbba91b0483d3857d1aff50e96b2a892384ee2c17417ed3203f173fca1", + "sha256:fba44e7898bbca160a2b2b501f492824fc8382485d3a6f11ba5d0c1937ce6130" ], - "version": "==1.7.1" + "version": "==2.1.0" }, "pypdf2": { "hashes": [ @@ -903,30 +932,29 @@ }, "python-dateutil": { "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], - "version": "==2.8.1" + "version": "==2.8.2" }, "python-docx": { "hashes": [ - "sha256:bc76ecac6b2d00ce6442a69d03a6f35c71cd72293cd8405a7472dfe317920024" + "sha256:1105d233a0956dd8dd1e710d20b159e2d72ac3c301041b95f4d4ceb3e0ebebc4" ], - "version": "==0.8.10" + "version": "==0.8.11" }, "python-magic": { "hashes": [ - "sha256:8551e804c09a3398790bd9e392acb26554ae2609f29c72abb0b9dee9a5571eae", - "sha256:ca884349f2c92ce830e3f498c5b7c7051fe2942c3ee4332f65213b8ebff15a62" + "sha256:4fec8ee805fea30c07afccd1592c0f17977089895bdfaae5fec870a84e997626", + "sha256:de800df9fb50f8ec5974761054a708af6e4246b03b4bdaee993f948947b0ebcf" ], - "version": "==0.4.22" + "version": "==0.4.24" }, "python3-openid": { "hashes": [ "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" ], - "markers": "python_version >= '3.0'", "version": "==3.2.0" }, "pytz": { @@ -968,6 +996,7 @@ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], + "index": "pypi", "version": "==5.4.1" }, "redis": { @@ -979,45 +1008,39 @@ }, "reportlab": { "hashes": [ - "sha256:0cf2206c73fbca752c8bd39e12bb9ad7f2d01e6fcb2b25b9eaf94ea042fe86c9", - "sha256:0d670e119d7f7a68a1136de024464999e8e3d5d1491f23cdd39d5d72481af88f", - "sha256:1656722530b3bbce012b093abf6290ab76dcba39d21f9e703310b008ddc7ffe9", - "sha256:1e41b441542881e007420530bbc028f08c0f546ecaaebdf9f065f901acdac106", - "sha256:34d827c771d6b4d7b45f7fc49a638c97fbd8a0fab6c9d3838ff04d307420b739", - "sha256:370c5225f0c395a9f1482ac8d4f974d2073548f186eaf49ceb91414f534ad4d8", - "sha256:42b90b0cb3556f4d1cc1c538345abc249b6ff58939d3af5e37f5fa8421d9ae07", - "sha256:492bd47aabeaa3215cde7a8d3c0d88c909bf7e6b63f0b511a645f1ffc1e948f6", - "sha256:4c5785b018ed6f48e762737deaa6b7528b0ba43ad67fca566bf10d0337a76dcd", - "sha256:519ef25d49fe807c6c0402abb5fe4d14b47a8e2358050d8d7673beecfbe116b2", - "sha256:51a2d5de2c605117cd25dfb3f51d1d14caf1cbed4ef6db582f085eeb0a0c922f", - "sha256:55ef4476b2cdecfa643ae4d7591aa157568f903c378c83ea544650b33b2d856d", - "sha256:5b4acfb15ca028bbc652a6c8d63073dec2a3c8c0db7585d68b96b52940f65899", - "sha256:5c483c96d4cbeb4919ad9fcf2f262e8e08e34dcbcf8d2bda16263ef002c890d4", - "sha256:5c931032aa955431c808e469eb0780ca7d12b39228a02ae7ea09f63d47b1e260", - "sha256:6a3119d0e985e5c7dadfcf29fb79bbab19806b08ad901622b23f5868c0221fce", - "sha256:72bb5417f198eb059f01d5a9e1ef80f2fbaf3eaa4cd63e9a681bbbd0ed9fcdf9", - "sha256:8cd355f8a4c7c126a246f4b4a9803c80498939709bb37d3db4f8dbee1eb7d8f0", - "sha256:9517f26a512a62d49fc4800222b306e21a14ceec8bd82c93182313ef1eefaa7a", - "sha256:9945e80a0a6e370f90a23907cc70a0811e808f79420fb9051e26d9c79eb8e26b", - "sha256:9989737a409235a734ec783b0545f2966247b26ff555e847f3d0f945e5a11493", - "sha256:9c0d71aef4fb5d30dc6ebd08a2bce317a7eaf37d468f85320947eb580daea90a", - "sha256:9d48fd4a1c2d98ec6686511717f0980d36f5590e038d5afe4e5241f328f06e38", - "sha256:af12fbff15a9652ef117456d1d6a4d6fade8fdc02670d6fd31212402e9d03559", - "sha256:b2b72a0742a493979c348dc3c9a329bd5b87e4243ffecf837b1c8739d58410ba", - "sha256:bda784ebb116d56d3e7133c8e0942cf68cb7fd58bdccf57231dbe56b6430eb01", - "sha256:df2784a474028b15a723f6b347625f1f91740de418bed4a0a2694c954de34dd7", - "sha256:e2b47a8e0126ec0a3820a2e299a94a6fc29ba132249957dd32c447d380eaae5f", - "sha256:e4b9b443e88735be4927529d66d9e1164b4fbd6a882e90114967eedc6ad608e7" - ], - "version": "==3.5.67" + "sha256:00e9ffb955972a8f6a3a0d61a12231fcaf5e23ee238c98421d65fecc29bd88a1", + "sha256:115177b3fc51209b5f50371735311c9a6cd9d260ffedbdce5fbc965645b7567c", + "sha256:17130f034dae50aaf22fce2292e0077a0c2093ba4363211bcafb54418fb8dc09", + "sha256:200bdfc327d5b06cb400ae86c972b579efe03a1fd8a2e8cb7a5d9aaa744e5adb", + "sha256:496b28ef414d9a7734e07221c4386bb00f416a3aa276b9f349ed9a328c73ec23", + "sha256:4bc378039f70141176f3d511d84bc1a172820d4d2edee4f9fcff52cde753dc08", + "sha256:4f357b4c39b0fa0071de47e8be7af44e07f375d2e59e395daccb7fd13b275668", + "sha256:57b39303e6dbe3de91e60a14269543ac058ac98a0ea6cf900f5403d9c226022f", + "sha256:6472478e597ef4a8f5c621d811d08b7ef09fc5af5bc85c2cf4a4505a7164f8b8", + "sha256:68f9324000cfc5570b5a59a92306691b5d655078a399f20bc72c2581fe903261", + "sha256:69870e2bbf39b60ebe9a31b31324e249bf314bdc2798e46efc58c67db74b56cb", + "sha256:6adb17ba89829d5e77fd81baac396f1af99241d7dfc121a065217334131662e7", + "sha256:7c360aee2bdaa05c24cadddc2f10924961dc7cad125d8876b4d307c879b3b4e8", + "sha256:7c4c8e87ef29714ccc7fa9764efe30d849cd38f8a9a1742ab7aedf8b5e23494d", + "sha256:8a07672e86bf288ea3e55959d2e06d6c01320318662241f9b7a71c583e15e5b5", + "sha256:9f583295f7dd523bf6e5619720677279dc7b9db22671573888f0591fc46b90b2", + "sha256:b668433f32ac955a94633e58ed7800c06c00f9c46d3b99e2189b3d88dc3184c8", + "sha256:b7a92564198c5a5ff4efdb83ace215c73343afb80d9379183bc736fea76edd6d", + "sha256:bd52e1715c70a96a116a61c8477e586b3a46047c85581195bc74162b19b46286", + "sha256:c7ddc9a6234267bbb52059b017ca22f59ffd7d41d545524cb85f68086a2cbb43", + "sha256:c8586d72932b8e3bd50a5230d6f1cfbb85c2605bad34253c6d6fe757211b2bf7", + "sha256:ce3d8e782e3776f19d3accc706aab85ff06caedb70a52016532bebacf5537567", + "sha256:f3fd26f63c4a9033115707a8718154538a1cebfd6ec992f214e6423524450e3e" + ], + "version": "==3.6.1" }, "requests": { "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", + "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" ], "index": "pypi", - "version": "==2.25.1" + "version": "==2.26.0" }, "requests-oauthlib": { "hashes": [ @@ -1028,68 +1051,69 @@ }, "sentry-sdk": { "hashes": [ - "sha256:71de00c9711926816f750bc0f57ef2abbcb1bfbdf5378c601df7ec978f44857a", - "sha256:9221e985f425913204989d0e0e1cbb719e8b7fa10540f1bc509f660c06a34e66" + "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c", + "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52" ], "index": "pypi", - "version": "==1.0.0" + "version": "==1.3.1" }, "simplejson": { "hashes": [ - "sha256:034550078a11664d77bc1a8364c90bb7eef0e44c2dbb1fd0a4d92e3997088667", - "sha256:05b43d568300c1cd43f95ff4bfcff984bc658aa001be91efb3bb21df9d6288d3", - "sha256:0dd9d9c738cb008bfc0862c9b8fa6743495c03a0ed543884bf92fb7d30f8d043", - "sha256:10fc250c3edea4abc15d930d77274ddb8df4803453dde7ad50c2f5565a18a4bb", - "sha256:2862beabfb9097a745a961426fe7daf66e1714151da8bb9a0c430dde3d59c7c0", - "sha256:292c2e3f53be314cc59853bd20a35bf1f965f3bc121e007ab6fd526ed412a85d", - "sha256:2d3eab2c3fe52007d703a26f71cf649a8c771fcdd949a3ae73041ba6797cfcf8", - "sha256:2e7b57c2c146f8e4dadf84977a83f7ee50da17c8861fd7faf694d55e3274784f", - "sha256:311f5dc2af07361725033b13cc3d0351de3da8bede3397d45650784c3f21fbcf", - "sha256:344e2d920a7f27b4023c087ab539877a1e39ce8e3e90b867e0bfa97829824748", - "sha256:3fabde09af43e0cbdee407555383063f8b45bfb52c361bc5da83fcffdb4fd278", - "sha256:42b8b8dd0799f78e067e2aaae97e60d58a8f63582939af60abce4c48631a0aa4", - "sha256:4b3442249d5e3893b90cb9f72c7d6ce4d2ea144d2c0d9f75b9ae1e5460f3121a", - "sha256:55d65f9cc1b733d85ef95ab11f559cce55c7649a2160da2ac7a078534da676c8", - "sha256:5c659a0efc80aaaba57fcd878855c8534ecb655a28ac8508885c50648e6e659d", - "sha256:72d8a3ffca19a901002d6b068cf746be85747571c6a7ba12cbcf427bfb4ed971", - "sha256:75ecc79f26d99222a084fbdd1ce5aad3ac3a8bd535cd9059528452da38b68841", - "sha256:76ac9605bf2f6d9b56abf6f9da9047a8782574ad3531c82eae774947ae99cc3f", - "sha256:7d276f69bfc8c7ba6c717ba8deaf28f9d3c8450ff0aa8713f5a3280e232be16b", - "sha256:7f10f8ba9c1b1430addc7dd385fc322e221559d3ae49b812aebf57470ce8de45", - "sha256:8042040af86a494a23c189b5aa0ea9433769cc029707833f261a79c98e3375f9", - "sha256:813846738277729d7db71b82176204abc7fdae2f566e2d9fcf874f9b6472e3e6", - "sha256:845a14f6deb124a3bcb98a62def067a67462a000e0508f256f9c18eff5847efc", - "sha256:869a183c8e44bc03be1b2bbcc9ec4338e37fa8557fc506bf6115887c1d3bb956", - "sha256:8acf76443cfb5c949b6e781c154278c059b09ac717d2757a830c869ba000cf8d", - "sha256:8f713ea65958ef40049b6c45c40c206ab363db9591ff5a49d89b448933fa5746", - "sha256:934115642c8ba9659b402c8bdbdedb48651fb94b576e3b3efd1ccb079609b04a", - "sha256:9551f23e09300a9a528f7af20e35c9f79686d46d646152a0c8fc41d2d074d9b0", - "sha256:9a2b7543559f8a1c9ed72724b549d8cc3515da7daf3e79813a15bdc4a769de25", - "sha256:a55c76254d7cf8d4494bc508e7abb993a82a192d0db4552421e5139235604625", - "sha256:ad8f41c2357b73bc9e8606d2fa226233bf4d55d85a8982ecdfd55823a6959995", - "sha256:af4868da7dd53296cd7630687161d53a7ebe2e63814234631445697bd7c29f46", - "sha256:afebfc3dd3520d37056f641969ce320b071bc7a0800639c71877b90d053e087f", - "sha256:b59aa298137ca74a744c1e6e22cfc0bf9dca3a2f41f51bc92eb05695155d905a", - "sha256:bc00d1210567a4cdd215ac6e17dc00cb9893ee521cee701adfd0fa43f7c73139", - "sha256:c1cb29b1fced01f97e6d5631c3edc2dadb424d1f4421dad079cb13fc97acb42f", - "sha256:c94dc64b1a389a416fc4218cd4799aa3756f25940cae33530a4f7f2f54f166da", - "sha256:ceaa28a5bce8a46a130cd223e895080e258a88d51bf6e8de2fc54a6ef7e38c34", - "sha256:cff6453e25204d3369c47b97dd34783ca820611bd334779d22192da23784194b", - "sha256:d0b64409df09edb4c365d95004775c988259efe9be39697d7315c42b7a5e7e94", - "sha256:d4813b30cb62d3b63ccc60dd12f2121780c7a3068db692daeb90f989877aaf04", - "sha256:da3c55cdc66cfc3fffb607db49a42448785ea2732f055ac1549b69dcb392663b", - "sha256:e058c7656c44fb494a11443191e381355388443d543f6fc1a245d5d238544396", - "sha256:fed0f22bf1313ff79c7fc318f7199d6c2f96d4de3234b2f12a1eab350e597c06", - "sha256:ffd4e4877a78c84d693e491b223385e0271278f5f4e1476a4962dca6824ecfeb" - ], - "version": "==3.17.2" + "sha256:065230b9659ac38c8021fa512802562d122afb0cf8d4b89e257014dcddb5730a", + "sha256:07707ba69324eaf58f0c6f59d289acc3e0ed9ec528dae5b0d4219c0d6da27dc5", + "sha256:10defa88dd10a0a4763f16c1b5504e96ae6dc68953cfe5fc572b4a8fcaf9409b", + "sha256:140eb58809f24d843736edb8080b220417e22c82ac07a3dfa473f57e78216b5f", + "sha256:188f2c78a8ac1eb7a70a4b2b7b9ad11f52181044957bf981fb3e399c719e30ee", + "sha256:1c2688365743b0f190392e674af5e313ebe9d621813d15f9332e874b7c1f2d04", + "sha256:24e413bd845bd17d4d72063d64e053898543fb7abc81afeae13e5c43cef9c171", + "sha256:2b59acd09b02da97728d0bae8ff48876d7efcbbb08e569c55e2d0c2e018324f5", + "sha256:2df15814529a4625ea6f7b354a083609b3944c269b954ece0d0e7455872e1b2a", + "sha256:352c11582aa1e49a2f0f7f7d8fd5ec5311da890d1354287e83c63ab6af857cf5", + "sha256:36b08b886027eac67e7a0e822e3a5bf419429efad7612e69501669d6252a21f2", + "sha256:376023f51edaf7290332dacfb055bc00ce864cb013c0338d0dea48731f37e42f", + "sha256:3ba82f8b421886f4a2311c43fb98faaf36c581976192349fef2a89ed0fcdbdef", + "sha256:3d72aa9e73134dacd049a2d6f9bd219f7be9c004d03d52395831611d66cedb71", + "sha256:40ece8fa730d1a947bff792bcc7824bd02d3ce6105432798e9a04a360c8c07b0", + "sha256:417b7e119d66085dc45bdd563dcb2c575ee10a3b1c492dd3502a029448d4be1c", + "sha256:42b7c7264229860fe879be961877f7466d9f7173bd6427b3ba98144a031d49fb", + "sha256:457d9cfe7ece1571770381edccdad7fc255b12cd7b5b813219441146d4f47595", + "sha256:4a6943816e10028eeed512ea03be52b54ea83108b408d1049b999f58a760089b", + "sha256:5b94df70bd34a3b946c0eb272022fb0f8a9eb27cad76e7f313fedbee2ebe4317", + "sha256:5f5051a13e7d53430a990604b532c9124253c5f348857e2d5106d45fc8533860", + "sha256:5f7f53b1edd4b23fb112b89208377480c0bcee45d43a03ffacf30f3290e0ed85", + "sha256:5fe8c6dcb9e6f7066bdc07d3c410a2fca78c0d0b4e0e72510ffd20a60a20eb8e", + "sha256:71a54815ec0212b0cba23adc1b2a731bdd2df7b9e4432718b2ed20e8aaf7f01a", + "sha256:7332f7b06d42153255f7bfeb10266141c08d48cc1a022a35473c95238ff2aebc", + "sha256:78c6f0ed72b440ebe1892d273c1e5f91e55e6861bea611d3b904e673152a7a4c", + "sha256:7c9b30a2524ae6983b708f12741a31fbc2fb8d6fecd0b6c8584a62fd59f59e09", + "sha256:86fcffc06f1125cb443e2bed812805739d64ceb78597ac3c1b2d439471a09717", + "sha256:87572213965fd8a4fb7a97f837221e01d8fddcfb558363c671b8aa93477fb6a2", + "sha256:8e595de17178dd3bbeb2c5b8ea97536341c63b7278639cb8ee2681a84c0ef037", + "sha256:917f01db71d5e720b731effa3ff4a2c702a1b6dacad9bcdc580d86a018dfc3ca", + "sha256:91cfb43fb91ff6d1e4258be04eee84b51a4ef40a28d899679b9ea2556322fb50", + "sha256:aa86cfdeb118795875855589934013e32895715ec2d9e8eb7a59be3e7e07a7e1", + "sha256:ade09aa3c284d11f39640aebdcbb748e1996f0c60504f8c4a0c5a9fec821e67a", + "sha256:b2a5688606dffbe95e1347a05b77eb90489fe337edde888e23bbb7fd81b0d93b", + "sha256:b92fbc2bc549c5045c8233d954f3260ccf99e0f3ec9edfd2372b74b350917752", + "sha256:c2d5334d935af711f6d6dfeec2d34e071cdf73ec0df8e8bd35ac435b26d8da97", + "sha256:cb0afc3bad49eb89a579103616574a54b523856d20fc539a4f7a513a0a8ba4b2", + "sha256:ce66f730031b9b3683b2fc6ad4160a18db86557c004c3d490a29bf8d450d7ab9", + "sha256:e29b9cea4216ec130df85d8c36efb9985fda1c9039e4706fb30e0fb6a67602ff", + "sha256:e2cc4b68e59319e3de778325e34fbff487bfdb2225530e89995402989898d681", + "sha256:e90d2e219c3dce1500dda95f5b893c293c4d53c4e330c968afbd4e7a90ff4a5b", + "sha256:f13c48cc4363829bdfecc0c181b6ddf28008931de54908a492dc8ccd0066cd60", + "sha256:f550730d18edec4ff9d4252784b62adfe885d4542946b6d5a54c8a6521b56afd", + "sha256:fa843ee0d34c7193f5a816e79df8142faff851549cab31e84b526f04878ac778", + "sha256:fe1c33f78d2060719d52ea9459d97d7ae3a5b707ec02548575c4fbed1d1d345b" + ], + "version": "==3.17.5" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "version": "==1.15.0" + "version": "==1.16.0" }, "social-auth-app-django": { "hashes": [ @@ -1105,12 +1129,11 @@ "azuread" ], "hashes": [ - "sha256:21c0639c56befd33ec162c2210d583bb1de8e1136d53b21bafb96afaf2f86c91", - "sha256:2f6ce1af8ec2b2cc37b86d647f7d4e4292f091ee556941db34b1e0e2dee77fc0", - "sha256:4a3cdf69c449b235cdabd54a1be7ba3722611297e69fded52e3584b1a990af25" + "sha256:5ab43b3b15dce5f059db69cc3082c216574739f0edbc98629c8c6e8769c67eb4", + "sha256:983b53167ac56e7ba4909db555602a6e7a98c97ca47183bb222eb85ba627bf2b" ], "index": "pypi", - "version": "==3.3.3" + "version": "==4.1.0" }, "sqlparse": { "hashes": [ @@ -1193,15 +1216,6 @@ "markers": "python_version >= '3.5.2'", "version": "==6.1" }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "markers": "python_version < '3.8'", - "version": "==3.7.4.3" - }, "unicef-attachments": { "hashes": [ "sha256:4a62e9335b231a1360d2a2bf9664e3999e8261005839e7e3811f38fe21ba3e3e", @@ -1220,11 +1234,11 @@ }, "unicef-locations": { "hashes": [ - "sha256:43bb9ec6ef015d78805ff5a6b76ec0e7eff3e1dc3c27b64de169593ed39e3957", - "sha256:a13104b3959d3d56347abcf154cd2bdabc49eedb998c5cf92ba50e25ceb6b35b" + "sha256:61363692737bbecb3d50efd03d61ab86746a2876a366241c5a354548951040ee", + "sha256:a83898767dacc675dc2bd9dc31be029346e1ae9f13dce925f1de6202c5768887" ], "index": "pypi", - "version": "==1.9" + "version": "==3.0" }, "unicef-notification": { "hashes": [ @@ -1250,10 +1264,11 @@ }, "unicef-snapshot": { "hashes": [ - "sha256:32824f491f0ab8fc07e053337337557f5b543e05915f411a1e7e9077138c937b" + "sha256:1740c75736d3d921d9cea57c127b02be99bfc37efbf69115f1df81d5c371ec3e", + "sha256:5645e3c887b108450b138ca87be3c73c45477611660e5056f0ea4b70202734bc" ], "index": "pypi", - "version": "==0.2.3" + "version": "==1.1" }, "unicef-vision": { "hashes": [ @@ -1278,10 +1293,10 @@ }, "urllib3": { "hashes": [ - "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", - "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" + "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", + "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "version": "==1.26.4" + "version": "==1.26.6" }, "vine": { "hashes": [ @@ -1316,6 +1331,7 @@ "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88" ], + "markers": "extra == 'xls'", "version": "==2.0.1" }, "xlwt": { @@ -1323,14 +1339,8 @@ "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88" ], + "markers": "extra == 'xls'", "version": "==1.3.0" - }, - "zipp": { - "hashes": [ - "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", - "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" - ], - "version": "==3.4.1" } }, "develop": { @@ -1341,27 +1351,12 @@ ], "version": "==0.7.12" }, - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "appnope": { - "hashes": [ - "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442", - "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a" - ], - "markers": "sys_platform == 'darwin'", - "version": "==0.1.2" - }, "babel": { "hashes": [ - "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5", - "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05" + "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", + "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" ], - "version": "==2.9.0" + "version": "==2.9.1" }, "backcall": { "hashes": [ @@ -1370,19 +1365,27 @@ ], "version": "==0.2.0" }, + "backports.entry-points-selectable": { + "hashes": [ + "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a", + "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc" + ], + "version": "==1.1.0" + }, "certifi": { "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], - "version": "==2020.12.5" + "version": "==2021.5.30" }, - "chardet": { + "charset-normalizer": { "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b", + "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3" ], - "version": "==4.0.0" + "markers": "python_version >= '3'", + "version": "==2.0.4" }, "coverage": { "hashes": [ @@ -1444,24 +1447,24 @@ }, "decorator": { "hashes": [ - "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060", - "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98" + "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323", + "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5" ], - "version": "==5.0.7" + "version": "==5.0.9" }, "distlib": { "hashes": [ - "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", - "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" + "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736", + "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c" ], - "version": "==0.3.1" + "version": "==0.3.2" }, "docutils": { "hashes": [ - "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", - "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" + "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", + "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" ], - "version": "==0.16" + "version": "==0.17.1" }, "factory-boy": { "hashes": [ @@ -1473,10 +1476,10 @@ }, "faker": { "hashes": [ - "sha256:26c7c3df8d46f1db595a34962f8967021dd90bbd38cc6e27461a3fb16cd413ae", - "sha256:44eb060fad3015690ff3fec6564d7171be393021e820ad1851d96cb968fbfcd4" + "sha256:6714c153433086681b26e5c95ee314ee0fcd45ec05f2426097543dd4c70789a6", + "sha256:810859626d19e62a2a13aa4a08d59ada131f0522431eec163b09b6df147a25b9" ], - "version": "==8.1.0" + "version": "==8.12.1" }, "fancycompleter": { "hashes": [ @@ -1494,11 +1497,11 @@ }, "flake8": { "hashes": [ - "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff", - "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0" + "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", + "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" ], "index": "pypi", - "version": "==3.9.0" + "version": "==3.9.2" }, "freezegun": { "hashes": [ @@ -1510,10 +1513,11 @@ }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "version": "==2.10" + "markers": "python_version >= '3'", + "version": "==3.2" }, "imagesize": { "hashes": [ @@ -1522,36 +1526,21 @@ ], "version": "==1.2.0" }, - "importlib-metadata": { - "hashes": [ - "sha256:2ec0faae539743ae6aaa84b49a169670a465f7f5d64e6add98388cc29fd1f2f6", - "sha256:c9356b657de65c53744046fa8f7358afe0714a1af7d570c00c3835c2d724a7c1" - ], - "markers": "python_version < '3.8'", - "version": "==3.10.1" - }, "ipython": { "hashes": [ - "sha256:9c900332d4c5a6de534b4befeeb7de44ad0cc42e8327fa41b7685abde58cec74", - "sha256:c0ce02dfaa5f854809ab7413c601c4543846d9da81010258ecdab299b542d199" + "sha256:58b55ebfdfa260dad10d509702dc2857cb25ad82609506b070cf2d7b7df5af13", + "sha256:75b5e060a3417cf64f138e0bb78e58512742c57dc29db5a5058a2b1f0c10df02" ], "index": "pypi", - "version": "==7.22.0" - }, - "ipython-genutils": { - "hashes": [ - "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", - "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" - ], - "version": "==0.2.0" + "version": "==7.27.0" }, "isort": { "hashes": [ - "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", - "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" + "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899", + "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2" ], "index": "pypi", - "version": "==5.8.0" + "version": "==5.9.3" }, "jedi": { "hashes": [ @@ -1562,67 +1551,76 @@ }, "jinja2": { "hashes": [ - "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", - "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" + "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", + "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" ], - "version": "==2.11.3" + "version": "==3.0.1" }, "markupsafe": { "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", - "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", - "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", - "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", - "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", - "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", - "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", - "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", - "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", + "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", + "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", + "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", + "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", + "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", + "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", + "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" ], - "version": "==1.1.1" + "version": "==2.0.1" + }, + "matplotlib-inline": { + "hashes": [ + "sha256:5cf1176f554abb4fa98cb362aa2b55c500147e4bdbb07e3fda359143e1da0811", + "sha256:f41d5ff73c9f5385775d5c0bc13b424535c8402fe70ea8210f93e11f3683993e" + ], + "version": "==0.1.2" }, "mccabe": { "hashes": [ @@ -1683,10 +1681,10 @@ }, "packaging": { "hashes": [ - "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", - "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" + "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", + "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" ], - "version": "==20.9" + "version": "==21.0" }, "parso": { "hashes": [ @@ -1697,10 +1695,11 @@ }, "pdbpp": { "hashes": [ - "sha256:73ff220d5006e0ecdc3e2705d8328d8aa5ac27fef95cc06f6e42cd7d22d55eb8" + "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", + "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5" ], "index": "pypi", - "version": "==0.10.2" + "version": "==0.10.3" }, "pexpect": { "hashes": [ @@ -1717,19 +1716,26 @@ ], "version": "==0.7.5" }, + "platformdirs": { + "hashes": [ + "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f", + "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648" + ], + "version": "==2.3.0" + }, "pluggy": { "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" ], - "version": "==0.13.1" + "version": "==1.0.0" }, "prompt-toolkit": { "hashes": [ - "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", - "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" + "sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c", + "sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c" ], - "version": "==3.0.18" + "version": "==3.0.20" }, "ptyprocess": { "hashes": [ @@ -1761,10 +1767,10 @@ }, "pygments": { "hashes": [ - "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94", - "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8" + "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380", + "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6" ], - "version": "==2.8.1" + "version": "==2.10.0" }, "pyparsing": { "hashes": [ @@ -1781,10 +1787,10 @@ }, "python-dateutil": { "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], - "version": "==2.8.1" + "version": "==2.8.2" }, "pytz": { "hashes": [ @@ -1825,30 +1831,31 @@ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], + "index": "pypi", "version": "==5.4.1" }, "requests": { "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", + "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" ], "index": "pypi", - "version": "==2.25.1" + "version": "==2.26.0" }, "responses": { "hashes": [ - "sha256:0f0ab4717728d33dae8e66deea61eecc1e38f0398e35249e3963ff74cfc8d0d8", - "sha256:75529f9bea08276cea43545dcb6129f137c299d6a12269485a753785c869e0e2" + "sha256:9476775d856d3c24ae660bbebe29fb6d789d4ad16acd723efbfb6ee20990b899", + "sha256:d8d0f655710c46fd3513b9202a7f0dcedd02ca0f8cf4976f27fa8ab5b81e656d" ], "index": "pypi", - "version": "==0.13.2" + "version": "==0.13.4" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "version": "==1.15.0" + "version": "==1.16.0" }, "snowballstemmer": { "hashes": [ @@ -1859,11 +1866,11 @@ }, "sphinx": { "hashes": [ - "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1", - "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8" + "sha256:3092d929cd807926d846018f2ace47ba2f3b671b309c7a89cd3306e80c826b13", + "sha256:46d52c6cee13fec44744b8c01ed692c18a640f6910a725cbb938bc36e8d64544" ], "index": "pypi", - "version": "==3.5.4" + "version": "==4.1.2" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -1881,10 +1888,10 @@ }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", - "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" + "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", + "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" ], - "version": "==1.0.3" + "version": "==2.0.0" }, "sphinxcontrib-jsmath": { "hashes": [ @@ -1902,10 +1909,10 @@ }, "sphinxcontrib-serializinghtml": { "hashes": [ - "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", - "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" + "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", + "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" ], - "version": "==1.1.4" + "version": "==1.1.5" }, "text-unidecode": { "hashes": [ @@ -1923,34 +1930,25 @@ }, "tox": { "hashes": [ - "sha256:05a4dbd5e4d3d8269b72b55600f0b0303e2eb47ad5c6fe76d3576f4c58d93661", - "sha256:e007673f3595cede9b17a7c4962389e4305d4a3682a6c5a4159a1453b4f326aa" + "sha256:9fbf8e2ab758b2a5e7cb2c72945e4728089934853076f67ef18d7575c8ab6b88", + "sha256:c6c4e77705ada004283610fd6d9ba4f77bc85d235447f875df9f0ba1bc23b634" ], "index": "pypi", - "version": "==3.23.0" + "version": "==3.24.3" }, "traitlets": { "hashes": [ - "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", - "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" - ], - "version": "==5.0.5" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + "sha256:03f172516916220b58c9f19d7f854734136dd9528103d04e9bf139a92c9f54c4", + "sha256:bd382d7ea181fbbcce157c133db9a829ce06edffe097bcf3ab945b435452b46d" ], - "markers": "python_version < '3.8'", - "version": "==3.7.4.3" + "version": "==5.1.0" }, "urllib3": { "hashes": [ - "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", - "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" + "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", + "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], - "version": "==1.26.4" + "version": "==1.26.6" }, "vcrpy": { "hashes": [ @@ -1962,10 +1960,10 @@ }, "virtualenv": { "hashes": [ - "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107", - "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c" + "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0", + "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06" ], - "version": "==20.4.3" + "version": "==20.7.2" }, "wcwidth": { "hashes": [ @@ -1976,9 +1974,9 @@ }, "wmctrl": { "hashes": [ - "sha256:d806f65ac1554366b6e31d29d7be2e8893996c0acbb2824bbf2b1f49cf628a13" + "sha256:66cbff72b0ca06a22ec3883ac3a4d7c41078bdae4fb7310f52951769b10e14e0" ], - "version": "==0.3" + "version": "==0.4" }, "wrapt": { "hashes": [ @@ -2028,13 +2026,6 @@ ], "markers": "python_version >= '3.6'", "version": "==1.6.3" - }, - "zipp": { - "hashes": [ - "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", - "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" - ], - "version": "==3.4.1" } } } diff --git a/src/etools/applications/action_points/admin.py b/src/etools/applications/action_points/admin.py index af4bbc7f6..975587fd9 100644 --- a/src/etools/applications/action_points/admin.py +++ b/src/etools/applications/action_points/admin.py @@ -22,13 +22,13 @@ def has_add_permission(self, request, obj=None): class ActionPointAdmin(SnapshotModelAdmin): - list_display = ('author', 'assigned_to', 'status', 'date_of_completion') + list_display = ('reference_number', 'author', 'assigned_to', 'status', 'date_of_completion') list_filter = ('status', ) - search_fields = ('author__email', 'assigned_to__email') + search_fields = ('author__email', 'assigned_to__email', 'reference_number') inlines = (CommentInline, ActivityInline, ) - readonly_fields = ('status', ) raw_id_fields = ('section', 'office', 'location', 'cp_output', 'partner', 'intervention', 'tpm_activity', - 'psea_assessment', 'travel_activity', 'engagement', 'author', 'assigned_by', 'assigned_to') + 'psea_assessment', 'travel_activity', 'engagement', 'author', 'assigned_by', 'assigned_to', + 'monitoring_activity') admin.site.register(ActionPoint, ActionPointAdmin) diff --git a/src/etools/applications/action_points/serializers.py b/src/etools/applications/action_points/serializers.py index d0697f27c..0e415adc1 100644 --- a/src/etools/applications/action_points/serializers.py +++ b/src/etools/applications/action_points/serializers.py @@ -12,7 +12,7 @@ from etools.applications.action_points.categories.models import Category from etools.applications.action_points.categories.serializers import CategorySerializer from etools.applications.action_points.models import ActionPoint -from etools.applications.partners.serializers.interventions_v2 import BaseInterventionListSerializer +from etools.applications.partners.serializers.interventions_v2 import MinimalInterventionListSerializer from etools.applications.partners.serializers.partner_organization_v2 import MinimalPartnerOrganizationListSerializer from etools.applications.permissions2.serializers import PermissionsBasedSerializerMixin from etools.applications.reports.serializers.v1 import ResultSerializer, SectionSerializer @@ -73,7 +73,7 @@ class ActionPointListSerializer(PermissionsBasedSerializerMixin, ActionPointBase read_field=MinimalPartnerOrganizationListSerializer(read_only=True, label=_('Partner')), ) intervention = SeparatedReadWriteField( - read_field=BaseInterventionListSerializer(read_only=True, label=_('PD/SSFA')), + read_field=MinimalInterventionListSerializer(read_only=True, label=_('PD/SSFA')), required=False, ) diff --git a/src/etools/applications/audit/migrations/0001_initial.py b/src/etools/applications/audit/migrations/0001_initial.py index 2452275b1..8e6d7f42f 100644 --- a/src/etools/applications/audit/migrations/0001_initial.py +++ b/src/etools/applications/audit/migrations/0001_initial.py @@ -1,7 +1,6 @@ # Generated by Django 1.10.8 on 2018-03-26 16:05 import django.contrib.postgres.fields -import django.contrib.postgres.fields.jsonb import django.db.models.deletion import django.utils.timezone from django.conf import settings @@ -157,7 +156,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('value', models.SmallIntegerField(blank=True, choices=[ (0, 'N/A'), (1, 'Low'), (2, 'Medium'), (3, 'Significant'), (4, 'High')], null=True, verbose_name='Value')), - ('extra', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Extra')), + ('extra', models.JSONField(blank=True, null=True, verbose_name='Extra')), ], ), migrations.CreateModel( diff --git a/src/etools/applications/audit/models.py b/src/etools/applications/audit/models.py index bfd3174cc..3e5e1bf68 100644 --- a/src/etools/applications/audit/models.py +++ b/src/etools/applications/audit/models.py @@ -1,7 +1,7 @@ from decimal import DivisionByZero, InvalidOperation from django.contrib.auth import get_user_model -from django.contrib.postgres.fields import ArrayField, JSONField +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.db import connection, models from django.db.transaction import atomic @@ -395,7 +395,7 @@ class Risk(models.Model): on_delete=models.CASCADE, ) value = models.SmallIntegerField(choices=VALUES, null=True, blank=True, verbose_name=_('Value')) - extra = JSONField(blank=True, null=True, verbose_name=_('Extra')) + extra = models.JSONField(blank=True, null=True, verbose_name=_('Extra')) def __str__(self): return 'Risk at {}, {}'.format(self.engagement, self.value) diff --git a/src/etools/applications/audit/purchase_order/admin.py b/src/etools/applications/audit/purchase_order/admin.py index 2857fa926..9d2ae41c3 100644 --- a/src/etools/applications/audit/purchase_order/admin.py +++ b/src/etools/applications/audit/purchase_order/admin.py @@ -1,10 +1,10 @@ -from functools import update_wrapper - -from django.conf.urls import url from django.contrib import admin from django.http.response import HttpResponseRedirect from django.urls import reverse +from admin_extra_urls.decorators import button +from admin_extra_urls.mixins import ExtraUrlMixin + from etools.applications.audit.purchase_order.models import ( AuditorFirm, AuditorStaffMember, @@ -40,8 +40,7 @@ class PurchaseOrderItemAdmin(admin.TabularInline): @admin.register(PurchaseOrder) -class PurchaseOrderAdmin(admin.ModelAdmin): - change_form_template = 'admin/purchase_order/change_form.html' +class PurchaseOrderAdmin(ExtraUrlMixin, admin.ModelAdmin): list_display = [ 'order_number', 'auditor_firm', 'contract_start_date', 'contract_end_date', @@ -52,19 +51,7 @@ class PurchaseOrderAdmin(admin.ModelAdmin): search_fields = ['order_number', 'auditor_firm__name', ] inlines = [PurchaseOrderItemAdmin] - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - return update_wrapper(wrapper, view) - - custom_urls = [ - url(r'^(?P\d+)/sync_purchase_order/$', wrap(self.sync_purchase_order), name='purchase_order_sync_purchase_order'), - ] - return custom_urls + urls - + @button() def sync_purchase_order(self, request, pk): sync_purchase_order(PurchaseOrder.objects.get(id=pk).order_number) return HttpResponseRedirect(reverse('admin:purchase_order_purchaseorder_change', args=[pk])) diff --git a/src/etools/applications/audit/tests/test_views.py b/src/etools/applications/audit/tests/test_views.py index 19963a05f..51492d5f8 100644 --- a/src/etools/applications/audit/tests/test_views.py +++ b/src/etools/applications/audit/tests/test_views.py @@ -972,6 +972,31 @@ def test_user_search_view(self): self.assertEqual(len(response.data), 1) self.assertIsNone(response.data[0]['auditor_firm']) + def test_users_list_queries(self): + [UserFactory() for _i in range(10)] + + with self.assertNumQueries(1): + response = self.forced_auth_req( + 'get', + '/api/audit/audit-firms/users/', + user=self.unicef_user, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('auditor_firm', response.data[0]) + + def test_users_list_queries_verbosity_minimal(self): + [UserFactory() for _i in range(10)] + + with self.assertNumQueries(1): + response = self.forced_auth_req( + 'get', + '/api/audit/audit-firms/users/', + user=self.unicef_user, + data={'verbosity': 'minimal'} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn('auditor_firm', response.data[0]) + class TestAuditorStaffMembersViewSet(AuditTestCaseMixin, BaseTenantTestCase): def test_list_view(self): @@ -1217,7 +1242,7 @@ def _test_pdf_view(self, user, status_code=status.HTTP_200_OK): self.assertEqual(response.status_code, status_code) if status_code == status.HTTP_200_OK: - self.assertIn(response._headers['content-disposition'][0], 'Content-Disposition') + self.assertIn('Content-Disposition', response.headers) def test_guest(self): self.user = None @@ -1260,7 +1285,7 @@ def _test_pdf_view(self, user, status_code=status.HTTP_200_OK): self.assertEqual(response.status_code, status_code) if status_code == status.HTTP_200_OK: - self.assertIn(response._headers['content-disposition'][0], 'Content-Disposition') + self.assertIn('Content-Disposition', response.headers) def test_guest(self): self.user = None diff --git a/src/etools/applications/audit/views.py b/src/etools/applications/audit/views.py index de09e4a36..edb69fbde 100644 --- a/src/etools/applications/audit/views.py +++ b/src/etools/applications/audit/views.py @@ -99,6 +99,7 @@ from etools.applications.permissions2.drf_permissions import get_permission_for_targets, NestedPermission from etools.applications.permissions2.metadata import BaseMetadata, PermissionBasedMetadata from etools.applications.permissions2.views import PermittedFSMActionMixin, PermittedSerializerMixin +from etools.applications.users.serializers_v3 import MinimalUserSerializer class BaseAuditViewSet( @@ -128,6 +129,19 @@ class AuditUsersViewSet(generics.ListAPIView): queryset = get_user_model().objects.all() serializer_class = AuditUserSerializer + def get_serializer_class(self): + if self.request.query_params.get('verbosity') == 'minimal': + return MinimalUserSerializer + return super().get_serializer_class() + + def get_queryset(self): + queryset = super().get_queryset() + + if self.request.query_params.get('verbosity', 'full') != 'minimal': + queryset = queryset.select_related('profile', 'purchase_order_auditorstaffmember__auditor_firm') + + return queryset + class AuditorFirmViewSet( BaseAuditViewSet, diff --git a/src/etools/applications/core/auth.py b/src/etools/applications/core/auth.py index 1977d792b..615730abb 100644 --- a/src/etools/applications/core/auth.py +++ b/src/etools/applications/core/auth.py @@ -5,17 +5,14 @@ from django.contrib.auth.models import Group from django.http import HttpResponseRedirect -import jwt from rest_framework.authentication import ( BasicAuthentication, get_authorization_header, SessionAuthentication, TokenAuthentication, ) -from rest_framework.exceptions import AuthenticationFailed, PermissionDenied -from rest_framework_jwt.authentication import JSONWebTokenAuthentication -from rest_framework_jwt.settings import api_settings -from rest_framework_jwt.utils import jwt_payload_handler +from rest_framework.exceptions import PermissionDenied +from rest_framework_simplejwt.authentication import JWTAuthentication from social_core.backends.azuread_b2c import AzureADB2COAuth2 from social_core.exceptions import AuthCanceled, AuthMissingParameter from social_core.pipeline import social_auth, user as social_core_user @@ -24,7 +21,6 @@ from etools.applications.users.models import Country from etools.libraries.tenant_support.utils import set_country -jwt_decode_handler = api_settings.JWT_DECODE_HANDLER logger = logging.getLogger(__name__) @@ -185,36 +181,17 @@ def authenticate(self, request): return user, token -class EToolsTenantJWTAuthentication(JSONWebTokenAuthentication): +class EToolsTenantJWTAuthentication(JWTAuthentication): """ Handles setting the tenant after a JWT successful authentication """ def authenticate(self, request): - jwt_value = self.get_jwt_value(request) - if jwt_value is None: - # no JWT token return to skip this authentication mechanism - return None - - try: - user, jwt_value = super().authenticate(request) - except TypeError: - raise PermissionDenied(detail='No valid authentication provided') - except AuthenticationFailed: - # Try again - if getattr(settings, 'JWT_ALLOW_NON_EXISTENT_USERS', False): - try: - # try and see if the token is valid - payload = jwt_decode_handler(jwt_value) - except (jwt.ExpiredSignature, jwt.DecodeError): - raise PermissionDenied(detail='Authentication Failed') - else: - # signature is valid user does not exist... setting default authenticated user - user = get_user_model().objects.get(username=settings.DEFAULT_UNICEF_USER) - setattr(user, 'jwt_payload', payload) - else: - raise PermissionDenied(detail='Authentication Failed') + authentication = super().authenticate(request) + if authentication is None: + return + user, validated_token = authentication if not user.profile.country: raise PermissionDenied(detail='No country found for user') @@ -224,16 +201,10 @@ def authenticate(self, request): user.profile.save() set_country(user, request) - return user, jwt_value + return user, validated_token class CsrfExemptSessionAuthentication(SessionAuthentication): def enforce_csrf(self, request): return - - -def custom_jwt_payload_handler(user): - payload = jwt_payload_handler(user) - payload['groups'] = list(user.groups.values_list('name', flat=True)) - return payload diff --git a/src/etools/applications/core/permissions.py b/src/etools/applications/core/permissions.py index a01f0e707..c49ababf1 100644 --- a/src/etools/applications/core/permissions.py +++ b/src/etools/applications/core/permissions.py @@ -4,6 +4,8 @@ from django.conf import settings from django.core.cache import cache +from rest_framework.permissions import IsAuthenticated + from etools.libraries.pythonlib.collections import Vividict @@ -91,3 +93,9 @@ def process_file(): cache.set(cache_key, response) return response + + +class IsUNICEFUser(IsAuthenticated): + + def has_permission(self, request, view): + return super().has_permission(request, view) and request.user.groups.filter(name='UNICEF User').exists() diff --git a/src/etools/applications/core/views.py b/src/etools/applications/core/views.py index cc61d6db7..44926d7b3 100644 --- a/src/etools/applications/core/views.py +++ b/src/etools/applications/core/views.py @@ -5,11 +5,12 @@ from django.urls import reverse from django.views.generic import RedirectView -from rest_framework.permissions import IsAuthenticated +import jwt from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_jwt.serializers import jwt_encode_handler, jwt_payload_handler -from rest_framework_jwt.views import jwt_response_payload_handler +from rest_framework_simplejwt.tokens import RefreshToken + +from etools.applications.core.permissions import IsUNICEFUser class MainView(RedirectView): @@ -23,15 +24,34 @@ def get(self, request, *args, **kwargs): class IssueJWTRedirectView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = (IsUNICEFUser, ) def get(self, request): user = self.request.user - payload = jwt_payload_handler(user) - token = jwt_encode_handler(payload) - response_data = jwt_response_payload_handler(token, user, request) - return Response(data=response_data) + refresh = RefreshToken.for_user(user) + access = str(refresh.access_token) + + decoded_token = jwt.decode(access, + settings.SIMPLE_JWT['VERIFYING_KEY'], + [settings.SIMPLE_JWT['ALGORITHM']], + audience=settings.SIMPLE_JWT['AUDIENCE'], + leeway=settings.SIMPLE_JWT['LEEWAY'], + ) + + decoded_token.update({ + 'groups': list(user.groups.values_list('name', flat=True)), + 'username': user.username, + 'email': user.email, + }) + + encoded = jwt.encode( + decoded_token, + settings.SIMPLE_JWT['SIGNING_KEY'], + algorithm=settings.SIMPLE_JWT['ALGORITHM'] + ) + + return Response(data={'token': encoded}) def logout_view(request): diff --git a/src/etools/applications/environment/migrations/0001_initial.py b/src/etools/applications/environment/migrations/0001_initial.py index bd2e36544..1abecaa2f 100644 --- a/src/etools/applications/environment/migrations/0001_initial.py +++ b/src/etools/applications/environment/migrations/0001_initial.py @@ -30,7 +30,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='The human/computer readable name.', max_length=100, unique=True, verbose_name='Name')), - ('everyone', models.NullBooleanField( + ('everyone', models.BooleanField(blank=True, null=True, help_text='Flip this flag on (Yes) or off (No) for everyone, overriding all other settings. Leave as Unknown to use normally.', verbose_name='Everyone')), ('percent', models.DecimalField(blank=True, decimal_places=1, help_text='A number between 0.0 and 99.9 to indicate a percentage of users for whom this flag will be active.', max_digits=3, null=True, verbose_name='Percent')), diff --git a/src/etools/applications/environment/models.py b/src/etools/applications/environment/models.py index 207fb6803..4c7351546 100644 --- a/src/etools/applications/environment/models.py +++ b/src/etools/applications/environment/models.py @@ -26,7 +26,7 @@ class TenantFlag(BaseModel): 'The human/computer readable name.')) countries = models.ManyToManyField(Country, blank=True, verbose_name=_('Countries'), help_text=( 'Activate this flag for these countries.')) - everyone = models.NullBooleanField(blank=True, verbose_name=_('Everyone'), help_text=( + everyone = models.BooleanField(blank=True, null=True, verbose_name=_('Everyone'), help_text=( 'Flip this flag on (Yes) or off (No) for everyone, overriding all ' 'other settings. Leave as Unknown to use normally.')) percent = models.DecimalField(max_digits=3, decimal_places=1, null=True, blank=True, verbose_name=_('Percent'), diff --git a/src/etools/applications/field_monitoring/data_collection/migrations/0001_initial_20191113.py b/src/etools/applications/field_monitoring/data_collection/migrations/0001_initial_20191113.py index 2e9354c0d..fb3df54e6 100644 --- a/src/etools/applications/field_monitoring/data_collection/migrations/0001_initial_20191113.py +++ b/src/etools/applications/field_monitoring/data_collection/migrations/0001_initial_20191113.py @@ -1,7 +1,6 @@ # Generated by Django 2.2.6 on 2019-11-13 10:19 from django.conf import settings -import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion @@ -26,10 +25,10 @@ class Migration(migrations.Migration): ('is_enabled', models.BooleanField(default=True, verbose_name='Enabled')), ('cp_output', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='reports.Result', verbose_name='Partner')), + to='reports.Result', verbose_name='CP Output')), ('intervention', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='partners.Intervention', verbose_name='Partner')), + to='partners.Intervention', verbose_name='Intervention')), ('monitoring_activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='field_monitoring_planning.MonitoringActivity', verbose_name='Activity')), @@ -69,7 +68,7 @@ class Migration(migrations.Migration): name='Finding', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Value')), + ('value', models.JSONField(blank=True, null=True, verbose_name='Value')), ('activity_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='field_monitoring_data_collection.ActivityQuestion', @@ -91,10 +90,10 @@ class Migration(migrations.Migration): ('narrative_finding', models.TextField(blank=True, verbose_name='Narrative Finding')), ('cp_output', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='reports.Result', verbose_name='Partner')), + to='reports.Result', verbose_name='CP Output')), ('intervention', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='partners.Intervention', verbose_name='Partner')), + to='partners.Intervention', verbose_name='Intervention')), ('partner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='partners.PartnerOrganization', verbose_name='Partner')), @@ -112,7 +111,7 @@ class Migration(migrations.Migration): name='ActivityQuestionOverallFinding', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Value')), + ('value', models.JSONField(blank=True, null=True, verbose_name='Value')), ('activity_question', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='overall_finding', to='field_monitoring_data_collection.ActivityQuestion', verbose_name='Activity')), @@ -128,13 +127,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('narrative_finding', models.TextField(blank=True, verbose_name='Narrative Finding')), - ('on_track', models.NullBooleanField()), + ('on_track', models.BooleanField(blank=True, null=True,)), ('cp_output', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='reports.Result', verbose_name='Partner')), + to='reports.Result', verbose_name='CP Output')), ('intervention', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='partners.Intervention', verbose_name='Partner')), + to='partners.Intervention', verbose_name='Intervention')), ('monitoring_activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='overall_findings', to='field_monitoring_planning.MonitoringActivity', verbose_name='Activity')), diff --git a/src/etools/applications/field_monitoring/data_collection/models.py b/src/etools/applications/field_monitoring/data_collection/models.py index 30dc45ca5..eb4d007e5 100644 --- a/src/etools/applications/field_monitoring/data_collection/models.py +++ b/src/etools/applications/field_monitoring/data_collection/models.py @@ -1,5 +1,4 @@ from django.conf import settings -from django.contrib.postgres.fields import JSONField from django.db import models from django.utils.translation import gettext_lazy as _ @@ -81,7 +80,7 @@ class Finding(models.Model): on_delete=models.CASCADE) activity_question = models.ForeignKey(ActivityQuestion, related_name='findings', verbose_name=_('Activity Question'), on_delete=models.CASCADE) - value = JSONField(null=True, blank=True, verbose_name=_('Value')) + value = models.JSONField(null=True, blank=True, verbose_name=_('Value')) class Meta: verbose_name = _('Checklist Finding') @@ -95,7 +94,7 @@ def __str__(self): class ActivityQuestionOverallFinding(models.Model): activity_question = models.OneToOneField(ActivityQuestion, related_name='overall_finding', verbose_name=_('Activity'), on_delete=models.CASCADE) - value = JSONField(null=True, blank=True, verbose_name=_('Value')) + value = models.JSONField(null=True, blank=True, verbose_name=_('Value')) class Meta: verbose_name = _('Overall Activity Question Finding') @@ -125,7 +124,7 @@ class ActivityOverallFinding(QuestionTargetMixin, models.Model): monitoring_activity = models.ForeignKey(MonitoringActivity, related_name='overall_findings', verbose_name=_('Activity'), on_delete=models.CASCADE) narrative_finding = models.TextField(blank=True, verbose_name=_('Narrative Finding')) - on_track = models.NullBooleanField() + on_track = models.BooleanField(null=True, blank=True) class Meta: verbose_name = _('Activity Overall Finding') diff --git a/src/etools/applications/field_monitoring/data_collection/tests/test_views.py b/src/etools/applications/field_monitoring/data_collection/tests/test_views.py index b94a4d2bf..2af4c8933 100644 --- a/src/etools/applications/field_monitoring/data_collection/tests/test_views.py +++ b/src/etools/applications/field_monitoring/data_collection/tests/test_views.py @@ -368,7 +368,7 @@ def get_list_args(self): return [self.activity.pk, self.started_checklist.id] def test_list(self): - with self.assertNumQueries(8): + with self.assertNumQueries(6): self._test_list(self.unicef_user, self.started_checklist.overall_findings.all()) def test_update_unicef(self): @@ -555,7 +555,7 @@ def test_list(self): AttachmentFactory(content_object=checklist.overall_findings.first()) - with self.assertNumQueries(11): + with self.assertNumQueries(9): response = self._test_list(self.unicef_user, [self.overall_finding]) self.assertIn('attachments', response.data['results'][0]) self.assertNotEqual(response.data['results'][0]['attachments'], []) @@ -660,7 +660,7 @@ def test_list(self): AttachmentFactory(content_object=self.activity.overall_findings.first()) checklist_overall_attachment = AttachmentFactory(content_object=self.started_checklist.overall_findings.first()) - with self.assertNumQueries(9): + with self.assertNumQueries(7): self._test_list(self.unicef_user, expected_objects=[checklist_overall_attachment]) def test_file_types(self): diff --git a/src/etools/applications/field_monitoring/fm_settings/migrations/0001_initial_20191113.py b/src/etools/applications/field_monitoring/fm_settings/migrations/0001_initial_20191113.py index cec316014..c9ef2f1b1 100644 --- a/src/etools/applications/field_monitoring/fm_settings/migrations/0001_initial_20191113.py +++ b/src/etools/applications/field_monitoring/fm_settings/migrations/0001_initial_20191113.py @@ -2,7 +2,6 @@ from django.conf import settings import django.contrib.gis.db.models.fields -import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion import django.utils.timezone @@ -100,7 +99,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('label', models.CharField(max_length=50, verbose_name='Label')), - ('value', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Value')), + ('value', models.JSONField(blank=True, null=True, verbose_name='Value')), ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='field_monitoring_settings.Question', verbose_name='Question')), ], diff --git a/src/etools/applications/field_monitoring/fm_settings/models.py b/src/etools/applications/field_monitoring/fm_settings/models.py index 99fb6589d..420a9791c 100644 --- a/src/etools/applications/field_monitoring/fm_settings/models.py +++ b/src/etools/applications/field_monitoring/fm_settings/models.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.gis.db.models import PointField -from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.db import models from django.db.models import Prefetch, QuerySet @@ -153,7 +152,7 @@ class Option(models.Model): label = models.CharField(max_length=50, verbose_name=_('Label')) # TODO: remove json field usage and replace with Charfield as this is only used without a structure: # eg: value = 1, value = "Characters", value = True -> used only for automatic typecasting and cand be confusing - value = JSONField(verbose_name=_('Value'), blank=True, null=True) + value = models.JSONField(verbose_name=_('Value'), blank=True, null=True) class Meta: verbose_name = _('Option') diff --git a/src/etools/applications/field_monitoring/fm_settings/views.py b/src/etools/applications/field_monitoring/fm_settings/views.py index fdd879fa7..eb1fd9998 100644 --- a/src/etools/applications/field_monitoring/fm_settings/views.py +++ b/src/etools/applications/field_monitoring/fm_settings/views.py @@ -128,7 +128,7 @@ def export(self, request, *args, **kwargs): class LocationsCountryView(views.APIView): def get(self, request, *args, **kwargs): - country = get_object_or_404(Location, gateway__admin_level=0) + country = get_object_or_404(Location, gateway__admin_level=0, is_active=True) return Response(data=LocationFullSerializer(instance=country).data) diff --git a/src/etools/applications/field_monitoring/planning/admin.py b/src/etools/applications/field_monitoring/planning/admin.py index 7b287e58d..0e2575ed5 100644 --- a/src/etools/applications/field_monitoring/planning/admin.py +++ b/src/etools/applications/field_monitoring/planning/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin -from etools.applications.field_monitoring.planning.models import MonitoringActivity, QuestionTemplate, YearPlan +from etools.applications.action_points.admin import ActionPointAdmin +from etools.applications.field_monitoring.planning.models import ( + MonitoringActivity, + MonitoringActivityActionPoint, + MonitoringActivityGroup, + QuestionTemplate, + YearPlan, +) @admin.register(YearPlan) @@ -26,3 +33,20 @@ class MonitoringActivityAdmin(admin.ModelAdmin): ) list_select_related = ('tpm_partner', 'visit_lead', 'location', 'location_site') list_filter = ('monitor_type', 'status') + + +@admin.register(MonitoringActivityGroup) +class MonitoringActivityGroupAdmin(admin.ModelAdmin): + list_display = ('partner', 'get_monitoring_activities') + list_select_related = ('partner',) + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('monitoring_activities') + + def get_monitoring_activities(self, obj): + return ', '.join(a.number for a in obj.monitoring_activities) + + +@admin.register(MonitoringActivityActionPoint) +class MonitoringActivityActionPointAdmin(ActionPointAdmin): + list_display = ('monitoring_activity', ) + ActionPointAdmin.list_display diff --git a/src/etools/applications/field_monitoring/planning/filters.py b/src/etools/applications/field_monitoring/planning/filters.py index d590ce27b..af4d2f88e 100644 --- a/src/etools/applications/field_monitoring/planning/filters.py +++ b/src/etools/applications/field_monitoring/planning/filters.py @@ -88,3 +88,12 @@ class InterventionsFilterSet(filters.FilterSet): class Meta: model = Intervention fields = ['partners__in', 'cp_outputs__in'] + + +class HactForPartnerFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + hact_for_partner = request.query_params.get('hact_for_partner', '') + if not hact_for_partner: + return queryset + + return queryset.filter_hact_for_partner(hact_for_partner) diff --git a/src/etools/applications/field_monitoring/planning/migrations/0001_initial_20191113.py b/src/etools/applications/field_monitoring/planning/migrations/0001_initial_20191113.py index 5ee64c2d8..44d1ffb90 100644 --- a/src/etools/applications/field_monitoring/planning/migrations/0001_initial_20191113.py +++ b/src/etools/applications/field_monitoring/planning/migrations/0001_initial_20191113.py @@ -56,10 +56,10 @@ class Migration(migrations.Migration): ('specific_details', models.TextField(blank=True, verbose_name='Specific Details To Probe')), ('cp_output', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='reports.Result', verbose_name='Partner')), + to='reports.Result', verbose_name='CP Output')), ('intervention', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', - to='partners.Intervention', verbose_name='Partner')), + to='partners.Intervention', verbose_name='Intervention')), ('partner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='partners.PartnerOrganization', verbose_name='Partner')), diff --git a/src/etools/applications/field_monitoring/planning/migrations/0011_monitoringactivitygroup.py b/src/etools/applications/field_monitoring/planning/migrations/0011_monitoringactivitygroup.py new file mode 100644 index 000000000..4ac0667a7 --- /dev/null +++ b/src/etools/applications/field_monitoring/planning/migrations/0011_monitoringactivitygroup.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.20 on 2021-06-30 10:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0048_auto_20210506_0803'), + ('field_monitoring_planning', '0010_auto_20210614_0738'), + ] + + operations = [ + migrations.CreateModel( + name='MonitoringActivityGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('monitoring_activities', models.ManyToManyField(related_name='groups', to='field_monitoring_planning.MonitoringActivity')), + ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monitoring_activity_groups', to='partners.PartnerOrganization')), + ], + ), + ] diff --git a/src/etools/applications/field_monitoring/planning/migrations/0012_auto_20210709_1455.py b/src/etools/applications/field_monitoring/planning/migrations/0012_auto_20210709_1455.py new file mode 100644 index 000000000..bd0d2ef94 --- /dev/null +++ b/src/etools/applications/field_monitoring/planning/migrations/0012_auto_20210709_1455.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2021-07-09 14:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('field_monitoring_planning', '0011_monitoringactivitygroup'), + ] + + operations = [ + migrations.AlterModelManagers( + name='monitoringactivity', + managers=[ + ], + ), + ] diff --git a/src/etools/applications/field_monitoring/planning/models.py b/src/etools/applications/field_monitoring/planning/models.py index b653569fe..db3db0ccc 100644 --- a/src/etools/applications/field_monitoring/planning/models.py +++ b/src/etools/applications/field_monitoring/planning/models.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.db import connection, models -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.db.models.base import ModelBase from django.utils.translation import gettext_lazy as _ @@ -71,9 +71,9 @@ def __str__(self): class QuestionTargetMixin(models.Model): partner = models.ForeignKey(PartnerOrganization, blank=True, null=True, verbose_name=_('Partner'), on_delete=models.CASCADE, related_name='+') - cp_output = models.ForeignKey(Result, blank=True, null=True, verbose_name=_('Partner'), + cp_output = models.ForeignKey(Result, blank=True, null=True, verbose_name=_('CP Output'), on_delete=models.CASCADE, related_name='+') - intervention = models.ForeignKey(Intervention, blank=True, null=True, verbose_name=_('Partner'), + intervention = models.ForeignKey(Intervention, blank=True, null=True, verbose_name=_('Intervention'), on_delete=models.CASCADE, related_name='+') @property @@ -102,6 +102,35 @@ def __str__(self): return 'Question Template for {}'.format(self.related_to) +class MonitoringActivitiesQuerySet(models.QuerySet): + def filter_hact_for_partner(self, partner_id: int): + from etools.applications.field_monitoring.data_collection.models import ( + ActivityOverallFinding, + ActivityQuestionOverallFinding, + ) + + question_sq = ActivityQuestionOverallFinding.objects.filter( + activity_question__monitoring_activity_id=OuterRef('id'), + activity_question__question__is_hact=True, + activity_question__question__level='partner', + value__isnull=False, + ) + finding_sq = ActivityOverallFinding.objects.filter( + ~Q(narrative_finding=''), + monitoring_activity_id=OuterRef('id'), + partner_id=partner_id, + ) + + return self.annotate( + is_hact=Exists(question_sq), + has_finding_for_partner=Exists(finding_sq), + ).filter( + status=MonitoringActivity.STATUS_COMPLETED, + is_hact=True, + has_finding_for_partner=True, + ) + + class MonitoringActivityMeta(ProtectUnknownTransitionsMeta, ModelBase): pass @@ -237,6 +266,8 @@ class MonitoringActivity( visit_lead_tracker = FieldTracker(fields=['visit_lead']) + objects = models.Manager.from_queryset(MonitoringActivitiesQuerySet)() + class Meta: verbose_name = _('Monitoring Activity') verbose_name_plural = _('Monitoring Activities') @@ -568,3 +599,15 @@ def get_mail_context(self, user=None): if self.monitoring_activity: context['monitoring_activity'] = self.monitoring_activity.get_mail_context(user=user) return context + + +class MonitoringActivityGroup(models.Model): + partner = models.ForeignKey( + 'partners.PartnerOrganization', + on_delete=models.CASCADE, + related_name='monitoring_activity_groups', + ) + monitoring_activities = models.ManyToManyField(MonitoringActivity, related_name='groups') + + def __str__(self): + return f'{self.partner} Monitoring Activities Group' diff --git a/src/etools/applications/field_monitoring/planning/notifications/fm-action_point_assigned.py b/src/etools/applications/field_monitoring/planning/notifications/fm-action_point_assigned.py new file mode 100644 index 000000000..d27f020d2 --- /dev/null +++ b/src/etools/applications/field_monitoring/planning/notifications/fm-action_point_assigned.py @@ -0,0 +1,35 @@ +from unicef_notification.utils import strip_text + +name = 'fm/action_point_assigned' +defaults = { + 'description': 'Field Monitoring action point was assigned', + 'subject': '[eTools] ACTION POINT ASSIGNED to {{ action_point.person_responsible }}', + + 'content': strip_text(""" + Dear {{ action_point.person_responsible }}, + + {{ action_point.assigned_by }} has assigned you an action point. + + Visit ID: {{ action_point.monitoring_activity.reference_number }} + Due Date: {{ action_point.due_date }} + Link: {{ action_point.monitoring_activity.object_url }} + + Thank you. + """), + + 'html_content': """ + {% extends "email-templates/base" %} + + {% block content %} + Dear {{ action_point.person_responsible }},

+ + {{ action_point.assigned_by }} has assigned you an action point.

+ + Visit ID: {{ action_point.monitoring_activity.reference_number }}
+ Due Date: {{ action_point.due_date }}
+ Link: click here

+ + Thank you. + {% endblock %} + """ +} diff --git a/src/etools/applications/field_monitoring/planning/signals.py b/src/etools/applications/field_monitoring/planning/signals.py index 7167d16b9..d4d56cf1f 100644 --- a/src/etools/applications/field_monitoring/planning/signals.py +++ b/src/etools/applications/field_monitoring/planning/signals.py @@ -5,7 +5,11 @@ MonitoringActivityOfflineSynchronizer, ) from etools.applications.field_monitoring.fm_settings.models import Question -from etools.applications.field_monitoring.planning.models import MonitoringActivity, QuestionTemplate +from etools.applications.field_monitoring.planning.models import ( + MonitoringActivity, + MonitoringActivityActionPoint, + QuestionTemplate, +) @receiver(post_save, sender=Question) @@ -26,3 +30,9 @@ def update_blueprints_visibility_on_visit_lead_changed(instance, created, **kwar if instance.status == MonitoringActivity.STATUSES.data_collection \ and instance.visit_lead_tracker.changed(): MonitoringActivityOfflineSynchronizer(instance).update_data_collectors_list() + + +@receiver(post_save, sender=MonitoringActivityActionPoint) +def action_point_updated_receiver(instance, created, **kwargs): + if created: + instance.send_email(instance.assigned_to, 'fm/action_point_assigned', cc=[instance.assigned_by.email]) diff --git a/src/etools/applications/field_monitoring/planning/tests/factories.py b/src/etools/applications/field_monitoring/planning/tests/factories.py index fefc46cfb..c61ae6c3b 100644 --- a/src/etools/applications/field_monitoring/planning/tests/factories.py +++ b/src/etools/applications/field_monitoring/planning/tests/factories.py @@ -7,8 +7,14 @@ from etools.applications.action_points.categories.models import Category from etools.applications.action_points.tests.factories import ActionPointFactory from etools.applications.field_monitoring.fm_settings.tests.factories import QuestionFactory -from etools.applications.field_monitoring.planning.models import MonitoringActivity, QuestionTemplate, YearPlan +from etools.applications.field_monitoring.planning.models import ( + MonitoringActivity, + MonitoringActivityGroup, + QuestionTemplate, + YearPlan, +) from etools.applications.field_monitoring.tests.factories import UserFactory +from etools.applications.partners.tests.factories import PartnerFactory from etools.libraries.tests.factories import StatusFactoryMetaClass @@ -153,3 +159,19 @@ class Meta: class MonitoringActivityActionPointFactory(ActionPointFactory): monitoring_activity = factory.SubFactory(MonitoringActivityFactory, status='completed') category__module = Category.MODULE_CHOICES.fm + + +class MonitoringActivityGroupFactory(factory.django.DjangoModelFactory): + partner = factory.SubFactory(PartnerFactory) + + class Meta: + model = MonitoringActivityGroup + + @factory.post_generation + def monitoring_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.monitoring_activities.add(activity) diff --git a/src/etools/applications/field_monitoring/planning/tests/test_views.py b/src/etools/applications/field_monitoring/planning/tests/test_views.py index df52e40dd..85a2780f0 100644 --- a/src/etools/applications/field_monitoring/planning/tests/test_views.py +++ b/src/etools/applications/field_monitoring/planning/tests/test_views.py @@ -17,8 +17,14 @@ AttachmentLinkFactory, ) from etools.applications.core.tests.cases import BaseTenantTestCase -from etools.applications.field_monitoring.data_collection.models import ActivityOverallFinding -from etools.applications.field_monitoring.data_collection.tests.factories import StartedChecklistFactory +from etools.applications.field_monitoring.data_collection.models import ( + ActivityOverallFinding, + ActivityQuestionOverallFinding, +) +from etools.applications.field_monitoring.data_collection.tests.factories import ( + ActivityQuestionFactory, + StartedChecklistFactory, +) from etools.applications.field_monitoring.fm_settings.models import Question from etools.applications.field_monitoring.fm_settings.tests.factories import QuestionFactory from etools.applications.field_monitoring.planning.models import MonitoringActivity, YearPlan @@ -126,6 +132,42 @@ def test_filter_by_visit_lead(self): data={'visit_lead__in': f'{activity1.visit_lead.pk},{activity2.visit_lead.pk}'} ) + def test_filter_by_partner_hact(self): + partner = PartnerFactory() + activity1 = MonitoringActivityFactory(partners=[partner], status='completed') + MonitoringActivityFactory(partners=[partner]) + MonitoringActivityFactory(partners=[partner]) + ActivityQuestionOverallFinding.objects.create( + activity_question=ActivityQuestionFactory( + question__is_hact=True, + question__level='partner', + monitoring_activity=activity1, + ), + value='ok', + ) + ActivityOverallFinding.objects.create(partner=partner, narrative_finding='test', + monitoring_activity=activity1) + + # not completed + activity2 = MonitoringActivityFactory(partners=[partner], status='report_finalization') + MonitoringActivityFactory(partners=[partner]) + MonitoringActivityFactory(partners=[partner]) + ActivityQuestionOverallFinding.objects.create( + activity_question=ActivityQuestionFactory( + question__is_hact=True, + question__level='partner', + monitoring_activity=activity2, + ), + value='ok', + ) + ActivityOverallFinding.objects.create(partner=partner, narrative_finding='test', + monitoring_activity=activity2) + + # not hact + MonitoringActivityFactory(partners=[partner], status='completed') + + self._test_list(self.unicef_user, [activity1], data={'hact_for_partner': partner.id}) + def test_details(self): activity = MonitoringActivityFactory(monitor_type='staff', team_members=[UserFactory(unicef_user=True)]) diff --git a/src/etools/applications/field_monitoring/planning/views.py b/src/etools/applications/field_monitoring/planning/views.py index 9e0cb64aa..8562cd1b5 100644 --- a/src/etools/applications/field_monitoring/planning/views.py +++ b/src/etools/applications/field_monitoring/planning/views.py @@ -33,6 +33,7 @@ from etools.applications.field_monitoring.planning.activity_validation.validator import ActivityValid from etools.applications.field_monitoring.planning.filters import ( CPOutputsFilterSet, + HactForPartnerFilter, InterventionsFilterSet, MonitoringActivitiesFilterSet, ReferenceNumberOrderingFilter, @@ -154,7 +155,10 @@ class MonitoringActivitiesViewSet( (IsEditAction & IsListAction & IsFieldMonitor) | (IsEditAction & (IsObjectAction & (IsFieldMonitor | IsVisitLead))) ] - filter_backends = (DjangoFilterBackend, ReferenceNumberOrderingFilter, OrderingFilter, SearchFilter) + filter_backends = ( + DjangoFilterBackend, ReferenceNumberOrderingFilter, + OrderingFilter, SearchFilter, HactForPartnerFilter, + ) filter_class = MonitoringActivitiesFilterSet ordering_fields = ( 'start_date', 'end_date', 'location', 'location_site', 'monitor_type', 'checklists_count', 'status' diff --git a/src/etools/applications/funds/migrations/0012_alter_fundsreservationitem_line_item_text.py b/src/etools/applications/funds/migrations/0012_alter_fundsreservationitem_line_item_text.py new file mode 100644 index 000000000..84cb438fc --- /dev/null +++ b/src/etools/applications/funds/migrations/0012_alter_fundsreservationitem_line_item_text.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2021-08-05 01:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0011_fundsreservationheader_delegated'), + ] + + operations = [ + migrations.AlterField( + model_name='fundsreservationitem', + name='line_item_text', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Description'), + ), + ] diff --git a/src/etools/applications/funds/models.py b/src/etools/applications/funds/models.py index 5c2a749af..bbb28f35a 100644 --- a/src/etools/applications/funds/models.py +++ b/src/etools/applications/funds/models.py @@ -255,7 +255,7 @@ class FundsReservationItem(TimeStampedModel): line_item_text = models.CharField( verbose_name=_("Description"), max_length=255, - default='', + null=True, blank=True, ) diff --git a/src/etools/applications/hact/management/commands/freeze_hact_data.py b/src/etools/applications/hact/management/commands/freeze_hact_data.py index 135c9971c..bd023efd4 100644 --- a/src/etools/applications/hact/management/commands/freeze_hact_data.py +++ b/src/etools/applications/hact/management/commands/freeze_hact_data.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from django.core.management import BaseCommand @@ -8,7 +7,6 @@ from etools.applications.hact.models import HactHistory from etools.applications.partners.models import hact_default, PartnerOrganization, PlannedEngagement from etools.applications.users.models import Country -from etools.libraries.pythonlib.encoders import CustomJSONEncoder class Command(BaseCommand): @@ -80,7 +78,7 @@ def freeze_data(self, hact_history): ('Audit Completed', self.get_or_empty(partner_hact, ['audits', 'completed'])), ('Audit Outstanding Findings', self.get_or_empty(partner_hact, ['outstanding_findings', ])), ] - hact_history.partner_values = json.dumps(partner_values, cls=CustomJSONEncoder) + hact_history.partner_values = partner_values hact_history.save() @transaction.atomic diff --git a/src/etools/applications/hact/migrations/0001_initial.py b/src/etools/applications/hact/migrations/0001_initial.py index e55d4e7c3..79367aa7d 100644 --- a/src/etools/applications/hact/migrations/0001_initial.py +++ b/src/etools/applications/hact/migrations/0001_initial.py @@ -1,6 +1,5 @@ # Generated by Django 1.10.8 on 2018-03-26 16:05 -import django.contrib.postgres.fields.jsonb import django.utils.timezone from django.db import migrations, models @@ -27,7 +26,7 @@ class Migration(migrations.Migration): default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('year', models.IntegerField( default=etools.libraries.pythonlib.datetime.get_current_year, unique=True, verbose_name='Year')), - ('partner_values', django.contrib.postgres.fields.jsonb.JSONField( + ('partner_values', models.JSONField(encoder=etools.libraries.pythonlib.encoders.CustomJSONEncoder, blank=True, null=True, verbose_name='Partner Values')), ], options={ @@ -43,7 +42,7 @@ class Migration(migrations.Migration): ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, editable=False, verbose_name='modified')), ('year', models.IntegerField(default=etools.libraries.pythonlib.datetime.get_current_year, verbose_name='Year')), - ('partner_values', django.contrib.postgres.fields.jsonb.JSONField( + ('partner_values', models.JSONField( blank=True, null=True, verbose_name='Partner Values')), ], options={ diff --git a/src/etools/applications/hact/models.py b/src/etools/applications/hact/models.py index d2eb8f57a..15a94eb6e 100644 --- a/src/etools/applications/hact/models.py +++ b/src/etools/applications/hact/models.py @@ -2,7 +2,6 @@ from datetime import date, datetime from decimal import Decimal -from django.contrib.postgres.fields import JSONField from django.db import models from django.db.models import Count, Q, Sum from django.db.models.functions import Coalesce @@ -23,7 +22,7 @@ class HactHistory(TimeStampedModel): on_delete=models.CASCADE, ) year = models.IntegerField(default=get_current_year, verbose_name=_('Year')) - partner_values = JSONField(null=True, blank=True, verbose_name=_('Partner Values')) + partner_values = models.JSONField(null=True, blank=True, verbose_name=_('Partner Values')) class Meta: unique_together = ('partner', 'year') @@ -33,7 +32,7 @@ class Meta: class AggregateHact(TimeStampedModel): year = models.IntegerField(default=get_current_year, unique=True, verbose_name=_('Year')) - partner_values = JSONField(null=True, blank=True, verbose_name=_('Partner Values')) + partner_values = models.JSONField(null=True, blank=True, verbose_name=_('Partner Values'), encoder=CustomJSONEncoder) class Meta: verbose_name_plural = _('Aggregate hact') @@ -42,7 +41,7 @@ def __str__(self): return f'{self.year}' def update(self): - self.partner_values = json.dumps({ + self.partner_values = { 'assurance_activities': self.get_assurance_activities(), 'assurance_coverage': self.get_assurance_coverage(), 'financial_findings': self.get_financial_findings(), @@ -53,7 +52,7 @@ def update(self): 'cash_transfers_partner_type': self.get_cash_transfer_partner_type(), 'spot_checks_completed': self.get_spot_checks_completed(), }, - }, cls=CustomJSONEncoder) + } self.save() @staticmethod @@ -85,17 +84,17 @@ def cash_transfers_amounts(self): '$0-50,000', ct_amount_first.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_NOT_REQUIRED, PartnerOrganization.RATING_NOT_ASSESSED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_first.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_LOW, PartnerOrganization.RATING_LOW_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_first.filter(highest_risk_rating_name=PartnerOrganization.RATING_MEDIUM).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_first.filter(highest_risk_rating_name=PartnerOrganization.RATING_SIGNIFICANT).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_first.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_HIGH, PartnerOrganization.RATING_HIGH_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_first.aggregate(count=Count('total_ct_ytd'))['count'], ] @@ -105,18 +104,18 @@ def cash_transfers_amounts(self): ct_amount_second.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_NOT_REQUIRED, PartnerOrganization.RATING_NOT_ASSESSED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_second.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_LOW, PartnerOrganization.RATING_LOW_RISK_ASSUMED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_second.filter(highest_risk_rating_name=PartnerOrganization.RATING_MEDIUM).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_second.filter(highest_risk_rating_name=PartnerOrganization.RATING_SIGNIFICANT).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_second.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_HIGH, PartnerOrganization.RATING_HIGH_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_second.aggregate(count=Count('total_ct_ytd'))['count'], ] @@ -126,18 +125,18 @@ def cash_transfers_amounts(self): ct_amount_third.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_NOT_REQUIRED, PartnerOrganization.RATING_NOT_ASSESSED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_third.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_LOW, PartnerOrganization.RATING_LOW_RISK_ASSUMED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_third.filter(highest_risk_rating_name=PartnerOrganization.RATING_MEDIUM).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_third.filter(highest_risk_rating_name=PartnerOrganization.RATING_SIGNIFICANT).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_third.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_HIGH, PartnerOrganization.RATING_HIGH_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_third.aggregate(count=Count('total_ct_ytd'))['count'], ] @@ -147,18 +146,18 @@ def cash_transfers_amounts(self): ct_amount_fourth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_NOT_REQUIRED, PartnerOrganization.RATING_NOT_ASSESSED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fourth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_LOW, PartnerOrganization.RATING_LOW_RISK_ASSUMED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fourth.filter(highest_risk_rating_name=PartnerOrganization.RATING_MEDIUM).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fourth.filter(highest_risk_rating_name=PartnerOrganization.RATING_SIGNIFICANT).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fourth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_HIGH, PartnerOrganization.RATING_HIGH_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fourth.aggregate(count=Count('total_ct_ytd'))['count'], ] @@ -168,18 +167,18 @@ def cash_transfers_amounts(self): ct_amount_fifth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_NOT_REQUIRED, PartnerOrganization.RATING_NOT_ASSESSED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fifth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_LOW, PartnerOrganization.RATING_LOW_RISK_ASSUMED ]).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fifth.filter(highest_risk_rating_name__in=PartnerOrganization.RATING_MEDIUM).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fifth.filter(highest_risk_rating_name__in=PartnerOrganization.RATING_SIGNIFICANT).aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fifth.filter(highest_risk_rating_name__in=[ PartnerOrganization.RATING_HIGH, PartnerOrganization.RATING_HIGH_RISK_ASSUMED - ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total'], + ]).aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total'], ct_amount_fifth.aggregate(count=Count('total_ct_ytd'))['count'], ] @@ -288,46 +287,46 @@ def get_financial_findings(): date_of_draft_report_to_ip__year=datetime.now().year).exclude(status=Engagement.CANCELLED) refunds = audits.filter(amount_refunded__isnull=False).aggregate( - total=Coalesce(Sum('amount_refunded'), 0))['total'] + total=Coalesce(Sum('amount_refunded'), Decimal(0.0)))['total'] additional_supporting_document_provided = audits.filter( additional_supporting_documentation_provided__isnull=False, ).aggregate( - total=Coalesce(Sum('additional_supporting_documentation_provided'), 0))['total'] + total=Coalesce(Sum('additional_supporting_documentation_provided'), Decimal(0.0)))['total'] justification_provided_and_accepted = audits.filter( justification_provided_and_accepted__isnull=False).aggregate( - total=Coalesce(Sum('justification_provided_and_accepted'), 0))['total'] + total=Coalesce(Sum('justification_provided_and_accepted'), Decimal(0.0)))['total'] impairment = audits.filter( write_off_required__isnull=False).aggregate( - total=Coalesce(Sum('write_off_required'), 0))['total'] + total=Coalesce(Sum('write_off_required'), Decimal(0.0)))['total'] # pending_unsupported_amount property _ff = audits.filter(financial_findings__isnull=False).aggregate( - total=Coalesce(Sum('financial_findings'), 0))['total'] + total=Coalesce(Sum('financial_findings'), Decimal(0.0)))['total'] _ar = audits.filter(amount_refunded__isnull=False).aggregate( - total=Coalesce(Sum('amount_refunded'), 0))['total'] + total=Coalesce(Sum('amount_refunded'), Decimal(0.0)))['total'] _asdp = audits.filter(additional_supporting_documentation_provided__isnull=False).aggregate( - total=Coalesce(Sum('additional_supporting_documentation_provided'), 0))['total'] + total=Coalesce(Sum('additional_supporting_documentation_provided'), Decimal(0.0)))['total'] _wor = audits.filter(write_off_required__isnull=False).aggregate( - total=Coalesce(Sum('write_off_required'), 0))['total'] + total=Coalesce(Sum('write_off_required'), Decimal(0.0)))['total'] outstanding = _ff - _ar - _asdp - _wor outstanding_audits_y1 = Audit.objects.filter( Q(partner__reported_cy__gt=0) | Q(partner__total_ct_cy__gt=0), date_of_draft_report_to_ip__year=datetime.now().year - 1).exclude(status=Engagement.CANCELLED) _ff_y1 = outstanding_audits_y1.filter(financial_findings__isnull=False).aggregate( - total=Coalesce(Sum('financial_findings'), 0))['total'] + total=Coalesce(Sum('financial_findings'), Decimal(0.0)))['total'] _ar_y1 = outstanding_audits_y1.filter(amount_refunded__isnull=False).aggregate( - total=Coalesce(Sum('amount_refunded'), 0))['total'] + total=Coalesce(Sum('amount_refunded'), Decimal(0.0)))['total'] _asdp_y1 = outstanding_audits_y1.filter(additional_supporting_documentation_provided__isnull=False).aggregate( - total=Coalesce(Sum('additional_supporting_documentation_provided'), 0))['total'] + total=Coalesce(Sum('additional_supporting_documentation_provided'), Decimal(0.0)))['total'] _wor_y1 = outstanding_audits_y1.filter(write_off_required__isnull=False).aggregate( - total=Coalesce(Sum('write_off_required'), 0))['total'] + total=Coalesce(Sum('write_off_required'), Decimal(0.0)))['total'] outstanding_y1 = _ff_y1 - _ar_y1 - _asdp_y1 - _wor_y1 total_financial_findings = audits.filter( - financial_findings__isnull=False).aggregate(total=Coalesce(Sum('financial_findings'), 0))['total'] + financial_findings__isnull=False).aggregate(total=Coalesce(Sum('financial_findings'), Decimal(0.0)))['total'] total_audited_expenditure = audits.filter(audited_expenditure__isnull=False).aggregate( - total=Coalesce(Sum('audited_expenditure'), 0))['total'] + total=Coalesce(Sum('audited_expenditure'), Decimal(0.0)))['total'] return [ { @@ -430,10 +429,10 @@ def get_assurance_coverage(): ], 'coverage_by_cash_transfer': [ ['Coverage by Cash Transfer (USD) (Total)', 'Count'], - ['Without Assurance', no_coverage.aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total']], + ['Without Assurance', no_coverage.aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total']], ['Partially Met Requirements', partial_coverage.aggregate( - total=Coalesce(Sum('total_ct_ytd'), 0))['total']], - ['Met Requirements', full_coverage.aggregate(total=Coalesce(Sum('total_ct_ytd'), 0))['total']], + total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total']], + ['Met Requirements', full_coverage.aggregate(total=Coalesce(Sum('total_ct_ytd'), Decimal(0.0)))['total']], ], 'table': [ diff --git a/src/etools/applications/management/views/gis_v1.py b/src/etools/applications/management/views/gis_v1.py index fc5605d73..8f389772a 100644 --- a/src/etools/applications/management/views/gis_v1.py +++ b/src/etools/applications/management/views/gis_v1.py @@ -103,11 +103,11 @@ def get(self, request): country_id = request.query_params.get('country_id') if loc_status == 'active': - location_queryset = Location.objects + location_queryset = Location.objects.filter(is_active=True) elif loc_status == 'archived': - location_queryset = Location.objects.archived_locations() + location_queryset = Location.objects.filter(is_active=False) else: - location_queryset = Location.objects.all_locations() + location_queryset = Location.objects.all() if not country_id: return Response(status=400, data={'error': 'Country id is required'}) @@ -201,7 +201,7 @@ def get(self, request, id=None, pcode=None): if pcode is not None or id is not None: try: lookup = {'p_code': pcode} if id is None else {'pk': id} - location = Location.objects.all_locations().get(**lookup) + location = Location.objects.get(**lookup) except Location.DoesNotExist: return Response(status=400, data={'error': 'Location not found'}) else: diff --git a/src/etools/applications/partners/admin.py b/src/etools/applications/partners/admin.py index 2daa514fe..4c7b68d2d 100644 --- a/src/etools/applications/partners/admin.py +++ b/src/etools/applications/partners/admin.py @@ -1,6 +1,3 @@ -from functools import update_wrapper - -from django.conf.urls import url from django.contrib import admin from django.contrib.contenttypes.models import ContentType from django.db import models @@ -10,6 +7,8 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from admin_extra_urls.decorators import button +from admin_extra_urls.mixins import ExtraUrlMixin from import_export.admin import ExportMixin from unicef_attachments.admin import AttachmentSingleInline from unicef_attachments.models import Attachment @@ -460,9 +459,8 @@ class CoreValueAssessmentInline(admin.StackedInline): extra = 0 -class PartnerAdmin(ExportMixin, admin.ModelAdmin): +class PartnerAdmin(ExtraUrlMixin, ExportMixin, admin.ModelAdmin): form = PartnersAdminForm - change_form_template = 'admin/partners/partnerorganization/change_form.html' resource_class = PartnerExport search_fields = ( 'name', @@ -583,25 +581,21 @@ def show_partners(self, request, queryset): def has_module_permission(self, request): return request.user.is_superuser or request.user.groups.filter(name='Country Office Administrator').exists() - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - - return update_wrapper(wrapper, view) - - custom_urls = [ - url(r'^(?P\d+)/sync_partner/$', wrap(self.sync_partner), - name='partnerorganization_sync_partner'), - ] - return custom_urls + urls - + @button() def sync_partner(self, request, pk): sync_partner(PartnerOrganization.objects.get(id=pk).vendor_number, request.user.profile.country) return HttpResponseRedirect(reverse('admin:partners_partnerorganization_change', args=[pk])) + @button() + def update_hact(self, request, pk): + obj = self.get_object(request, pk) + obj.planned_visits_to_hact() + obj.programmatic_visits() + obj.spot_checks() + obj.audits_completed() + obj.hact_support() + obj.update_min_requirements() + class PlannedEngagementAdmin(admin.ModelAdmin): model = PlannedEngagement diff --git a/src/etools/applications/partners/migrations/0001_initial.py b/src/etools/applications/partners/migrations/0001_initial.py index 3bd283d4c..07abbebed 100644 --- a/src/etools/applications/partners/migrations/0001_initial.py +++ b/src/etools/applications/partners/migrations/0001_initial.py @@ -181,7 +181,7 @@ class Migration(migrations.Migration): ('contingency_pd', models.BooleanField(default=False, verbose_name='Contingency PD')), ('population_focus', models.CharField(blank=True, max_length=130, null=True, verbose_name='Population Focus')), ('in_amendment', models.BooleanField(default=False, verbose_name='Amendment Open')), - ('metadata', django.contrib.postgres.fields.jsonb.JSONField( + ('metadata', models.JSONField( blank=True, default=dict, null=True, verbose_name='Metadata')), ], options={ @@ -366,8 +366,8 @@ class Migration(migrations.Migration): help_text='Liquidations 1 Oct - 30 Sep', max_digits=12, null=True, verbose_name='Liquidation')), ('total_ct_ytd', models.DecimalField(blank=True, decimal_places=2, help_text='Cash Transfers Jan - Dec', max_digits=12, null=True, verbose_name='Cash Transfer Jan - Dec')), - ('hact_values', django.contrib.postgres.fields.jsonb.JSONField(blank=True, - default=etools.applications.partners.models.hact_default, null=True, verbose_name='HACT')), + ('hact_values', models.JSONField(blank=True, encoder=etools.libraries.pythonlib.encoders.CustomJSONEncoder, + default=etools.applications.partners.models.hact_default, null=True, verbose_name='HACT')), ('basis_for_risk_rating', models.CharField(blank=True, max_length=50, null=True, verbose_name='Basis for Risk Rating')), ], diff --git a/src/etools/applications/partners/migrations/0041_auto_20191209_2039.py b/src/etools/applications/partners/migrations/0041_auto_20191209_2039.py index bbd81d40b..d3f92f0fc 100644 --- a/src/etools/applications/partners/migrations/0041_auto_20191209_2039.py +++ b/src/etools/applications/partners/migrations/0041_auto_20191209_2039.py @@ -1,23 +1,6 @@ # Generated by Django 2.2.8 on 2019-12-09 20:39 -import json -from django.db import connection, migrations - -from etools.libraries.pythonlib.encoders import CustomJSONEncoder - - -def update_partner_hact_json_structure(apps, schema_editor): - - # Only run this when NOT in test - if connection.tenant.schema_name != "test": - PartnerOrganization = apps.get_model("partners", "PartnerOrganization") - for partner in PartnerOrganization.objects.all(): - hact = json.loads(partner.hact_values) if isinstance(partner.hact_values, str) else partner.hact_values - hact['audits']['minimum_requirements'] = None - hact['spot_checks']['minimum_requirements'] = None - hact['programmatic_visits']['minimum_requirements'] = None - partner.hact_values = json.dumps(hact, cls=CustomJSONEncoder) - partner.save() +from django.db import migrations class Migration(migrations.Migration): @@ -27,5 +10,4 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(update_partner_hact_json_structure, migrations.RunPython.noop), ] diff --git a/src/etools/applications/partners/models.py b/src/etools/applications/partners/models.py index e7740a88f..14325c25d 100644 --- a/src/etools/applications/partners/models.py +++ b/src/etools/applications/partners/models.py @@ -3,7 +3,7 @@ import json from django.conf import settings -from django.contrib.postgres.fields import ArrayField, JSONField +from django.contrib.postgres.fields import ArrayField from django.db import connection, IntegrityError, models, transaction from django.db.models import Case, CharField, Count, F, Max, Min, OuterRef, Q, Subquery, Sum, When from django.urls import reverse @@ -498,7 +498,7 @@ class PartnerOrganization(TimeStampedModel): verbose_name=_('Outstanding DCT more than 9 months') ) - hact_values = JSONField(blank=True, null=True, default=hact_default, verbose_name='HACT') + hact_values = models.JSONField(blank=True, null=True, default=hact_default, verbose_name='HACT', encoder=CustomJSONEncoder) basis_for_risk_rating = models.CharField( verbose_name=_("Basis for Risk Rating"), max_length=50, default='', blank=True) psea_assessment_date = models.DateTimeField( @@ -592,6 +592,13 @@ def expiring_assessment_flag(self): return last_assessment_age >= PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR return False + @cached_property + def expiring_psea_assessment_flag(self): + if self.psea_assessment_date: + psea_assessment_age = datetime.date.today().year - self.psea_assessment_date.year + return psea_assessment_age >= PartnerOrganization.EXPIRING_ASSESSMENT_LIMIT_YEAR + return False + @cached_property def approaching_threshold_flag(self): total_ct_ytd = self.total_ct_ytd or 0 @@ -603,7 +610,8 @@ def approaching_threshold_flag(self): def flags(self): return { 'expiring_assessment_flag': self.expiring_assessment_flag, - 'approaching_threshold_flag': self.approaching_threshold_flag + 'approaching_threshold_flag': self.approaching_threshold_flag, + 'expiring_psea_assessment_flag': self.expiring_psea_assessment_flag, } @cached_property @@ -717,7 +725,7 @@ def programmatic_visits(self, event_date=None, update_one=False): :return: all completed programmatic visits """ # Avoid circular imports - from etools.applications.field_monitoring.data_collection.models import ActivityQuestion + from etools.applications.field_monitoring.planning.models import MonitoringActivity, MonitoringActivityGroup hact = self.get_hact_json() @@ -755,22 +763,40 @@ def programmatic_visits(self, event_date=None, update_one=False): tpm_total = tpmv1 + tpmv2 + tpmv3 + tpmv4 + fmvgs = MonitoringActivityGroup.objects.filter( + partner=self, + monitoring_activities__status="completed", + ).annotate( + end_date=Max('monitoring_activities__end_date'), + ).distinct() + fmgv1 = fmvgs.filter(end_date__quarter=1).count() + fmgv2 = fmvgs.filter(end_date__quarter=2).count() + fmgv3 = fmvgs.filter(end_date__quarter=3).count() + fmgv4 = fmvgs.filter(end_date__quarter=4).count() + fmgv_total = fmgv1 + fmgv2 + fmgv3 + fmgv4 + # field monitoring activities qualify as programmatic visits if during a monitoring activity the hact # question was answered with an overall rating and the visit is completed - fmvqs = ActivityQuestion.objects.filter(question__is_hact=True, partner=self, - overall_finding__value__isnull=False, - monitoring_activity__status="completed") - fmvq1 = fmvqs.filter(monitoring_activity__end_date__quarter=1).count() - fmvq2 = fmvqs.filter(monitoring_activity__end_date__quarter=2).count() - fmvq3 = fmvqs.filter(monitoring_activity__end_date__quarter=3).count() - fmvq4 = fmvqs.filter(monitoring_activity__end_date__quarter=4).count() + grouped_activities = MonitoringActivityGroup.objects.filter( + partner=self + ).values_list('monitoring_activities__id', flat=True) + fmvqs = MonitoringActivity.objects.filter( + partners=self, + end_date__year=datetime.datetime.now().year, + ).filter_hact_for_partner(self.id).exclude( + id__in=grouped_activities, + ) + fmvq1 = fmvqs.filter(end_date__quarter=1).count() + fmvq2 = fmvqs.filter(end_date__quarter=2).count() + fmvq3 = fmvqs.filter(end_date__quarter=3).count() + fmvq4 = fmvqs.filter(end_date__quarter=4).count() fmv_total = fmvq1 + fmvq2 + fmvq3 + fmvq4 - hact['programmatic_visits']['completed']['q1'] = pvq1 + tpmv1 + fmvq1 - hact['programmatic_visits']['completed']['q2'] = pvq2 + tpmv2 + fmvq2 - hact['programmatic_visits']['completed']['q3'] = pvq3 + tpmv3 + fmvq3 - hact['programmatic_visits']['completed']['q4'] = pvq4 + tpmv4 + fmvq4 - hact['programmatic_visits']['completed']['total'] = pv + tpm_total + fmv_total + hact['programmatic_visits']['completed']['q1'] = pvq1 + tpmv1 + fmgv1 + fmvq1 + hact['programmatic_visits']['completed']['q2'] = pvq2 + tpmv2 + fmgv2 + fmvq2 + hact['programmatic_visits']['completed']['q3'] = pvq3 + tpmv3 + fmgv3 + fmvq3 + hact['programmatic_visits']['completed']['q4'] = pvq4 + tpmv4 + fmgv4 + fmvq4 + hact['programmatic_visits']['completed']['total'] = pv + tpm_total + fmgv_total + fmv_total self.hact_values = hact self.save() @@ -848,7 +874,7 @@ def hact_support(self): hact['outstanding_findings'] = sum([ audit.pending_unsupported_amount for audit in audits if audit.pending_unsupported_amount]) hact['assurance_coverage'] = self.assurance_coverage - self.hact_values = json.dumps(hact, cls=CustomJSONEncoder) + self.hact_values = hact self.save() def update_min_requirements(self): @@ -859,7 +885,7 @@ def update_min_requirements(self): hact[hact_eng]['minimum_requirements'] = self.hact_min_requirements[hact_eng] updated.append(hact_eng) if updated: - self.hact_values = json.dumps(hact, cls=CustomJSONEncoder) + self.hact_values = hact self.save() return updated @@ -1907,7 +1933,7 @@ class Intervention(TimeStampedModel): # Flag if this has been migrated to a status that is not correct # previous status - metadata = JSONField( + metadata = models.JSONField( verbose_name=_("Metadata"), blank=True, null=True, diff --git a/src/etools/applications/partners/permissions.py b/src/etools/applications/partners/permissions.py index d5382bfe3..7267ef1d5 100644 --- a/src/etools/applications/partners/permissions.py +++ b/src/etools/applications/partners/permissions.py @@ -1,7 +1,7 @@ import datetime +from functools import lru_cache from django.apps import apps -from django.utils.lru_cache import lru_cache from django.utils.translation import gettext as _ from etools_validator.utils import check_rigid_related diff --git a/src/etools/applications/partners/serializers/interventions_v2.py b/src/etools/applications/partners/serializers/interventions_v2.py index 6b79a12a2..e6e02180d 100644 --- a/src/etools/applications/partners/serializers/interventions_v2.py +++ b/src/etools/applications/partners/serializers/interventions_v2.py @@ -272,6 +272,7 @@ class Meta: fields = ( 'id', 'title', + 'number', ) diff --git a/src/etools/applications/partners/serializers/partner_organization_v2.py b/src/etools/applications/partners/serializers/partner_organization_v2.py index 21456c5ab..e3409b109 100644 --- a/src/etools/applications/partners/serializers/partner_organization_v2.py +++ b/src/etools/applications/partners/serializers/partner_organization_v2.py @@ -1,4 +1,5 @@ import datetime +import itertools import json from django.contrib.auth import get_user_model @@ -12,6 +13,7 @@ from unicef_attachments.serializers import AttachmentSerializerMixin from unicef_snapshot.serializers import SnapshotModelSerializer +from etools.applications.field_monitoring.planning.models import MonitoringActivity, MonitoringActivityGroup from etools.applications.partners.models import ( Agreement, Assessment, @@ -388,6 +390,46 @@ def is_valid(self, **kwargs): return super().is_valid(**kwargs) +class MonitoringActivityGroupSerializer(serializers.Field): + default_error_messages = { + 'bad_value': 'List was expected, {type} provided', + } + + def to_internal_value(self, data): + if not data: + return data + + if not isinstance(data, list): + self.fail('bad_value', type=type(data)) + + if not hasattr(self.root, 'instance'): + return [] + + partner = self.root.instance + hact_activities = MonitoringActivity.objects.filter(partners=partner).filter_hact_for_partner(partner.id) + activities = { + activity.id: activity + for activity in hact_activities.filter(id__in=itertools.chain(*data)) + } + + result = [] + for group in data: + result.append([activities[activity] for activity in group if activity in activities]) + result = list(filter(lambda x: x, result)) + + return result + + def to_representation(self, data): + group_objects = list( + self.parent.instance.monitoring_activity_groups.values_list('id', 'monitoring_activities__id') + ) + groups = {group_id: [] for group_id in sorted(set(group[0] for group in group_objects))} + for group in group_objects: + groups[group[0]].append(group[1]) + + return list(groups.values()) + + class PartnerOrganizationDetailSerializer(serializers.ModelSerializer): staff_members = PartnerStaffMemberDetailSerializer(many=True, read_only=True) @@ -404,6 +446,7 @@ class PartnerOrganizationDetailSerializer(serializers.ModelSerializer): sea_risk_rating_name = serializers.CharField(label="psea_risk_rating") highest_risk_rating_type = serializers.CharField(label="highest_risk_type") highest_risk_rating_name = serializers.CharField(label="highest_risk_rating") + monitoring_activity_groups = MonitoringActivityGroupSerializer() def get_hact_values(self, obj): return json.loads(obj.hact_values) if isinstance(obj.hact_values, str) else obj.hact_values @@ -459,6 +502,7 @@ class PartnerOrganizationCreateUpdateSerializer(SnapshotModelSerializer): hidden = serializers.BooleanField(read_only=True) planned_visits = PartnerPlannedVisitsSerializer(many=True, read_only=True, required=False) core_values_assessments = CoreValuesAssessmentSerializer(many=True, read_only=True, required=False) + monitoring_activity_groups = MonitoringActivityGroupSerializer(required=False) def get_hact_values(self, obj): return json.loads(obj.hact_values) if isinstance(obj.hact_values, str) else obj.hact_values @@ -485,6 +529,43 @@ def validate(self, data): return data + def save_monitoring_activity_groups(self, instance, groups): + instance_groups = list(instance.monitoring_activity_groups.prefetch_related('monitoring_activities')) + updated = False + + for i in range(len(groups)): + if i >= len(instance_groups): + group_object = MonitoringActivityGroup.objects.create(partner=instance) + instance_activities = [] + else: + group_object = instance_groups[i] + instance_activities = instance_groups[i].monitoring_activities.all() + + if set(instance_activities).symmetric_difference(set(groups[i])): + updated = True + + group_object.monitoring_activities.set(groups[i]) + + if len(instance_groups) > len(groups): + updated = True + + for i in range(len(groups), len(instance_groups)): + instance_groups[i].delete() + + return updated + + def update(self, instance, validated_data): + monitoring_activity_groups = validated_data.pop('monitoring_activity_groups', None) + + instance = super().update(instance, validated_data) + + if monitoring_activity_groups is not None: + groups_updated = self.save_monitoring_activity_groups(instance, monitoring_activity_groups) + if groups_updated: + instance.programmatic_visits() + + return instance + class Meta: model = PartnerOrganization fields = "__all__" diff --git a/src/etools/applications/partners/tests/test_api_partners.py b/src/etools/applications/partners/tests/test_api_partners.py index 0b784a21d..5c37b643e 100644 --- a/src/etools/applications/partners/tests/test_api_partners.py +++ b/src/etools/applications/partners/tests/test_api_partners.py @@ -14,6 +14,15 @@ from etools.applications.attachments.tests.factories import AttachmentFactory, AttachmentFileTypeFactory from etools.applications.core.tests.cases import BaseTenantTestCase from etools.applications.core.tests.mixins import URLAssertionMixin +from etools.applications.field_monitoring.data_collection.models import ( + ActivityOverallFinding, + ActivityQuestionOverallFinding, +) +from etools.applications.field_monitoring.data_collection.tests.factories import ActivityQuestionFactory +from etools.applications.field_monitoring.planning.tests.factories import ( + MonitoringActivityFactory, + MonitoringActivityGroupFactory, +) from etools.applications.funds.tests.factories import FundsReservationHeaderFactory from etools.applications.partners.models import ( Agreement, @@ -40,6 +49,7 @@ from etools.applications.t2f.tests.factories import TravelActivityFactory from etools.applications.users.models import Country from etools.applications.users.tests.factories import GroupFactory, UserFactory +from etools.libraries.pythonlib.datetime import get_quarter INSIGHT_PATH = "etools.applications.partners.views.partner_organization_v2.get_data_from_insight" @@ -536,6 +546,149 @@ def test_assign_staff_member_to_another_staff(self): response.data['staff_members']['active'], ) + def test_get_partner_monitoring_activity_groups(self): + activity1 = MonitoringActivityFactory(partners=[self.partner]) + activity2 = MonitoringActivityFactory(partners=[self.partner]) + activity3 = MonitoringActivityFactory(partners=[self.partner]) + MonitoringActivityGroupFactory( + partner=self.partner, + monitoring_activities=[activity1, activity2] + ) + MonitoringActivityGroupFactory( + partner=self.partner, + monitoring_activities=[activity3] + ) + response = self.forced_auth_req( + 'get', + self.url, + user=self.unicef_staff + ) + self.assertEqual(response.data['monitoring_activity_groups'], [[activity1.id, activity2.id], [activity3.id]]) + + def _add_hact_finding_for_activity(self, activity): + ActivityQuestionOverallFinding.objects.create( + activity_question=ActivityQuestionFactory( + monitoring_activity=activity, + question__is_hact=True, + question__level='partner', + ), + value=True + ) + ActivityOverallFinding.objects.create( + narrative_finding='ok', + monitoring_activity=activity, + partner=self.partner, + ) + + def test_add_partner_monitoring_activity_groups(self): + today = datetime.date.today() + activity1 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity2 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + self._add_hact_finding_for_activity(activity1) + self._add_hact_finding_for_activity(activity2) + + self.partner.programmatic_visits() + response = self.forced_auth_req('get', self.url, user=self.unicef_staff) + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 2) + + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data={ + 'monitoring_activity_groups': [[activity1.id, activity2.id]], + } + ) + self.assertEqual(len(response.data['monitoring_activity_groups']), 1) + self.assertCountEqual(response.data['monitoring_activity_groups'][0], [activity1.id, activity2.id]) + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 1) + + def test_add_partner_monitoring_activity_into_group(self): + today = datetime.date.today() + activity1 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity2 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity3 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + self._add_hact_finding_for_activity(activity1) + self._add_hact_finding_for_activity(activity2) + self._add_hact_finding_for_activity(activity3) + + MonitoringActivityGroupFactory(partner=self.partner, monitoring_activities=[activity1, activity2]) + + self.partner.programmatic_visits() + response = self.forced_auth_req('get', self.url, user=self.unicef_staff) + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 2) + + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data={ + 'monitoring_activity_groups': [[activity1.id, activity2.id, activity3.id]], + } + ) + self.assertEqual(len(response.data['monitoring_activity_groups']), 1) + self.assertCountEqual(response.data['monitoring_activity_groups'][0], + [activity1.id, activity2.id, activity3.id]) + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 1) + + def test_add_partner_monitoring_activity_groups_not_completed_or_not_hact(self): + activity1 = MonitoringActivityFactory(partners=[self.partner], status='completed') + activity2 = MonitoringActivityFactory(partners=[self.partner], status='report_finalization') + activity3 = MonitoringActivityFactory(partners=[self.partner], status='completed') + self._add_hact_finding_for_activity(activity1) + self._add_hact_finding_for_activity(activity2) + + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data={ + 'monitoring_activity_groups': [[activity1.id, activity2.id, activity3.id]], + } + ) + self.assertEqual(len(response.data['monitoring_activity_groups']), 1) + self.assertEqual(response.data['monitoring_activity_groups'], [[activity1.id]]) + + def test_update_partner_monitoring_activity_groups(self): + today = datetime.date.today() + activity1 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity2 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity3 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + activity4 = MonitoringActivityFactory(partners=[self.partner], end_date=today, status='completed') + self._add_hact_finding_for_activity(activity1) + self._add_hact_finding_for_activity(activity2) + self._add_hact_finding_for_activity(activity3) + self._add_hact_finding_for_activity(activity4) + MonitoringActivityFactory(partners=[self.partner]) + + MonitoringActivityGroupFactory( + partner=self.partner, + monitoring_activities=[activity1, activity2] + ) + MonitoringActivityGroupFactory( + partner=self.partner, + monitoring_activities=[activity3, activity4] + ) + self.partner.programmatic_visits() + # 2 groups + response = self.forced_auth_req('get', self.url, user=self.unicef_staff) + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 2) + + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data={ + 'monitoring_activity_groups': [[activity2.id, activity4.id]], + } + ) + self.assertEqual(len(response.data['monitoring_activity_groups']), 1) + self.assertCountEqual(response.data['monitoring_activity_groups'][0], [activity2.id, activity4.id]) + self.assertEqual(self.partner.monitoring_activity_groups.count(), 1) + self.partner.refresh_from_db() + # 1 group + 2 ungrouped + self.assertEqual(response.data['hact_values']['programmatic_visits']['completed'][get_quarter()], 3) + class TestPartnerOrganizationHactAPIView(BaseTenantTestCase): @classmethod diff --git a/src/etools/applications/partners/tests/test_serializers.py b/src/etools/applications/partners/tests/test_serializers.py index dbf3dcd7f..86462e89a 100644 --- a/src/etools/applications/partners/tests/test_serializers.py +++ b/src/etools/applications/partners/tests/test_serializers.py @@ -706,7 +706,7 @@ def test_retrieve(self): 'vendor_number', 'vision_synced', 'planned_visits', 'manually_blocked', 'flags', 'partner_type_slug', 'outstanding_dct_amount_6_to_9_months_usd', 'outstanding_dct_amount_more_than_9_months_usd', 'psea_assessment_date', 'sea_risk_rating_name', 'highest_risk_rating_name', - 'highest_risk_rating_type', 'lead_office', 'lead_section' + 'highest_risk_rating_type', 'lead_office', 'lead_section', 'monitoring_activity_groups', ]) self.assertCountEqual(data['planned_engagement'].keys(), [ diff --git a/src/etools/applications/partners/tests/test_views.py b/src/etools/applications/partners/tests/test_views.py index ac7f5ad3d..54b55ff9a 100644 --- a/src/etools/applications/partners/tests/test_views.py +++ b/src/etools/applications/partners/tests/test_views.py @@ -371,6 +371,13 @@ def test_values_negative(self): response = self.forced_auth_req('get', self.url, data={"values": "banana"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + @override_settings(UNICEF_USER_EMAIL="@example.com") + def test_switchable_pagination(self): + [PartnerFactory() for _i in range(15)] + response = self.forced_auth_req('get', self.url, data={'page': 1}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 16) + class TestPartnerOrganizationListViewForCSV(BaseTenantTestCase): """Exercise the CSV-generating portion of the list view for PartnerOrganization. @@ -1264,7 +1271,7 @@ def test_intervention_list_minimal(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(list(response.data[0].keys()), ["id", "title"]) + self.assertEqual(list(response.data[0].keys()), ["id", "title", "number"]) def test_intervention_create(self): data = { diff --git a/src/etools/applications/partners/views/partner_organization_v2.py b/src/etools/applications/partners/views/partner_organization_v2.py index 672abe5bd..c4e7e5f4f 100644 --- a/src/etools/applications/partners/views/partner_organization_v2.py +++ b/src/etools/applications/partners/views/partner_organization_v2.py @@ -73,6 +73,7 @@ from etools.applications.partners.synchronizers import PartnerSynchronizer from etools.applications.partners.views.helpers import set_tenant_or_fail from etools.applications.t2f.models import Travel, TravelActivity, TravelType +from etools.applications.utils.pagination import AppendablePageNumberPagination from etools.libraries.djangolib.models import StringConcat from etools.libraries.djangolib.views import ExternalModuleFilterMixin @@ -105,6 +106,7 @@ class PartnerOrganizationListAPIView(ExternalModuleFilterMixin, QueryStringFilte 'tpm': ['activity__tpmactivity__tpm_visit__tpm_partner__staff_members__user', ], 'psea': ['psea_assessment__assessor__auditor_firm_staff__user', 'psea_assessment__assessor__user'] } + pagination_class = AppendablePageNumberPagination def get_serializer_class(self, format=None): """ @@ -473,7 +475,11 @@ def create(self, request, *args, **kwargs): valid_response, response = get_data_from_insight('partners/?vendor={vendor_code}', {"vendor_code": vendor}) - if not valid_response and "ROWSET" not in response: + + if valid_response and "ROWSET" not in response: + return Response({"error": "The vendor number could not be found in Insight"}, status=status.HTTP_400_BAD_REQUEST) + + if not valid_response or "ROWSET" not in response: return Response({"error": response}, status=status.HTTP_400_BAD_REQUEST) partner_resp = response["ROWSET"]["ROW"] diff --git a/src/etools/applications/partners/views/v1.py b/src/etools/applications/partners/views/v1.py index 5e9bfe0df..475f17f13 100644 --- a/src/etools/applications/partners/views/v1.py +++ b/src/etools/applications/partners/views/v1.py @@ -88,7 +88,7 @@ def get_context_data(self, **kwargs): tax_number_5 = response["ROWSET"]["ROW"]['TAX_NUMBER_5'] for b in banks_records: if isinstance(b, dict): - b["BANK_ADDRESS"] = ', '.join(b[key] for key in ['STREET', 'CITY'] if key in b) + b["BANK_ADDRESS"] = ', '.join(b[key] for key in ['STREET', 'CITY'] if key in b and b[key]) b["ACCT_HOLDER"] = b["ACCT_HOLDER"] if "ACCT_HOLDER" in b else "" # TODO: fix currency field name when we have it b["BANK_ACCOUNT_CURRENCY"] = b["BANK_ACCOUNT_CURRENCY"] if "BANK_ACCOUNT_CURRENCY" in b else "" diff --git a/src/etools/applications/psea/admin.py b/src/etools/applications/psea/admin.py index 7e1c67d8a..5b81a7ac4 100644 --- a/src/etools/applications/psea/admin.py +++ b/src/etools/applications/psea/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ +from etools.applications.action_points.admin import ActionPointAdmin from etools.applications.partners.admin import AttachmentSingleInline from etools.applications.psea.models import Answer, Assessment, AssessmentActionPoint, Assessor, Evidence, Indicator @@ -54,11 +55,5 @@ class AssessorAdmin(admin.ModelAdmin): @admin.register(AssessmentActionPoint) -class AssessmentActionPointAdmin(admin.ModelAdmin): - readonly_fields = ['status'] - search_fields = ('author__username', 'assigned_to__username',) - list_display = ( - 'psea_assessment', 'author', 'assigned_to', 'due_date', 'status', - ) - raw_id_fields = ('section', 'office', 'location', 'cp_output', 'partner', 'intervention', 'tpm_activity', - 'psea_assessment', 'travel_activity', 'engagement', 'author', 'assigned_by', 'assigned_to') +class AssessmentActionPointAdmin(ActionPointAdmin): + list_display = ('psea_assessment', ) + ActionPointAdmin.list_display diff --git a/src/etools/applications/publics/migrations/0001_initial.py b/src/etools/applications/publics/migrations/0001_initial.py index 5118c83c8..6beeac5d1 100644 --- a/src/etools/applications/publics/migrations/0001_initial.py +++ b/src/etools/applications/publics/migrations/0001_initial.py @@ -2,7 +2,6 @@ import datetime -import django.contrib.postgres.fields.jsonb import django.db.models.deletion import django.db.models.manager from django.db import migrations, models @@ -133,7 +132,7 @@ class Migration(migrations.Migration): ('status', models.CharField(blank=True, choices=[('uploaded', 'Uploaded'), ('processing', 'Processing'), ( 'failed', 'Failed'), ('done', 'Done')], max_length=64, null=True, verbose_name='Status')), ('upload_date', models.DateTimeField(auto_now_add=True, verbose_name='Upload Date')), - ('errors', django.contrib.postgres.fields.jsonb.JSONField( + ('errors', models.JSONField( blank=True, default=dict, null=True, verbose_name='Errors')), ], ), diff --git a/src/etools/applications/reports/migrations/0009_auto_20180606_1807.py b/src/etools/applications/reports/migrations/0009_auto_20180606_1807.py index c6e6ee91d..81c7ea403 100644 --- a/src/etools/applications/reports/migrations/0009_auto_20180606_1807.py +++ b/src/etools/applications/reports/migrations/0009_auto_20180606_1807.py @@ -1,7 +1,6 @@ # Generated by Django 1.10.8 on 2018-06-06 18:07 from __future__ import unicode_literals -import django.contrib.postgres.fields.jsonb from django.db import migrations, models @@ -15,7 +14,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='appliedindicator', name='baseline_new', - field=django.contrib.postgres.fields.jsonb.JSONField(default={'d': 1, 'v': 0}), + field=models.JSONField(default={'d': 1, 'v': 0}), ), migrations.AddField( model_name='appliedindicator', @@ -40,6 +39,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='appliedindicator', name='target_new', - field=django.contrib.postgres.fields.jsonb.JSONField(default={'d': 1, 'v': 0}), + field=models.JSONField(default={'d': 1, 'v': 0}), ), ] diff --git a/src/etools/applications/reports/migrations/0013_auto_20180709_1348.py b/src/etools/applications/reports/migrations/0013_auto_20180709_1348.py index bead1bbb8..afcca2208 100644 --- a/src/etools/applications/reports/migrations/0013_auto_20180709_1348.py +++ b/src/etools/applications/reports/migrations/0013_auto_20180709_1348.py @@ -1,8 +1,7 @@ # Generated by Django 1.10.8 on 2018-07-09 13:48 from __future__ import unicode_literals -import django.contrib.postgres.fields.jsonb -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -15,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='appliedindicator', name='baseline', - field=django.contrib.postgres.fields.jsonb.JSONField(default={'d': 1, 'v': 0}, null=True), + field=models.JSONField(default={'d': 1, 'v': 0}, null=True), ), ] diff --git a/src/etools/applications/reports/migrations/0014_auto_20181229_0249.py b/src/etools/applications/reports/migrations/0014_auto_20181229_0249.py index c9d428b0a..e456eeadb 100644 --- a/src/etools/applications/reports/migrations/0014_auto_20181229_0249.py +++ b/src/etools/applications/reports/migrations/0014_auto_20181229_0249.py @@ -1,7 +1,6 @@ # Generated by Django 2.0.9 on 2018-12-29 02:49 -import django.contrib.postgres.fields.jsonb -from django.db import migrations +from django.db import migrations, models import etools.applications.reports.models @@ -16,11 +15,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='appliedindicator', name='baseline', - field=django.contrib.postgres.fields.jsonb.JSONField(default=etools.applications.reports.models.indicator_default_dict, null=True), + field=models.JSONField(default=etools.applications.reports.models.indicator_default_dict, null=True), ), migrations.AlterField( model_name='appliedindicator', name='target', - field=django.contrib.postgres.fields.jsonb.JSONField(default=etools.applications.reports.models.indicator_default_dict), + field=models.JSONField(default=etools.applications.reports.models.indicator_default_dict), ), ] diff --git a/src/etools/applications/reports/migrations/0027_auto_20210714_2147.py b/src/etools/applications/reports/migrations/0027_auto_20210714_2147.py new file mode 100644 index 000000000..1aae94aba --- /dev/null +++ b/src/etools/applications/reports/migrations/0027_auto_20210714_2147.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2021-07-14 21:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0026_auto_20210611_1403'), + ] + + operations = [ + migrations.AddField( + model_name='result', + name='programme_area_code', + field=models.CharField(blank=True, max_length=16, null=True, verbose_name='Programme Area Code'), + ), + migrations.AddField( + model_name='result', + name='programme_area_name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Programme Area Name'), + ), + ] diff --git a/src/etools/applications/reports/models.py b/src/etools/applications/reports/models.py index 42d67fb3e..d2ed51277 100644 --- a/src/etools/applications/reports/models.py +++ b/src/etools/applications/reports/models.py @@ -1,6 +1,5 @@ from datetime import date -from django.contrib.postgres.fields import JSONField from django.db import models, transaction from django.utils.functional import cached_property from django.utils.translation import gettext as _ @@ -244,6 +243,18 @@ class Result(MPTTModel): null=True, blank=True, ) + programme_area_code = models.CharField( + verbose_name=_("Programme Area Code"), + max_length=16, + null=True, + blank=True, + ) + programme_area_name = models.CharField( + verbose_name=_("Programme Area Name"), + max_length=255, + null=True, + blank=True, + ) gic_code = models.CharField( verbose_name=_("GIC Code"), max_length=8, @@ -618,8 +629,8 @@ class AppliedIndicator(TimeStampedModel): blank=True, ) - target = JSONField(default=indicator_default_dict) - baseline = JSONField(default=indicator_default_dict, null=True) + target = models.JSONField(default=indicator_default_dict) + baseline = models.JSONField(default=indicator_default_dict, null=True) assumptions = models.TextField( verbose_name=_("Assumptions"), diff --git a/src/etools/applications/reports/synchronizers.py b/src/etools/applications/reports/synchronizers.py index 7d923012b..e46ee725e 100644 --- a/src/etools/applications/reports/synchronizers.py +++ b/src/etools/applications/reports/synchronizers.py @@ -243,6 +243,8 @@ class ProgrammeSynchronizer(VisionDataTenantSynchronizer): ("OUTPUT_END_DATE", "to_date"), ("HUMANITARIAN_MARKER_CODE", "humanitarian_marker_code"), ("HUMANITARIAN_MARKER_NAME", "humanitarian_marker_name"), + ("PROGRAMME_AREA_CODE", "programme_area_code"), + ("PROGRAMME_AREA_NAME", "programme_area_name"), ) ACTIVITY_MAP = ( ("ACTIVITY_WBS", "wbs"), diff --git a/src/etools/applications/reports/tests/test_synchronizers.py b/src/etools/applications/reports/tests/test_synchronizers.py index 1f6bb0e23..0fe6b3967 100644 --- a/src/etools/applications/reports/tests/test_synchronizers.py +++ b/src/etools/applications/reports/tests/test_synchronizers.py @@ -474,6 +474,8 @@ def test_clean_records_outputs(self): self.data["OUTPUT_END_DATE"] = "OP_END" self.data["HUMANITARIAN_MARKER_CODE"] = "HUMANITARIAN_MARKER_CODE" self.data["HUMANITARIAN_MARKER_NAME"] = "HUMANITARIAN_MARKER_NAME" + self.data["PROGRAMME_AREA_CODE"] = "PROGRAMME_AREA_CODE" + self.data["PROGRAMME_AREA_NAME"] = "PROGRAMME_AREA_NAME" records = [self.data] result = self.adapter._clean_records(records) self.assertEqual(result, { @@ -490,6 +492,8 @@ def test_clean_records_outputs(self): "from_date": "OP_START", 'humanitarian_marker_code': 'HUMANITARIAN_MARKER_CODE', 'humanitarian_marker_name': 'HUMANITARIAN_MARKER_NAME', + 'programme_area_code': 'PROGRAMME_AREA_CODE', + 'programme_area_name': 'PROGRAMME_AREA_NAME', "to_date": "OP_END", }}, "activities": {} diff --git a/src/etools/applications/t2f/admin.py b/src/etools/applications/t2f/admin.py index b12ffe1b8..fb3ebc09d 100644 --- a/src/etools/applications/t2f/admin.py +++ b/src/etools/applications/t2f/admin.py @@ -95,3 +95,4 @@ class TravelAttachmentAdmin(AdminListMixin, admin.ModelAdmin): @admin.register(T2FActionPoint) class T2FActionPointAdmin(ActionPointAdmin): form = T2FActionPointAdminForm + list_display = ('travel_activity', ) + ActionPointAdmin.list_display diff --git a/src/etools/applications/t2f/migrations/0001_initial.py b/src/etools/applications/t2f/migrations/0001_initial.py index a1b3d66c8..a3f56b59b 100644 --- a/src/etools/applications/t2f/migrations/0001_initial.py +++ b/src/etools/applications/t2f/migrations/0001_initial.py @@ -154,8 +154,8 @@ class Migration(migrations.Migration): ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='End Date')), ('purpose', models.CharField(blank=True, max_length=500, null=True, verbose_name='Purpose')), ('additional_note', models.TextField(blank=True, null=True, verbose_name='Additional Note')), - ('international_travel', models.NullBooleanField(default=False, verbose_name='International Travel')), - ('ta_required', models.NullBooleanField(default=True, verbose_name='TA Required')), + ('international_travel', models.BooleanField(default=False, verbose_name='International Travel', blank=True, null=True)), + ('ta_required', models.BooleanField(default=True, verbose_name='TA Required', blank=True, null=True)), ('reference_number', models.CharField(default=etools.applications.t2f.models.make_travel_reference_number, max_length=12, unique=True, verbose_name='Reference Number')), ('hidden', models.BooleanField(default=False, verbose_name='Hidden')), diff --git a/src/etools/applications/t2f/models.py b/src/etools/applications/t2f/models.py index 36e27c221..cf46ea0a1 100644 --- a/src/etools/applications/t2f/models.py +++ b/src/etools/applications/t2f/models.py @@ -144,9 +144,9 @@ class Travel(models.Model): end_date = models.DateField(null=True, blank=True, verbose_name=_('End Date')) purpose = models.CharField(max_length=500, default='', blank=True, verbose_name=_('Purpose')) additional_note = models.TextField(default='', blank=True, verbose_name=_('Additional Note')) - international_travel = models.NullBooleanField(default=False, null=True, blank=True, - verbose_name=_('International Travel')) - ta_required = models.NullBooleanField(default=True, null=True, blank=True, verbose_name=_('TA Required')) + international_travel = models.BooleanField(default=False, null=True, blank=True, + verbose_name=_('International Travel')) + ta_required = models.BooleanField(default=True, null=True, blank=True, verbose_name=_('TA Required')) reference_number = models.CharField(max_length=12, default=make_travel_reference_number, unique=True, verbose_name=_('Reference Number')) hidden = models.BooleanField(default=False, verbose_name=_('Hidden')) diff --git a/src/etools/applications/t2f/tests/test_exports.py b/src/etools/applications/t2f/tests/test_exports.py index 171059d1d..217867dcb 100644 --- a/src/etools/applications/t2f/tests/test_exports.py +++ b/src/etools/applications/t2f/tests/test_exports.py @@ -1,9 +1,9 @@ import csv import datetime import logging +from io import StringIO from django.urls import reverse -from django.utils.six import StringIO from pytz import UTC from unicef_locations.tests.factories import LocationFactory @@ -199,7 +199,7 @@ def test_activity_export(self): 'Partnership A1', partnership_A1.number, 'Result A11', - 'Location 345, Location ABC', + 'Location ABC, Location 345', '08-Nov-2017', '14-Nov-2017', '', @@ -262,7 +262,7 @@ def test_activity_export(self): 'Partnership C1', partnership_C1.number, '', - 'Location 111, Location 345', + 'Location 345, Location 111', '08-Nov-2017', '14-Nov-2017', '', diff --git a/src/etools/applications/tpm/admin.py b/src/etools/applications/tpm/admin.py index 337026168..ef6afc6d3 100644 --- a/src/etools/applications/tpm/admin.py +++ b/src/etools/applications/tpm/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from etools.applications.action_points.admin import ActionPointAdmin from etools.applications.tpm import forms, models @@ -66,11 +67,6 @@ def reference_number(self, obj): @admin.register(models.TPMActionPoint) -class TPMActionPointAdmin(admin.ModelAdmin): +class TPMActionPointAdmin(ActionPointAdmin): form = forms.TPMActionPointForm - - readonly_fields = ['status'] - search_fields = ('author__username', 'assigned_to__username',) - list_display = [ - 'author', 'assigned_to', 'tpm_activity', 'due_date', 'status', - ] + list_display = ('tpm_activity', ) + ActionPointAdmin.list_display diff --git a/src/etools/applications/tpm/migrations/0006_auto_20180522_0736.py b/src/etools/applications/tpm/migrations/0006_auto_20180522_0736.py index c7f8b9f49..8fc627c78 100644 --- a/src/etools/applications/tpm/migrations/0006_auto_20180522_0736.py +++ b/src/etools/applications/tpm/migrations/0006_auto_20180522_0736.py @@ -90,8 +90,8 @@ class Migration(migrations.Migration): fields=[ ], options={ - 'verbose_name': 'Engagement Action Point', - 'verbose_name_plural': 'Engagement Action Points', + 'verbose_name': 'TPM Action Point', + 'verbose_name_plural': 'TPM Action Points', 'abstract': False, 'proxy': True, }, diff --git a/src/etools/applications/tpm/models.py b/src/etools/applications/tpm/models.py index c45bbb890..dc0b02e7d 100644 --- a/src/etools/applications/tpm/models.py +++ b/src/etools/applications/tpm/models.py @@ -471,8 +471,8 @@ class TPMActionPoint(ActionPoint): objects = TPMActionPointManager() class Meta(ActionPoint.Meta): - verbose_name = _('Engagement Action Point') - verbose_name_plural = _('Engagement Action Points') + verbose_name = _('TPM Action Point') + verbose_name_plural = _('TPM Action Points') proxy = True def get_mail_context(self, user=None): diff --git a/src/etools/applications/users/admin.py b/src/etools/applications/users/admin.py index bc3a2b3cb..1f056b020 100644 --- a/src/etools/applications/users/admin.py +++ b/src/etools/applications/users/admin.py @@ -1,6 +1,3 @@ -from functools import update_wrapper - -from django.conf.urls import url from django.contrib import admin, messages from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin @@ -9,6 +6,8 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse +from admin_extra_urls.decorators import button +from admin_extra_urls.mixins import ExtraUrlMixin from django_tenants.admin import TenantAdminMixin from django_tenants.utils import get_public_schema_name @@ -175,9 +174,7 @@ def save_model(self, request, obj, form, change): obj.save() -class UserAdminPlus(UserAdmin): - - change_form_template = 'admin/users/user/change_form.html' +class UserAdminPlus(ExtraUrlMixin, UserAdmin): inlines = [ProfileInline] readonly_fields = ('date_joined',) @@ -193,19 +190,7 @@ class UserAdminPlus(UserAdmin): 'country', ] - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - return update_wrapper(wrapper, view) - - custom_urls = [ - url(r'^(?P\d+)/sync_user/$', wrap(self.sync_user), name='users_sync_user'), - ] - return custom_urls + urls - + @button() def sync_user(self, request, pk): user = get_object_or_404(get_user_model(), pk=pk) sync_user.delay(user.username) @@ -252,8 +237,7 @@ def get_readonly_fields(self, request, obj=None): return fields -class CountryAdmin(TenantAdminMixin, admin.ModelAdmin): - change_form_template = 'admin/users/country/change_form.html' +class CountryAdmin(ExtraUrlMixin, TenantAdminMixin, admin.ModelAdmin): def has_add_permission(self, request): return False @@ -273,30 +257,11 @@ def has_add_permission(self, request): 'offices', ) - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - return update_wrapper(wrapper, view) - - custom_urls = [ - url(r'^(?P\d+)/sync_fc/$', wrap(self.sync_fund_commitment), name='users_country_fund_commitment'), - url(r'^(?P\d+)/sync_fr/$', wrap(self.sync_fund_reservation), name='users_country_fund_reservation'), - url(r'^(?P\d+)/sync_delegated_fr/$', wrap(self.sync_fund_reservation_delegated), - name='users_country_fund_reservation_delegated'), - url(r'^(?P\d+)/sync_partners/$', wrap(self.sync_partners), name='users_country_partners'), - url(r'^(?P\d+)/sync_programme/$', wrap(self.sync_programme), name='users_country_programme'), - url(r'^(?P\d+)/sync_ram/$', wrap(self.sync_ram), name='users_country_ram'), - url(r'^(?P\d+)/sync_dct/$', wrap(self.sync_dct), name='users_country_dct'), - url(r'^(?P\d+)/update_hact/$', wrap(self.update_hact), name='users_country_update_hact'), - ] - return custom_urls + urls - + @button() def sync_fund_commitment(self, request, pk): return self.execute_sync(pk, 'fund_commitment', request) + @button() def sync_fund_reservation_delegated(self, request, pk): country = Country.objects.get(pk=pk) if country.schema_name == get_public_schema_name(): @@ -306,18 +271,23 @@ def sync_fund_reservation_delegated(self, request, pk): messages.info(request, "Task fund reservation delegated scheduled") return HttpResponseRedirect(reverse('admin:users_country_change', args=[country.pk])) + @button() def sync_fund_reservation(self, request, pk): return self.execute_sync(pk, 'fund_reservation', request) + @button() def sync_partners(self, request, pk): return self.execute_sync(pk, 'partner', request) + @button() def sync_programme(self, request, pk): return self.execute_sync(pk, 'programme', request) + @button() def sync_ram(self, request, pk): return self.execute_sync(pk, 'ram', request) + @button() def sync_dct(self, request, pk): return self.execute_sync(pk, 'dct', request) @@ -331,6 +301,7 @@ def execute_sync(country_pk, synchronizer, request): messages.info(request, f"Task {synchronizer} scheduled") return HttpResponseRedirect(reverse('admin:users_country_change', args=[country.pk])) + @button() def update_hact(self, request, pk): country = Country.objects.get(pk=pk) if country.schema_name == get_public_schema_name(): diff --git a/src/etools/applications/users/migrations/0016_country_custom_dashboards.py b/src/etools/applications/users/migrations/0016_country_custom_dashboards.py new file mode 100644 index 000000000..636a2ff93 --- /dev/null +++ b/src/etools/applications/users/migrations/0016_country_custom_dashboards.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.24 on 2021-07-14 21:24 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import etools.applications.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_auto_20200924_1453'), + ] + + operations = [ + migrations.AddField( + model_name='country', + name='custom_dashboards', + field=models.JSONField(default=etools.applications.users.models.custom_dashboards_default, verbose_name='Custom Dashboards'), + ), + ] diff --git a/src/etools/applications/users/models.py b/src/etools/applications/users/models.py index d69033b88..7409e5eaf 100644 --- a/src/etools/applications/users/models.py +++ b/src/etools/applications/users/models.py @@ -105,6 +105,10 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +def custom_dashboards_default(): + return dict(bi_url='') + + class Country(TenantMixin): """ Tenant Schema @@ -149,6 +153,7 @@ class Country(TenantMixin): # TODO: rename the related name as it's inappropriate for relating offices to countries.. should be office_countries offices = models.ManyToManyField('Office', related_name='offices', verbose_name=_('Offices'), blank=True) + custom_dashboards = models.JSONField(verbose_name=_('Custom Dashboards'), default=custom_dashboards_default) def __str__(self): return self.name diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 463a63c65..9ef04a6cc 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -54,9 +54,16 @@ class Meta: 'longitude', 'initial_zoom', 'local_currency', + 'custom_dashboards', ) +class DashboardCountrySerializer(CountrySerializer): + + class Meta(CountrySerializer.Meta): + fields = CountrySerializer.Meta.fields + ('custom_dashboards', ) + + class CountryDetailSerializer(serializers.ModelSerializer): local_currency = serializers.CharField(source='local_currency.name', read_only=True) local_currency_id = serializers.IntegerField(source='local_currency.id', read_only=True) @@ -93,7 +100,7 @@ class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): email = serializers.CharField(source='user.email', read_only=True) is_staff = serializers.CharField(source='user.is_staff', read_only=True) is_active = serializers.CharField(source='user.is_active', read_only=True) - country = CountrySerializer(read_only=True) + country = DashboardCountrySerializer(read_only=True) show_ap = serializers.SerializerMethodField() is_unicef_user = serializers.SerializerMethodField() diff --git a/src/etools/applications/users/templates/admin/users/country/change_form.html b/src/etools/applications/users/templates/admin/users/country/change_form.html deleted file mode 100644 index 7494503cf..000000000 --- a/src/etools/applications/users/templates/admin/users/country/change_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "admin/django_tenants/tenant/change_form.html" %} -{% load i18n etools %} -{% block object-tools-items %} - {{ block.super }} -
  • {% trans "Sync Partners" %}
  • -
  • {% trans "Sync Programme" %}
  • -
  • {% trans "Sync RAM" %}
  • -
  • {% trans "Sync Fund Reservation" %}
  • -
  • {% trans "Sync Delegated Fund Reservations" %}
  • -
  • {% trans "Sync Fund Commitment" %}
  • -
  • {% trans "Sync DCT" %}
  • -
  • {% trans "Update HACT" %}
  • - -{% endblock object-tools-items %} diff --git a/src/etools/applications/users/templates/admin/users/user/change_form.html b/src/etools/applications/users/templates/admin/users/user/change_form.html deleted file mode 100644 index e6d3d778b..000000000 --- a/src/etools/applications/users/templates/admin/users/user/change_form.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "admin/change_form.html" %}{% load i18n %} -{% block object-tools-items %} - {{ block.super }} -
  • {% trans "Sync User" %}
  • - {% if request.user.is_superuser %} -
  • {% trans "AD Info" %}
  • - {% endif %} -{% endblock object-tools-items %} \ No newline at end of file diff --git a/src/etools/applications/users/tests/test_admin.py b/src/etools/applications/users/tests/test_admin.py index 47eeba86c..929a50085 100644 --- a/src/etools/applications/users/tests/test_admin.py +++ b/src/etools/applications/users/tests/test_admin.py @@ -153,7 +153,7 @@ def test_update_hact_button_on_change_page(self): country = Country.objects.exclude(schema_name='public').first() url = reverse('admin:users_country_change', args=[country.pk]) response = self.client.get(url) - self.assertContains(response, text=">Update HACT<", msg_prefix=response.content.decode('utf-8')) + self.assertContains(response, text=">Update Hact<", msg_prefix=response.content.decode('utf-8')) self.assertTemplateUsed('admin/users/country/change_form.html') def test_update_hact_action_nonpublic_country(self): diff --git a/src/etools/applications/users/tests/test_views_v3.py b/src/etools/applications/users/tests/test_views_v3.py index b8ce27225..cecbbf1cc 100644 --- a/src/etools/applications/users/tests/test_views_v3.py +++ b/src/etools/applications/users/tests/test_views_v3.py @@ -86,6 +86,26 @@ def test_api_users_list(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 2) + def test_forced_pagination(self): + [UserFactory(is_staff=True) for _i in range(15)] + response = self.forced_auth_req('get', self.url, user=self.unicef_staff, data={'page': 1}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 17) + + def test_forced_pagination_custom_page_size(self): + [UserFactory(is_staff=True) for _i in range(15)] + response = self.forced_auth_req('get', self.url, user=self.unicef_staff, data={'page': 1, 'page_size': 5}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 5) + + def test_search(self): + UserFactory(is_staff=True, email='test_user_email@example.com') + UserFactory(is_staff=True, email='test_user@example.com') + response = self.forced_auth_req('get', self.url, user=self.unicef_staff, data={'search': 'test_user_email'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['email'], 'test_user_email@example.com') + def test_users_api_list_values(self): response = self.forced_auth_req( 'get', diff --git a/src/etools/applications/users/views_v3.py b/src/etools/applications/users/views_v3.py index 0d4c25449..1999a84b1 100644 --- a/src/etools/applications/users/views_v3.py +++ b/src/etools/applications/users/views_v3.py @@ -19,6 +19,7 @@ MinimalUserSerializer, ProfileRetrieveUpdateSerializer, ) +from etools.applications.utils.pagination import AppendablePageNumberPagination logger = logging.getLogger(__name__) @@ -62,9 +63,11 @@ class UsersListAPIView(QueryStringFilterMixin, ListAPIView): Country is determined by the currently logged in user. """ model = get_user_model() - queryset = get_user_model().objects.all() + queryset = get_user_model().objects.all().select_related('profile') serializer_class = MinimalUserSerializer permission_classes = (IsAdminUser, ) + pagination_class = AppendablePageNumberPagination + search_terms = ('email__icontains', 'first_name__icontains', 'middle_name__icontains', 'last_name__icontains') filters = ( ('group', 'groups__name__in'), diff --git a/src/etools/applications/utils/pagination.py b/src/etools/applications/utils/pagination.py new file mode 100644 index 000000000..055b91474 --- /dev/null +++ b/src/etools/applications/utils/pagination.py @@ -0,0 +1,15 @@ +from rest_framework.pagination import PageNumberPagination + + +class AppendablePageNumberPagination(PageNumberPagination): + """ + Don't use pagination by default (if page parameter is not presented), but allow it to be enabled if required + """ + page_size = 30 + page_size_query_param = 'page_size' + max_page_size = 1000 + + def get_page_size(self, request): + if self.page_query_param in request.query_params: + return super().get_page_size(request) + return None diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index 9b9db7d70..6f35f5f42 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -184,6 +184,7 @@ def get_from_secrets_or_env(var_name, default=None): 'easy_pdf', 'ordered_model', 'social_django', + 'admin_extra_urls', 'etools.applications.vision', 'etools.applications.publics', 'etools.applications.users', @@ -287,6 +288,8 @@ def get_from_secrets_or_env(var_name, default=None): LOGIN_URL = LOGOUT_REDIRECT_URL = get_from_secrets_or_env('LOGIN_URL', '/landing/') +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + # CONTRIB: GIS (GeoDjango) POSTGIS_VERSION = (2, 1) @@ -410,38 +413,32 @@ def get_from_secrets_or_env(var_name, default=None): # https://django-tenant-schemas.readthedocs.io/en/latest/use.html#performance-considerations TENANT_LIMIT_SET_CALLS = True -# django-rest-framework-jwt: http://getblimp.github.io/django-rest-framework-jwt/ -JWT_AUTH = { - 'JWT_ENCODE_HANDLER': - 'rest_framework_jwt.utils.jwt_encode_handler', - - 'JWT_DECODE_HANDLER': - 'rest_framework_jwt.utils.jwt_decode_handler', - - 'JWT_PAYLOAD_HANDLER': - 'rest_framework_jwt.utils.jwt_payload_handler', - - 'JWT_PAYLOAD_GET_USER_ID_HANDLER': - 'rest_framework_jwt.utils.jwt_get_user_id_from_payload_handler', - - 'JWT_PAYLOAD_GET_USERNAME_HANDLER': - 'rest_framework_jwt.utils.jwt_get_username_from_payload_handler', - - 'JWT_RESPONSE_PAYLOAD_HANDLER': - 'rest_framework_jwt.utils.jwt_response_payload_handler', - - 'JWT_ALGORITHM': 'HS256', - 'JWT_VERIFY': True, - 'JWT_VERIFY_EXPIRATION': True, - 'JWT_LEEWAY': 30, - 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=30000), - 'JWT_AUDIENCE': None, - 'JWT_ISSUER': None, - - 'JWT_ALLOW_REFRESH': False, - 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), - - 'JWT_AUTH_HEADER_PREFIX': 'JWT', +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': datetime.timedelta(minutes=480), + 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': SECRET_KEY, + 'AUDIENCE': None, + 'ISSUER': None, + 'LEEWAY': 60, + + 'AUTH_HEADER_TYPES': ('JWT',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'pk', + 'USER_ID_CLAIM': 'user_id', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_jwt_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + 'JTI_CLAIM': 'jti', + + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': datetime.timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': datetime.timedelta(days=1), } SENTRY_DSN = get_from_secrets_or_env('SENTRY_DSN') # noqa: F405 @@ -516,7 +513,6 @@ def before_send(event, hint): # In case we decide to whitelist: # SOCIAL_AUTH_WHITELISTED_DOMAINS = ['unicef.org', 'google.com', 'ravdev.com'] LOGIN_ERROR_URL = "/workspace_inactive" -JWT_LEEWAY = 1000 SOCIAL_PASSWORD_RESET_POLICY = os.getenv('AZURE_B2C_PASS_RESET_POLICY', "B2C_1_PasswordResetPolicy") SOCIAL_AUTH_PIPELINE = ( @@ -557,7 +553,7 @@ def before_send(event, hint): ATTACHMENT_PERMISSIONS = "etools.applications.attachments.permissions.IsInSchema" GEOS_LIBRARY_PATH = os.getenv('GEOS_LIBRARY_PATH', '/usr/lib/libgeos_c.so.1') # default path -GDAL_LIBRARY_PATH = os.getenv('GDAL_LIBRARY_PATH', '/usr/lib/libgdal.so.26') # default path +GDAL_LIBRARY_PATH = os.getenv('GDAL_LIBRARY_PATH', '/usr/lib/libgdal.so.28') # default path SHELL_PLUS_PRE_IMPORTS = ( ('etools.applications.core.util_scripts', '*'), diff --git a/src/etools/config/settings/production.py b/src/etools/config/settings/production.py index 91f738426..6fb0d493e 100644 --- a/src/etools/config/settings/production.py +++ b/src/etools/config/settings/production.py @@ -62,13 +62,9 @@ certificate = load_pem_x509_certificate(public_key_bytes, default_backend()) JWT_PUBLIC_KEY = certificate.public_key() - JWT_AUTH.update({ # noqa: F405 - 'JWT_SECRET_KEY': SECRET_KEY, - 'JWT_PUBLIC_KEY': JWT_PUBLIC_KEY, - 'JWT_PRIVATE_KEY': JWT_PRIVATE_KEY, - 'JWT_ALGORITHM': 'RS256', - 'JWT_LEEWAY': 60, - 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=3000), # noqa: F405 - 'JWT_AUDIENCE': 'https://etools.unicef.org/', - 'JWT_PAYLOAD_HANDLER': 'etools.applications.core.auth.custom_jwt_payload_handler' + SIMPLE_JWT.update({ # noqa: F405 + 'SIGNING_KEY': JWT_PRIVATE_KEY, + 'VERIFYING_KEY': JWT_PUBLIC_KEY, + 'AUDIENCE': 'https://etools.unicef.org/', + 'ALGORITHM': 'RS256', }) diff --git a/src/etools/config/urls.py b/src/etools/config/urls.py index b3ce3e6ea..57345ce86 100644 --- a/src/etools/config/urls.py +++ b/src/etools/config/urls.py @@ -3,7 +3,6 @@ from django.contrib import admin from django.views.generic import TemplateView -import rest_framework_jwt.views from rest_framework_nested import routers from rest_framework_swagger.renderers import OpenAPIRenderer @@ -112,10 +111,6 @@ url(r'^api/schema/openapi', schema_view_json_openapi), url(r'^admin/', admin.site.urls), - # helper urls - url(r'^login/token-auth/', rest_framework_jwt.views.obtain_jwt_token), - # TODO: remove this when eTrips is deployed needed - url(r'^api-token-auth/', rest_framework_jwt.views.obtain_jwt_token), url(r'^workspace_inactive/$', TemplateView.as_view(template_name='removed_workspace.html'), name='workspace-inactive'), diff --git a/src/etools/libraries/djangolib/tests/utils.py b/src/etools/libraries/djangolib/tests/utils.py index df21a53d3..5e6ce65f9 100644 --- a/src/etools/libraries/djangolib/tests/utils.py +++ b/src/etools/libraries/djangolib/tests/utils.py @@ -13,6 +13,6 @@ def _test_export(self, user, url_name, args=tuple(), kwargs=None, status_code=st self.assertEqual(response.status_code, status_code) if status_code == status.HTTP_200_OK: - self.assertIn(response._headers['content-disposition'][0], 'Content-Disposition') + self.assertIn('Content-Disposition', response.headers) return response diff --git a/src/etools/libraries/locations/admin.py b/src/etools/libraries/locations/admin.py index 5f79f9f26..7d9acf164 100644 --- a/src/etools/libraries/locations/admin.py +++ b/src/etools/libraries/locations/admin.py @@ -1,48 +1,24 @@ +from django.contrib import messages from django.contrib.gis import admin -from django.db import transaction +from admin_extra_urls.decorators import button from celery import chain from unicef_locations.admin import CartoDBTableAdmin -from unicef_locations.models import CartoDBTable, Location, LocationRemapHistory +from unicef_locations.models import CartoDBTable, LocationRemapHistory -from etools.libraries.locations.tasks import ( - cleanup_obsolete_locations, - notify_import_site_completed, - update_sites_from_cartodb, - validate_locations_in_use, -) +from etools.libraries.locations.tasks import import_locations, notify_import_site_completed class EtoolsCartoDBTableAdmin(CartoDBTableAdmin): - def import_sites(self, request, queryset): - # ensure the location tree is valid before we import/update the data - with transaction.atomic(): - Location.objects.all_locations().select_for_update().only('id') - Location.objects.rebuild() + @button(css_class="btn-warning auto-disable") + def import_sites(self, request, pk): + chain([ + import_locations.si(pk), + notify_import_site_completed.si(pk, request.user.pk) + ]).delay() - task_list = [] - - # import locations from top to bottom - queryset = sorted(queryset, key=lambda l: (l.tree_id, l.lft, l.pk)) - carto_tables = [qry.pk for qry in queryset] - - for table in carto_tables: - task_list += [ - validate_locations_in_use.si(table), - update_sites_from_cartodb.si(table), - ] - - # clean up locations from bottom to top, it's easier to validate parents this way - for table in reversed(carto_tables): - task_list.extend([ - cleanup_obsolete_locations.si(table), - notify_import_site_completed.si(table, request.user.pk) - ]) - - if task_list: - # Trying to force the tasks to execute in correct sequence - chain(task_list).delay() + messages.info(request, 'Import Scheduled') class RemapAdmin(admin.ModelAdmin): diff --git a/src/etools/libraries/locations/exceptions.py b/src/etools/libraries/locations/exceptions.py new file mode 100644 index 000000000..8c4e3f826 --- /dev/null +++ b/src/etools/libraries/locations/exceptions.py @@ -0,0 +1,5 @@ +from carto.exceptions import CartoException + + +class InvalidRemap(CartoException): + pass diff --git a/src/etools/libraries/locations/task_utils.py b/src/etools/libraries/locations/task_utils.py deleted file mode 100644 index 898b13eee..000000000 --- a/src/etools/libraries/locations/task_utils.py +++ /dev/null @@ -1,476 +0,0 @@ -import time -from collections import defaultdict - -from django.db import IntegrityError -from django.db.models import Count - -from carto.exceptions import CartoException -from celery.utils.log import get_task_logger -from unicef_locations.models import Location, LocationRemapHistory - -from etools.applications.action_points.models import ActionPoint -from etools.applications.activities.models import Activity -from etools.applications.partners.models import Intervention -from etools.applications.reports.models import AppliedIndicator -from etools.applications.t2f.models import TravelActivity - -logger = get_task_logger(__name__) - - -def get_cartodb_locations(sql_client, carto_table): - - rows = [] - cartodb_id_col = 'cartodb_id' - try: - query_row_count = sql_client.send('select count(*) from {}'.format(carto_table.table_name)) - row_count = query_row_count['rows'][0]['count'] - - # do not spam Carto with requests, wait 1 second - time.sleep(1) - query_max_id = sql_client.send('select MAX({}) from {}'.format(cartodb_id_col, carto_table.table_name)) - max_id = query_max_id['rows'][0]['max'] - except CartoException: # pragma: no-cover - logger.exception("Cannot fetch pagination prequisites from CartoDB for table {}".format( - carto_table.table_name - )) - return False, [] - - offset = 0 - limit = 100 - - # failsafe in the case when cartodb id's are too much off compared to the nr. of records - if max_id > (5 * row_count): - limit = max_id + 1 - logger.warning("The CartoDB primary key seemf off, pagination is not possible") - - if carto_table.parent_code_col and carto_table.parent: - qry = 'select st_AsGeoJSON(the_geom) as the_geom, {}, {}, {} from {}'.format( - carto_table.name_col, - carto_table.pcode_col, - carto_table.parent_code_col, - carto_table.table_name) - else: - qry = 'select st_AsGeoJSON(the_geom) as the_geom, {}, {} from {}'.format( - carto_table.name_col, - carto_table.pcode_col, - carto_table.table_name) - - while offset <= max_id: - paged_qry = qry + ' WHERE {} > {} AND {} <= {}'.format( - cartodb_id_col, - offset, - cartodb_id_col, - offset + limit - ) - logger.info('Requesting rows between {} and {} for {}'.format( - offset, - offset + limit, - carto_table.table_name - )) - - # do not spam Carto with requests, wait 1 second - time.sleep(1) - try: - sites = sql_client.send(paged_qry) - except CartoException: # pragma: no-cover - logger.exception("CartoDB API pagination failed at offset: {}".format(offset)) - retried_row = retry_failed_query(sql_client, paged_qry, offset) - if retried_row: - rows += retried_row - offset += limit - else: - # can not continue if we have missing pages.. - return False, [] - else: - if 'error' in sites: - # it seems we can have both valid results and error messages in the same CartoDB response - # When this occurs, we receive truncated locations, interrupt the import due to incomplete data - logger.exception("CartoDB API error received: {}".format(sites['error'])) - return False, [] - else: - rows += sites['rows'] - offset += limit - - return True, rows - - -def retry_failed_query(sql_client, failed_query, offset): - """ - Retry a timed-out CartoDB query - :param sql_client: - :param failed_query: - :param offset: - :return: - """ - - retries = 0 - logger.warning('Retrying table page at offset {}'.format(offset)) - while retries < 5: - time.sleep(1) - retries += 1 - try: - sites = sql_client.send(failed_query) - except CartoException: - if retries < 5: - logger.warning('Retrying again table page at offset {}'.format(offset)) - else: - if 'error' in sites: - return False - else: - return sites['rows'] - return False - - -def validate_remap_table(database_pcodes, new_carto_pcodes, carto_table, sql_client): # pragma: no-cover - remapped_pcode_pairs = [] - remap_old_pcodes = [] - remap_new_pcodes = [] - remap_table_valid = True - - if carto_table.remap_table_name: - try: - remap_qry = 'select old_pcode::text, new_pcode::text from {}'.format( - carto_table.remap_table_name) - remapped_pcode_pairs = sql_client.send(remap_qry)['rows'] - except CartoException: # pragma: no-cover - logger.exception("CartoDB exception occured on the remap table query") - remap_table_valid = False - else: - # validate remap table - bad_old_pcodes = [] - bad_new_pcodes = [] - for remap_row in remapped_pcode_pairs: - if 'old_pcode' not in remap_row or 'new_pcode' not in remap_row: - return False, remapped_pcode_pairs, remap_old_pcodes, remap_new_pcodes - - remap_old_pcodes.append(remap_row['old_pcode']) - remap_new_pcodes.append(remap_row['new_pcode']) - - # check for non-existing remap pcodes in the database - if remap_row['old_pcode'] not in database_pcodes: - bad_old_pcodes.append(remap_row['old_pcode']) - # check for non-existing remap pcodes in the Carto dataset - if remap_row['new_pcode'] not in new_carto_pcodes: - bad_new_pcodes.append(remap_row['new_pcode']) - - if len(bad_old_pcodes) > 0: - logger.exception( - "Invalid old_pcode found in the remap table: {}".format(','.join(bad_old_pcodes))) - remap_table_valid = False - - if len(bad_new_pcodes) > 0: - logger.exception( - "Invalid new_pcode found in the remap table: {}".format(','.join(bad_new_pcodes))) - remap_table_valid = False - - return remap_table_valid, remapped_pcode_pairs, remap_old_pcodes, remap_new_pcodes - - -def duplicate_pcodes_exist(database_pcodes, new_carto_pcodes, remap_old_pcodes, remap_new_pcodes): # pragma: no-cover - duplicates_found = False - temp = {} - duplicate_database_pcodes = [] - for database_pcode in database_pcodes: - if database_pcode in temp: - duplicate_database_pcodes.append(database_pcode) - temp[database_pcode] = 1 - - if duplicate_database_pcodes: - logger.exception("Duplicates found in the existing database pcodes: {}". - format(','.join(duplicate_database_pcodes))) - duplicates_found = True - - temp = {} - duplicate_carto_pcodes = [] - for new_carto_pcode in new_carto_pcodes: - if new_carto_pcode in temp: - duplicate_carto_pcodes.append(new_carto_pcode) - temp[new_carto_pcode] = 1 - - if duplicate_carto_pcodes: - logger.exception("Duplicates found in the new CartoDB pcodes: {}". - format(','.join(duplicate_database_pcodes))) - duplicates_found = True - - temp = {} - duplicate_remap_old_pcodes = [] - for remap_old_pcode in remap_old_pcodes: - if remap_old_pcode in temp: - duplicate_remap_old_pcodes.append(remap_old_pcode) - temp[remap_old_pcode] = 1 - - if duplicate_remap_old_pcodes: - logger.exception("Duplicates found in the remap table `old pcode` column: {}". - format(','.join(duplicate_remap_old_pcodes))) - duplicates_found = True - - if len(remap_new_pcodes) != len(set(remap_new_pcodes)): - logger.info("Duplicates found in the remap table new pcodes(at this point not an issue)") - - return duplicates_found - - -def get_location_ids_in_use(location_ids): - """ - :param location_ids: list of location ids to check - :return location_ids_in_use: the input reduced to locations in use - """ - location_ids_in_use = [] - - for intervention in Intervention.objects.all(): - location_ids_in_use += [loc.id for loc in intervention.flat_locations.filter(id__in=location_ids)] - - for indicator in AppliedIndicator.objects.all(): - location_ids_in_use += [loc.id for loc in indicator.locations.filter(id__in=location_ids)] - - for travelactivity in TravelActivity.objects.all(): - location_ids_in_use += [loc.id for loc in travelactivity.locations.filter(id__in=location_ids)] - - for activity in Activity.objects.all(): - location_ids_in_use += [loc.id for loc in activity.locations.filter(id__in=location_ids)] - - location_ids_in_use += [a.location_id for a in ActionPoint.objects.filter(location__in=location_ids).distinct()] - - return list(set(location_ids_in_use)) - - -def filter_remapped_locations_cb(remap_table_row): - """ - :param remap_table_row contains old_pcode, new_pcode - :return: true|false - """ - old_location_id = Location.objects.all_locations().get(p_code=remap_table_row['old_pcode']).id - return len(get_location_ids_in_use([old_location_id])) > 0 - - -def create_location(pcode, carto_table, parent, parent_instance, site_name, row, - sites_not_added, sites_created, sites_updated): - """ - :param pcode: pcode of the new/updated location - :param carto_table: - :param parent: - :param parent_instance: - :param site_name: - :param row: the new location data as received from CartoDB - :param sites_not_added: - :param sites_created: - :param sites_updated: - :return: - """ - - logger.info('{}: {} ({})'.format( - 'Importing location', - pcode, - carto_table.location_type.name - )) - - location = None - try: - # TODO: revisit this, maybe include (location name?) carto_table in the check - # see below at update branch - names can be updated for existing locations with the same code - location = Location.objects.get(p_code=pcode) - - except Location.MultipleObjectsReturned: - logger.warning("Multiple locations found for: {}, {} ({})".format( - carto_table.location_type, site_name, pcode - )) - sites_not_added += 1 - return False, sites_not_added, sites_created, sites_updated - - except Location.DoesNotExist: - pass - - if not location: - # try to create the location - create_args = { - 'p_code': pcode, - 'gateway': carto_table.location_type, - 'name': site_name - } - if parent and parent_instance: - create_args['parent'] = parent_instance - - if not row['the_geom']: - sites_not_added += 1 - return False, sites_not_added, sites_created, sites_updated - - if 'Point' in row['the_geom']: - create_args['point'] = row['the_geom'] - else: - create_args['geom'] = row['the_geom'] - - try: - location = Location.objects.create(**create_args) - sites_created += 1 - except IntegrityError: - logger.exception('Error while creating location: %s', site_name) - sites_not_added += 1 - return False, sites_not_added, sites_created, sites_updated - - logger.info('{}: {} ({})'.format( - 'Added', - location.name, - carto_table.location_type.name - )) - - return True, sites_not_added, sites_created, sites_updated - - else: - if not row['the_geom']: - return False, sites_not_added, sites_created, sites_updated - - # names can be updated for existing locations with the same code - location.name = site_name - # TODO: re-confirm if this is not a problem. (assuming that every row in the new data is active) - location.is_active = True - - if 'Point' in row['the_geom']: - location.point = row['the_geom'] - else: - location.geom = row['the_geom'] - - if parent and parent_instance: - logger.info("Updating parent:{} for location {}".format(parent_instance, location)) - location.parent = parent_instance - else: - location.parent = None - - try: - location.save() - except IntegrityError: - logger.exception('Error while saving location: %s', site_name) - return False, sites_not_added, sites_created, sites_updated - - sites_updated += 1 - logger.info('{}: {} ({})'.format( - 'Updated', - location.name, - carto_table.location_type.name - )) - - return True, sites_not_added, sites_created, sites_updated - - -def remap_location(carto_table, new_pcode, remapped_pcodes): - """ - :param carto_table: - :param new_pcode: pcode the others will be remapped to - :param remapped_pcodes: pcodes to be remapped and archived/removed - - :return: [(new_location.id, remapped_location.id), ...] - """ - - remapped_locations_qs = Location.objects.all_locations().filter(p_code__in=list(remapped_pcodes)) - if not remapped_locations_qs.exists(): - logger.info('Remapped pcodes: [{}] cannot be found in the database!'.format(",".join(remapped_pcodes))) - return - - logger.info('Preparing to remap : [{}] to {}'.format(",".join(remapped_pcodes), new_pcode)) - - try: - new_location = Location.objects.all_locations().get(p_code=new_pcode) - # the approach below is not good - remap should work across location levels, and probably for archived locs too - # new_location = Location.objects.get(p_code=new_pcode, gateway=carto_table.location_type) - except Location.MultipleObjectsReturned: - logger.warning("REMAP: multiple locations found for new pcode: {} ({})".format( - new_pcode, carto_table.location_type - )) - return None - except Location.DoesNotExist: - # if the remap destination location does not exist in the DB, we have to create it. - # the `name` and `parent` will be updated in the next step of the update process. - create_args = { - 'p_code': new_pcode, - 'gateway': carto_table.location_type, - 'name': new_pcode # the name is temporary - } - new_location = Location.objects.create(**create_args) - - results = [] - for remapped_location in remapped_locations_qs: - remapped_location.is_active = False - remapped_location.save() - - logger.info('Prepared to remap {} to {} ({})'.format( - remapped_location.p_code, - new_location.p_code, - carto_table.location_type.name - )) - - results.append((new_location.id, remapped_location.id)) - - return results - - -def update_model_locations(remapped_locations, model, related_object, related_property, multiples): - - random_object = model.objects.first() - if random_object: - handled_related_objects = [] - ThroughModel = getattr(random_object, related_property).through - # clean up multiple remaps - for new_location_id, old_location_id in remapped_locations: - """ - Clean up `multiple/duplicate remaps` from the through table. - This step is necessary because a new location can replace multiple old locations during the remap, and the - through table constraints disallow duplicates appearing due to multiple old locations being replaced by the - same new location. - """ - - if len(multiples[new_location_id]) > 1: - # it seems Django can do the wanted grouping only if we pass the counted column in `values()` - # the result contains the related ref id and the nr. of duplicates, ex.: - # - grouped_magic = ThroughModel.objects.filter(location__in=multiples[new_location_id]).\ - values(related_object).annotate(object_count=Count(related_object)).\ - filter(object_count__gt=1) - - for record in grouped_magic: - related_object_id = record.get(related_object) - # create something to check against - check_record = (related_object_id, new_location_id) - if check_record not in handled_related_objects: - handled_related_objects.append(check_record) - else: - # construct the delete query - # all the `duplicate remaps` except the one skipped with the `check_record` should be picked up - filter_args = {related_object: related_object_id, "location": old_location_id} - ThroughModel.objects.filter(**filter_args).delete() - - # update through table only after it was cleaned up from duplicates - for new_loc, old_loc in remapped_locations: - ThroughModel.objects.filter(location=old_loc).update(location=new_loc) - - -def save_location_remap_history(remapped_location_pairs): - """ - :param remapped_location_pairs: (new_location_id, remapped_location_id) tuples, where remapped_location can be None - :return: - """ - - if not remapped_location_pairs: - return - remapped_locations = set([tp for tp in remapped_location_pairs if tp[1]]) - if not remapped_locations: - return - - multiples = defaultdict(list) - for new_loc, old_loc in remapped_locations: - multiples[new_loc].append(old_loc) - - for model, related_object, related_property in [(AppliedIndicator, "appliedindicator", "locations"), - (TravelActivity, "travelactivity", "locations"), - (Activity, "activity", "locations"), - (Intervention, "intervention", "flat_locations")]: - update_model_locations(remapped_locations, model, related_object, related_property, multiples) - - # action points - for new_loc, old_loc in remapped_locations: - ActionPoint.objects.filter(location=old_loc).update(location=new_loc) - - for new_loc, old_loc in remapped_locations: - LocationRemapHistory.objects.create( - old_location=Location.objects.all_locations().get(id=old_loc), - new_location=Location.objects.all_locations().get(id=new_loc), - comments="Remapped location id {} to id {}".format(old_loc, new_loc) - ) diff --git a/src/etools/libraries/locations/tasks.py b/src/etools/libraries/locations/tasks.py index 30a79188c..b3caf131c 100644 --- a/src/etools/libraries/locations/tasks.py +++ b/src/etools/libraries/locations/tasks.py @@ -1,338 +1,71 @@ from django.contrib.auth import get_user_model -from django.db import IntegrityError, transaction -from django.db.models import Q -from django.utils.encoding import force_text import celery -from carto.exceptions import CartoException -from carto.sql import SQLClient from celery.utils.log import get_task_logger from tenant_schemas_celery.app import get_schema_name_from_task -from unicef_locations.auth import LocationsCartoNoAuthClient -from unicef_locations.models import CartoDBTable, Location, LocationRemapHistory +from unicef_locations.models import CartoDBTable +from unicef_locations.synchronizers import LocationSynchronizer from unicef_notification.utils import send_notification_with_template from unicef_vision.utils import get_vision_logger_domain_model +from etools.applications.field_monitoring.fm_settings.models import LocationSite +from etools.applications.field_monitoring.planning.models import MonitoringActivity from etools.applications.users.models import Country -from etools.libraries.locations.task_utils import ( - create_location, - duplicate_pcodes_exist, - filter_remapped_locations_cb, - get_cartodb_locations, - get_location_ids_in_use, - remap_location, - save_location_remap_history, - validate_remap_table, -) logger = get_task_logger(__name__) -@celery.current_app.task(bind=True) -def validate_locations_in_use(self, carto_table_pk): - carto_table = CartoDBTable.objects.get(pk=carto_table_pk) - country = Country.objects.get(schema_name=get_schema_name_from_task(self, dict)) - log, _ = get_vision_logger_domain_model().objects.get_or_create( - handler_name=f'LocationsHandler ({carto_table.location_type.admin_level})', - business_area_code=getattr(country, 'business_area_code', ''), - country=country - - ) - log.details = self.__class__.__name__ - - database_pcodes = [] - rows = Location.objects.all_locations().filter(gateway=carto_table.location_type).values('p_code') - log.total_records = rows.count() - for row in rows: - database_pcodes.append(row['p_code']) - - auth_client = LocationsCartoNoAuthClient(base_url="https://{}.carto.com/".format(carto_table.domain)) - sql_client = SQLClient(auth_client) - - try: - qry = sql_client.send('select array_agg({}) AS aggregated_pcodes from {}'.format( - carto_table.pcode_col, - carto_table.table_name, - )) - new_carto_pcodes = qry['rows'][0]['aggregated_pcodes'] \ - if len(qry['rows']) > 0 and "aggregated_pcodes" in qry['rows'][0] else [] - - remap_table_pcode_pairs = [] - if carto_table.remap_table_name: - remap_qry = 'select old_pcode::text, new_pcode::text from {}'.format(carto_table.remap_table_name) - remap_table_pcode_pairs = sql_client.send(remap_qry)['rows'] - - except CartoException as e: - message = "CartoDB exception occured during the data validation." - logger.exception(message) - log.exception_message = message - log.save() - raise e - - remap_old_pcodes = [remap_row['old_pcode'] for remap_row in remap_table_pcode_pairs] - orphaned_pcodes = set(database_pcodes) - (set(new_carto_pcodes) | set(remap_old_pcodes)) - orphaned_location_ids = Location.objects.all_locations().filter(p_code__in=list(orphaned_pcodes)) - - # if location ids with no remap in use are found, do not continue the import - location_ids_bnriu = get_location_ids_in_use(orphaned_location_ids) - if location_ids_bnriu: - message = "Location ids in use without remap found: {}". format(','.join([str(iu) for iu in location_ids_bnriu])) - logger.exception(message) - log.exception_message = message - log.save() - raise NoRemapInUseException(message) - - log.save() - return True - - -@celery.current_app.task(bind=True) # noqa: ignore=C901 -def update_sites_from_cartodb(self, carto_table_pk): - - carto_table = CartoDBTable.objects.get(pk=carto_table_pk) - country = Country.objects.get(schema_name=get_schema_name_from_task(self, dict)) - log, _ = get_vision_logger_domain_model().objects.get_or_create( - handler_name=f'LocationsHandler ({carto_table.location_type.admin_level})', - business_area_code=getattr(country, 'business_area_code', ''), - country=country - ) - log.details = self.__class__.__name__ - results = [] - - auth_client = LocationsCartoNoAuthClient(base_url="https://{}.carto.com/".format(carto_table.domain)) - sql_client = SQLClient(auth_client) - sites_created = sites_updated = sites_remapped = sites_not_added = 0 - - try: - # query cartodb for the locations with geometries - carto_succesfully_queried, rows = get_cartodb_locations(sql_client, carto_table) - - if not carto_succesfully_queried: - return results - except CartoException: # pragma: no-cover - message = "CartoDB exception occured" - logger.exception(message) - log.exception_message = message - log.save() - else: - # validations - # get the list of the existing Pcodes and previous Pcodes from the database - database_pcodes = [] - lrows = Location.objects.filter(gateway=carto_table.location_type).values('p_code') - log.total_records = lrows.count() - for row in lrows: - database_pcodes.append(row['p_code']) - - # get the list of the new Pcodes from the Carto data - new_carto_pcodes = [str(row[carto_table.pcode_col]) for row in rows] - - # validate remap table contents - remap_table_valid, remap_table_pcode_pairs, remap_old_pcodes, remap_new_pcodes = \ - validate_remap_table(database_pcodes, new_carto_pcodes, carto_table, sql_client) - - if not remap_table_valid: - return results - - # check for duplicate pcodes in both local and Carto data - if duplicate_pcodes_exist(database_pcodes, new_carto_pcodes, remap_old_pcodes, remap_new_pcodes): - return results - - # wrap Location tree updates in a transaction, to prevent an invalid tree state due to errors - try: - with transaction.atomic(): - # should write lock the locations table until the tree is rebuilt - Location.objects.all_locations().select_for_update().only('id') - - # disable tree 'generation' during single row updates, rebuild the tree after the rows are updated. - with Location.objects.disable_mptt_updates(): - # update locations in two steps: first remap, then update the data. The reason of this approach is that - # a remapped 'old' pcode can appear as a newly inserted pcode. Remapping before updating/inserting - # should prevent the problem of archiving locations when remapping and updating in the same step. - - # REMAP locations - if carto_table.remap_table_name and len(remap_table_pcode_pairs) > 0: - # remapped_pcode_pairs ex.: {'old_pcode': 'ET0721', 'new_pcode': 'ET0714'} - remap_table_pcode_pairs = list(filter( - filter_remapped_locations_cb, - remap_table_pcode_pairs - )) - - aggregated_remapped_pcode_pairs = {} - for row in rows: - carto_pcode = str(row[carto_table.pcode_col]).strip() - for remap_row in remap_table_pcode_pairs: - # create the location or update the existing based on type and code - if carto_pcode == remap_row['new_pcode']: - if carto_pcode not in aggregated_remapped_pcode_pairs: - aggregated_remapped_pcode_pairs[carto_pcode] = [] - aggregated_remapped_pcode_pairs[carto_pcode].append(remap_row['old_pcode']) - - # aggregated_remapped_pcode_pairs - {'new_pcode': ['old_pcode_1', old_pcode_2, ...], ...} - for remapped_new_pcode, remapped_old_pcodes in aggregated_remapped_pcode_pairs.items(): - remapped_location_id_pairs = remap_location( - carto_table, - remapped_new_pcode, - remapped_old_pcodes - ) - # create remap history, and remap relevant models to the new location - if remapped_location_id_pairs: - save_location_remap_history(remapped_location_id_pairs) - sites_remapped += 1 - - # UPDATE locations - for row in rows: - carto_pcode = str(row[carto_table.pcode_col]).strip() - site_name = row[carto_table.name_col] - - if not site_name or site_name.isspace(): - logger.warning("No name for location with PCode: {}".format(carto_pcode)) - sites_not_added += 1 - continue - - parent = parent_instance = None - - # attempt to reference the parent of this location - if carto_table.parent_code_col and carto_table.parent: - msg = None - parent = carto_table.parent.__class__ - parent_code = row[carto_table.parent_code_col] - try: - parent_instance = Location.objects.get(p_code=parent_code) - except Location.MultipleObjectsReturned: - msg = "Multiple locations found for parent code: {}".format( - parent_code - ) - except Location.DoesNotExist: - msg = "No locations found for parent code: {}".format( - parent_code - ) - except Exception as exp: - msg = force_text(exp) - - if msg is not None: - logger.warning(msg) - sites_not_added += 1 - continue - - # create the location or update the existing based on type and code - success, sites_not_added, sites_created, sites_updated = create_location( - carto_pcode, carto_table, - parent, parent_instance, - site_name, row, - sites_not_added, sites_created, sites_updated - ) - if success: - logger.warning("Location level {} imported with success".format(carto_table.location_type)) - - orphaned_old_pcodes = set(database_pcodes) - (set(new_carto_pcodes) | set(remap_old_pcodes)) - if orphaned_old_pcodes: # pragma: no-cover - logger.warning("Archiving unused pcodes: {}".format(','.join(orphaned_old_pcodes))) - Location.objects.filter( - p_code__in=list(orphaned_old_pcodes), - is_active=True, - ).update( - is_active=False - ) - - # rebuild location tree - Location.objects.rebuild() - - except IntegrityError as e: - logger.exception(str(e)) - log.exception_message = str(e) - log.save() - raise e - - logger.warning("Table name {}: {} sites created, {} sites updated, {} sites remapped, {} sites skipped".format( - carto_table.table_name, sites_created, sites_updated, sites_remapped, sites_not_added)) - log.save() - return True +class eToolsLocationSynchronizer(LocationSynchronizer): + """eTools version of synchronizer with use the VisionSyncLog to store log execution""" + + def __init__(self, pk, schema) -> None: + super().__init__(pk) + country = Country.objects.get(schema_name=schema) + self.log, _ = get_vision_logger_domain_model().objects.get_or_create( + handler_name=f'LocationsHandler (lev{self.carto.location_type.admin_level})', + business_area_code=getattr(country, 'business_area_code', ''), + country=country, + details=self.__class__.__name__ + ) + + def sync(self): + new, updated, skipped, error = super().sync() + self.log.total_records = new + updated + skipped + error + self.log.total_processed = new + updated + self.log.successful = True + self.log.save() + + def post_sync(self): + # update sites + for site in LocationSite.objects.all(): + parent = site.get_parent_location() + if site.parent != parent: + site.parent = parent + site.save() + + # update monitoring activities + for activity in MonitoringActivity.objects.filter(location_site__isnull=False): + if activity.site.parent != activity.location: + activity.location = activity.site.parent + activity.save() @celery.current_app.task(bind=True) -def cleanup_obsolete_locations(self, carto_table_pk): - carto_table = CartoDBTable.objects.get(pk=carto_table_pk) - country = Country.objects.get(schema_name=get_schema_name_from_task(self, dict)) - log, _ = get_vision_logger_domain_model().objects.get_or_create( - handler_name=f'LocationsHandler ({carto_table.location_type.admin_level})', - business_area_code=getattr(country, 'business_area_code', ''), - country=country - ) - log.details = self.__class__.__name__ - - database_pcodes = [] - rows = Location.objects.all_locations().filter(gateway=carto_table.location_type).values('p_code') - log.total_processed = rows.count() - for row in rows: - database_pcodes.append(row['p_code']) - - auth_client = LocationsCartoNoAuthClient(base_url="https://{}.carto.com/".format(carto_table.domain)) - sql_client = SQLClient(auth_client) - - try: - qry = sql_client.send('select array_agg({}) AS aggregated_pcodes from {}'.format( - carto_table.pcode_col, - carto_table.table_name, - )) - new_carto_pcodes = qry['rows'][0]['aggregated_pcodes'] \ - if len(qry['rows']) > 0 and "aggregated_pcodes" in qry['rows'][0] else [] - - remap_table_pcode_pairs = [] - if carto_table.remap_table_name: - remap_qry = 'select old_pcode::text, new_pcode::text from {}'.format(carto_table.remap_table_name) - remap_table_pcode_pairs = sql_client.send(remap_qry)['rows'] +def import_locations(self, carto_table_pk): + """ + Delete all locations that are not matching* in the remap table and are not in use (referenced models). + Deactivate all locations that are in use and are not matching* in the remap table. - except CartoException as e: - message = "CartoDB exception occured during the data validation." - logger.exception(message) - log.exception_message = message - log.save() - raise e + In use no Matching no => Delete + In use yes Matching no => Deactivate + In use yes/no Matching yes => Update - remapped_pcodes = [remap_row['old_pcode'] for remap_row in remap_table_pcode_pairs] - remapped_pcodes += [remap_row['new_pcode'] for remap_row in remap_table_pcode_pairs] - # select for deletion those pcodes which are not present in the Carto datasets in any form - deleteable_pcodes = set(database_pcodes) - (set(new_carto_pcodes) | set(remapped_pcodes)) + Iterate on all the “new” locations: + if they have match update else create + """ - # Do a few safety checks before we actually delete a location, like: - # - ensure that the deleted locations doesn't have any children in the location tree - # - check if the deleted location was remapped before, do not delete if yes. - # if the checks pass, add the deleteable location ID to the `revalidated_deleteable_pcodes` array so they can be - # deleted in one go later - revalidated_deleteable_pcodes = [] - - with transaction.atomic(): - # prevent writing into locations until the cleanup is done - Location.objects.all_locations().select_for_update().only('id') - - for deleteable_pcode in deleteable_pcodes: - try: - deleteable_location = Location.objects.all_locations().get(p_code=deleteable_pcode) - except Location.DoesNotExist: - logger.warning("Cannot find orphaned pcode {}.".format(deleteable_pcode)) - else: - if deleteable_location.is_leaf_node(): - secondary_parent_check = Location.objects.all_locations().filter( - parent=deleteable_location.id - ).exists() - remap_history_check = LocationRemapHistory.objects.filter( - Q(old_location=deleteable_location) | Q(new_location=deleteable_location) - ).exists() - if not secondary_parent_check and not remap_history_check: - logger.info("Selecting orphaned pcode {} for deletion".format(deleteable_location.p_code)) - revalidated_deleteable_pcodes.append(deleteable_location.id) - - # delete the selected locations all at once, it seems it's faster like this compared to deleting them one by one. - if revalidated_deleteable_pcodes: - logger.info("Deleting selected orphaned pcodes") - Location.objects.all_locations().filter(id__in=revalidated_deleteable_pcodes).delete() - - # rebuild location tree after the unneeded locations are cleaned up, because it seems deleting locations - # sometimes leaves the location tree in a `bugged` state - Location.objects.rebuild() - log.successful = True - log.save() - return True + schema = get_schema_name_from_task(self, dict) + eToolsLocationSynchronizer(carto_table_pk, schema).sync() @celery.current_app.task @@ -347,7 +80,3 @@ def notify_import_site_completed(carto_table_pk, user_pk): template_name='locations/import_completed', context=context ) - - -class NoRemapInUseException(Exception): - pass diff --git a/src/etools/libraries/locations/tests/test_tasks.py b/src/etools/libraries/locations/tests/test_tasks.py deleted file mode 100644 index f687ce087..000000000 --- a/src/etools/libraries/locations/tests/test_tasks.py +++ /dev/null @@ -1,145 +0,0 @@ -from unittest.mock import Mock, patch - -from unicef_locations.models import Location -from unicef_locations.tests.factories import CartoDBTableFactory, LocationFactory - -from etools.applications.action_points.tests.factories import ActionPointFactory -from etools.applications.core.tests.cases import BaseTenantTestCase -from etools.applications.partners.models import Intervention -from etools.applications.partners.tests.factories import InterventionFactory, InterventionResultLinkFactory -from etools.applications.reports.tests.factories import AppliedIndicatorFactory, LowerResultFactory -from etools.applications.t2f.tests.factories import TravelActivityFactory -from etools.applications.users.tests.factories import UserFactory -from etools.libraries.locations import task_utils, tasks - - -class TestLocationTasks(BaseTenantTestCase): - def setUp(self): - self.unicef_staff = UserFactory(is_staff=True) - self.carto_table = CartoDBTableFactory(remap_table_name="test_rmp") - self.locations = [LocationFactory(gateway=self.carto_table.location_type) for x in range(5)] - self.remapped_location = self.locations[0] - self.new_location = self.locations[1] - self.obsolete_locations = self.locations[2:] - - self.mock_sql = Mock() - self.mock_remap_data = Mock() - self.mock_carto_data = Mock() - self.geom = "MultiPolygon(((10 10, 10 20, 20 20, 20 15, 10 10)), ((10 10, 10 20, 20 20, 20 15, 10 10)))" - - def _run_validation(self, carto_table_pk): - with patch("unicef_locations.tasks.SQLClient.send", self.mock_sql): - tasks.validate_locations_in_use.push_request(headers={'_schema_name': 'test'}) - return tasks.validate_locations_in_use.run(carto_table_pk) - - def _run_update(self, carto_table_pk): - # IMPORTANT mock the actual function loaded in tasks, it doesn't work by mocking the function in task_utils - with patch( - "etools.libraries.locations.tasks.validate_remap_table", self.mock_remap_data), patch( - "etools.libraries.locations.tasks.get_cartodb_locations", self.mock_carto_data): - tasks.update_sites_from_cartodb.push_request(headers={'_schema_name': 'test'}) - return tasks.update_sites_from_cartodb.run(carto_table_pk) - - def _run_cleanup(self, carto_table_pk): - with patch("unicef_locations.tasks.SQLClient.send", self.mock_sql): - tasks.cleanup_obsolete_locations.push_request(headers={'_schema_name': 'test'}) - return tasks.cleanup_obsolete_locations.run(carto_table_pk) - - def _assert_response(self, response, expected_result): - self.assertEqual(response, expected_result) - - def test_remap_in_use_validation_failed(self): - self.mock_sql.return_value = {"rows": []} - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.remapped_location) - intervention.save() - - with self.assertRaises(tasks.NoRemapInUseException): - self._run_validation(self.carto_table.pk) - - def test_remap_in_use_validation_success(self): - self.mock_sql.return_value = {"rows": [ - {"old_pcode": self.remapped_location.p_code, "new_pcode": self.new_location.p_code} - ]} - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.remapped_location) - intervention.save() - - response = self._run_validation(self.carto_table.pk) - self.assertTrue(response) - - def test_remap_in_use_reassignment_success(self): - self.mock_remap_data.return_value = ( - True, - [{"old_pcode": self.remapped_location.p_code, "new_pcode": self.new_location.p_code}], - [self.remapped_location.p_code], - [self.new_location.p_code], - ) - - self.mock_carto_data.return_value = True, [{ - self.carto_table.pcode_col: self.new_location.p_code, - "name": self.new_location.name + "_remapped", - "the_geom": self.geom - }] - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.remapped_location) - intervention.save() - - with self.assertRaises(Location.DoesNotExist): - intervention.flat_locations.get(id=self.new_location.id) - self.assertIsNotNone(intervention.flat_locations.get(id=self.remapped_location.id)) - - self._run_update(self.carto_table.pk) - - with self.assertRaises(Location.DoesNotExist): - intervention.flat_locations.get(id=self.remapped_location.id) - new_flat_location = intervention.flat_locations.get(p_code=self.new_location.p_code) - self.assertIsNotNone(new_flat_location) - self.assertEqual(new_flat_location.name, self.new_location.name + "_remapped") - - def test_remap_in_use_cleanup(self): - self.mock_sql.return_value = {"rows": [ - {"old_pcode": self.remapped_location.p_code, "new_pcode": self.new_location.p_code} - ]} - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.remapped_location) - intervention.save() - - self.assertEqual(len(Location.objects.all_locations()), 5) - self._run_cleanup(self.carto_table.pk) - self.assertEqual(len(Location.objects.all_locations()), 2) - - def test_remap_table_filter_callback(self): - remap_row = {"old_pcode": self.remapped_location.p_code, "new_pcode": self.new_location.p_code} - self.assertFalse(task_utils.filter_remapped_locations_cb(remap_row)) - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.remapped_location) - intervention.save() - - self.assertTrue(task_utils.filter_remapped_locations_cb(remap_row)) - - def test_get_location_ids_in_use(self): - location_ids = [location.id for location in self.locations] - self.assertListEqual(task_utils.get_location_ids_in_use(location_ids), []) - - intervention = InterventionFactory(status=Intervention.SIGNED) - intervention.flat_locations.add(self.locations[0]) - intervention.save() - - lower_result = LowerResultFactory(result_link=InterventionResultLinkFactory()) - ai = AppliedIndicatorFactory(lower_result=lower_result) - ai.locations.add(self.locations[1]) - ai.save() - tva = TravelActivityFactory() - tva.locations.add(self.locations[2]) - tva.save() - ap = ActionPointFactory() - ap.location = self.locations[3] - ap.save() - - self.assertListEqual(sorted(task_utils.get_location_ids_in_use(location_ids)), sorted(location_ids[0:4])) diff --git a/src/etools/libraries/locations/tests/test_views.py b/src/etools/libraries/locations/tests/test_views.py index 27e6af889..5ccfd5d9b 100644 --- a/src/etools/libraries/locations/tests/test_views.py +++ b/src/etools/libraries/locations/tests/test_views.py @@ -26,7 +26,7 @@ def test_api_locationtypes_list(self): def test_api_location_light_list(self): response = self.forced_auth_req('get', reverse('locations-light-list'), user=self.unicef_staff) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(sorted(response.data[0].keys()), ["gateway", "id", "name", "p_code"]) + self.assertEqual(sorted(response.data[0].keys()), ["gateway", "id", "name", "p_code", "parent"]) # sort the expected locations by name, the same way the API results are sorted self.locations.sort(key=lambda location: location.name) @@ -117,5 +117,5 @@ def test_api_location_autocomplete(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 6) - self.assertEqual(sorted(response.data[0].keys()), ["gateway", "id", "name", "p_code"]) + self.assertEqual(sorted(response.data[0].keys()), ["gateway", "id", "name", "p_code", "parent"]) self.assertIn("Loc", response.data[0]["name"]) diff --git a/tox.ini b/tox.ini index ef2dc4252..3e4647797 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] -envlist = d{22} +envlist = d{32} [testenv] -basepython=python3.7 +basepython=python3.9 passenv = * extras=test @@ -17,10 +17,10 @@ whitelist_externals = pipenv commands = pipenv install --dev --ignore-pipfile -[testenv:d22] +[testenv:d32] commands = {[testenv]commands} - pip install "django>=2.2,<2.3" + pip install "django>=3.2,<4.0" sh ./runtests.sh [testenv:report]