diff --git a/Pipfile b/Pipfile index 3d5f5b04bb..57a948b8dd 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ name = "pypi" [packages] argon2-cffi = "==23.1.0" authlib = "==1.4.0" -boto3 = "==1.35.98" +boto3 = "==1.36.7" celery = "==5.4.0" django = "==5.1.4" django-environ = "==0.12.0" @@ -32,7 +32,7 @@ newrelic = "==10.4.0" pillow = "==11.1.0" psycopg = { extras = ["c"], version = "==3.2.3" } pydantic = "==2.9.2" -pyjwt = "==2.9.0" +pyjwt = "==2.10.1" python-slugify = "==8.0.4" pywebpush = "==2.0.1" redis = { extras = ["hiredis"], version = "==5.2.1" } @@ -42,7 +42,7 @@ simplejson = "==3.19.3" sentry-sdk = "==2.18.0" whitenoise = "==6.8.2" django-anymail = {extras = ["amazon-ses"], version = "*"} -pydantic-extra-types = "2.10.1" +pydantic-extra-types = "2.10.2" phonenumberslite = "==8.13.52" [dev-packages] @@ -56,7 +56,7 @@ djangorestframework-stubs = "==3.15.2" factory-boy = "==3.3.1" freezegun = "==1.5.1" ipython = "==8.31.0" -mypy = "==1.13.0" +mypy = "==1.14.1" pre-commit = "==4.0.1" requests-mock = "==1.12.1" tblib = "==3.0.0" diff --git a/Pipfile.lock b/Pipfile.lock index 123cc58a17..1dba5ee6ed 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9ac56a338a7f502672f149eee563ad8f1a71e213e8a3b1e1f00bea0a54d74032" + "sha256": "663cc58c322331774e6872c6042add29781929bf015aeea6b0755231e4757ac4" }, "pipfile-spec": 6, "requires": { @@ -176,11 +176,11 @@ }, "attrs": { "hashes": [ - "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", - "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" + "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", + "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" ], "markers": "python_version >= '3.8'", - "version": "==24.3.0" + "version": "==25.1.0" }, "authlib": { "hashes": [ @@ -201,20 +201,20 @@ }, "boto3": { "hashes": [ - "sha256:4b6274b4fe9d7113f978abea66a1f20c8a397c268c9d1b2a6c96b14a256da4a5", - "sha256:d0224e1499d7189b47aa7f469d96522d98df6f5702fccb20a95a436582ebcd9d" + "sha256:ab501f75557863e2d2c9fa731e4fe25c45f35e0d92ea0ee11a4eaa63929d3ede", + "sha256:ae98634efa7b47ced1b0d7342e2940b32639eee913f33ab406590b8ed55ee94b" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.7" }, "botocore": { "hashes": [ - "sha256:4f1c0b687488663a774ad3a5e81a5f94fae1bcada2364cfdc48482c4dbf794d5", - "sha256:d11742b3824bdeac3c89eeeaf5132351af41823bbcef8fc15e95c8250b1de09c" + "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2", + "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa" ], "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.8" }, "celery": { "hashes": [ @@ -912,11 +912,11 @@ }, "more-itertools": { "hashes": [ - "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", - "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6" + "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", + "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89" ], - "markers": "python_version >= '3.8'", - "version": "==10.5.0" + "markers": "python_version >= '3.9'", + "version": "==10.6.0" }, "multidict": { "hashes": [ @@ -1155,11 +1155,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", - "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e" + "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", + "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.48" + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.50" }, "propcache": { "hashes": [ @@ -1388,21 +1388,21 @@ }, "pydantic-extra-types": { "hashes": [ - "sha256:db2c86c04a837bbac0d2d79bbd6f5d46c4c9253db11ca3fdd36a2b282575f1e2", - "sha256:e4f937af34a754b8f1fa228a2fac867091a51f56ed0e8a61d5b3a6719b13c923" + "sha256:934d59ab7a02ff788759c3a97bc896f5cfdc91e62e4f88ea4669067a73f14b98", + "sha256:9eccd55a2b7935cea25f0a67f6ff763d55d80c41d86b887d88915412ccf5b7fa" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.10.1" + "version": "==2.10.2" }, "pyjwt": { "hashes": [ - "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", - "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c" + "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", + "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.9.0" + "markers": "python_version >= '3.9'", + "version": "==2.10.1" }, "python-dateutil": { "hashes": [ @@ -1524,11 +1524,11 @@ }, "referencing": { "hashes": [ - "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", - "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de" + "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", + "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0" ], - "markers": "python_version >= '3.8'", - "version": "==0.35.1" + "markers": "python_version >= '3.9'", + "version": "==0.36.2" }, "requests": { "hashes": [ @@ -1650,11 +1650,11 @@ }, "s3transfer": { "hashes": [ - "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", - "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" + "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", + "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" }, "sentry-sdk": { "hashes": [ @@ -1855,11 +1855,11 @@ }, "tzdata": { "hashes": [ - "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", - "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd" + "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", + "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639" ], "markers": "python_version >= '2'", - "version": "==2024.2" + "version": "==2025.1" }, "unicodecsv": { "hashes": [ @@ -2015,20 +2015,20 @@ }, "autopep8": { "hashes": [ - "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", - "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d" + "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", + "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128" ], - "markers": "python_version >= '3.8'", - "version": "==2.3.1" + "markers": "python_version >= '3.9'", + "version": "==2.3.2" }, "boto3": { "hashes": [ - "sha256:4b6274b4fe9d7113f978abea66a1f20c8a397c268c9d1b2a6c96b14a256da4a5", - "sha256:d0224e1499d7189b47aa7f469d96522d98df6f5702fccb20a95a436582ebcd9d" + "sha256:ab501f75557863e2d2c9fa731e4fe25c45f35e0d92ea0ee11a4eaa63929d3ede", + "sha256:ae98634efa7b47ced1b0d7342e2940b32639eee913f33ab406590b8ed55ee94b" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.7" }, "boto3-stubs": { "extras": [ @@ -2036,27 +2036,27 @@ "s3" ], "hashes": [ - "sha256:14a1443d669dde72c6247092dff576bd720e58ea10eec4b0c251e73a21ce1509", - "sha256:a9d38ceafdd72ada60bdfaa72d83b549f96bd8235b3b2f0904753e45191d690d" + "sha256:197bdbacd3a9085c6310a06f21616f30f6103ed8be67705962620ac4587ba1fb", + "sha256:d5d3f1f537c4d317f1f11b1cb4ce8f427822204936e29419b43c709ec54758ea" ], "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.7" }, "botocore": { "hashes": [ - "sha256:4f1c0b687488663a774ad3a5e81a5f94fae1bcada2364cfdc48482c4dbf794d5", - "sha256:d11742b3824bdeac3c89eeeaf5132351af41823bbcef8fc15e95c8250b1de09c" + "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2", + "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa" ], "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.8" }, "botocore-stubs": { "hashes": [ - "sha256:2209d79a107c73920f82ad1690013d9d001e860784864346d94bfb94292be808", - "sha256:a33f474e8e9e354ed7e4e47d5efb13d68234d7366360d6391c6e70963c477589" + "sha256:8f966e0cf994981f1086906fd72531acc434baea1fbc149fa2666a9087a0c17d", + "sha256:a8b65d7f6ae14aa90d03fbc1c27b7c3c6b3ca3d7c2eb1d814441ba04a22dd981" ], "markers": "python_version >= '3.8'", - "version": "==1.35.98" + "version": "==1.36.8" }, "certifi": { "hashes": [ @@ -2335,19 +2335,19 @@ }, "django-stubs": { "hashes": [ - "sha256:126d354bbdff4906c4e93e6361197f6fbfb6231c3df6def85a291dae6f9f577b", - "sha256:c4dc64260bd72e6d32b9e536e8dd0d9247922f0271f82d1d5132a18f24b388ac" + "sha256:04ddc778faded6fb48468a8da9e98b8d12b9ba983faa648d37a73ebde0f024da", + "sha256:a0fcb3659bab46a6d835cc2d9bff3fc29c36ccea41a10e8b1930427bc0f9f0df" ], "markers": "python_version >= '3.8'", - "version": "==5.1.1" + "version": "==5.1.2" }, "django-stubs-ext": { "hashes": [ - "sha256:3907f99e178c93323e2ce908aef8352adb8c047605161f8d9e5e7b4efb5a6a9c", - "sha256:db7364e4f50ae7e5360993dbd58a3a57ea4b2e7e5bab0fbd525ccdb3e7975d1c" + "sha256:421c0c3025a68e3ab8e16f065fad9ba93335ecefe2dd92a0cff97a665680266c", + "sha256:6c559214538d6a26f631ca638ddc3251a0a891d607de8ce01d23d3201ad8ad6c" ], "markers": "python_version >= '3.8'", - "version": "==5.1.1" + "version": "==5.1.2" }, "djangorestframework-stubs": { "hashes": [ @@ -2360,11 +2360,11 @@ }, "executing": { "hashes": [ - "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", - "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab" + "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", + "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755" ], "markers": "python_version >= '3.8'", - "version": "==2.1.0" + "version": "==2.2.0" }, "factory-boy": { "hashes": [ @@ -2386,11 +2386,11 @@ }, "filelock": { "hashes": [ - "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", - "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" + "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", + "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e" ], - "markers": "python_version >= '3.8'", - "version": "==3.16.1" + "markers": "python_version >= '3.9'", + "version": "==3.17.0" }, "freezegun": { "hashes": [ @@ -2411,11 +2411,11 @@ }, "identify": { "hashes": [ - "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", - "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc" + "sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251", + "sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881" ], "markers": "python_version >= '3.9'", - "version": "==2.6.5" + "version": "==2.6.6" }, "idna": { "hashes": [ @@ -2536,50 +2536,56 @@ }, "mypy": { "hashes": [ - "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", - "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", - "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", - "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", - "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", - "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", - "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", - "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", - "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", - "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", - "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", - "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", - "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", - "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", - "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", - "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", - "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", - "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", - "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", - "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", - "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", - "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", - "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", - "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", - "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", - "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", - "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", - "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", - "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", - "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", - "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", - "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.13.0" + "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", + "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", + "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", + "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", + "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", + "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", + "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", + "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", + "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319", + "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", + "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", + "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", + "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", + "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", + "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31", + "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", + "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", + "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", + "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", + "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", + "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837", + "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6", + "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", + "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", + "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", + "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", + "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", + "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", + "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b", + "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac", + "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", + "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", + "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", + "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", + "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", + "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", + "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", + "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.14.1" }, "mypy-boto3-s3": { "hashes": [ - "sha256:4cd3f1718fa0d8a54212c495cdff493bdcc6a8ae419d95428c60fb6bc7db7980", - "sha256:b4529e57a8d5f21d4c61fe650fa6764fee2ba7ab524a455a34ba2698ef6d27a8" + "sha256:80a881847b0e1fbc5edcad8b2870c110e31e7ef128db42402b70c159b7e93d5a", + "sha256:a65ccb6be7b7ebf907887268d44975e435b1fc1164fc0a25de310e2b832f7e91" ], "markers": "python_version >= '3.8'", - "version": "==1.35.93" + "version": "==1.36.0" }, "mypy-extensions": { "hashes": [ @@ -2641,11 +2647,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", - "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e" + "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", + "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.48" + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.50" }, "ptyprocess": { "hashes": [ @@ -2789,11 +2795,11 @@ }, "s3transfer": { "hashes": [ - "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", - "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" + "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", + "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" }, "six": { "hashes": [ @@ -2837,11 +2843,11 @@ }, "types-awscrt": { "hashes": [ - "sha256:405bce8c281f9e7c6c92a229225cc0bf10d30729a6a601123213389bd524b8b1", - "sha256:fbf9c221af5607b24bf17f8431217ce8b9a27917139edbc984891eb63fd5a593" + "sha256:2141391a8f4d36cf098406c19d9060b34f13a558c22d4aadac250a0c57d12710", + "sha256:d66b3817565769f5311b7e171a3c48d3dbf8a8f9c22f02686c2f003b6559a2a5" ], "markers": "python_version >= '3.8'", - "version": "==0.23.6" + "version": "==0.23.8" }, "types-pyyaml": { "hashes": [ @@ -2861,11 +2867,11 @@ }, "types-s3transfer": { "hashes": [ - "sha256:03123477e3064c81efe712bf9d372c7c72f2790711431f9baa59cf96ea607267", - "sha256:22ac1aabc98f9d7f2928eb3fb4d5c02bf7435687f0913345a97dd3b84d0c217d" + "sha256:09c31cff8c79a433fcf703b840b66d1f694a6c70c410ef52015dd4fe07ee0ae2", + "sha256:3ccb8b90b14434af2fb0d6c08500596d93f3a83fb804a2bb843d9bf4f7c2ca60" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" }, "typing-extensions": { "hashes": [ @@ -2885,11 +2891,11 @@ }, "virtualenv": { "hashes": [ - "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", - "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329" + "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779", + "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35" ], "markers": "python_version >= '3.8'", - "version": "==20.28.1" + "version": "==20.29.1" }, "watchdog": { "hashes": [ diff --git a/README.md b/README.md index ed20fe7431..63903106d9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Care backend makes the following features possible: ### Docs and Guides -You can find the docs at https://care-be-docs.coronasafe.network +You can find the docs at https://care-be-docs.ohc.network ### Staging Deployments diff --git a/care/contrib/sites/migrations/0003_set_site_domain_and_name.py b/care/contrib/sites/migrations/0003_set_site_domain_and_name.py index 2078c433f6..4dbc0f2c62 100644 --- a/care/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/care/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -14,7 +14,7 @@ def update_site_forward(apps, schema_editor): Site.objects.update_or_create( id=settings.SITE_ID, defaults={ - "domain": "coronasafe.in", + "domain": "ohc.network", "name": "Care", }, ) diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index 625a5318d3..cdc9750dcf 100644 --- a/care/emr/api/otp_viewsets/login.py +++ b/care/emr/api/otp_viewsets/login.py @@ -4,6 +4,7 @@ from django.conf import settings from django.utils import timezone +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field, field_validator from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -45,6 +46,9 @@ class OTPLoginView(EMRBaseViewSet): authentication_classes = [] permission_classes = [] + @extend_schema( + request=OTPLoginRequestSpec, + ) @action(detail=False, methods=["POST"]) def send(self, request): data = OTPLoginRequestSpec(**request.data) @@ -76,6 +80,9 @@ def send(self, request): otp_obj.save() return Response({"otp": "generated"}) + @extend_schema( + request=OTPLoginSpec, + ) @action(detail=False, methods=["POST"]) def login(self, request): data = OTPLoginSpec(**request.data) diff --git a/care/emr/api/otp_viewsets/slot.py b/care/emr/api/otp_viewsets/slot.py index 903d3cc951..40e005f9bd 100644 --- a/care/emr/api/otp_viewsets/slot.py +++ b/care/emr/api/otp_viewsets/slot.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -39,6 +40,9 @@ class OTPSlotViewSet(EMRRetrieveMixin, EMRBaseViewSet): database_model = TokenSlot pydantic_read_model = TokenSlotBaseSpec + @extend_schema( + request=SlotsForDayRequestSpec, + ) @action(detail=False, methods=["POST"]) def get_slots_for_day(self, request, *args, **kwargs): request_data = SlotsForDayRequestSpec(**request.data) @@ -46,6 +50,9 @@ def get_slots_for_day(self, request, *args, **kwargs): request_data.facility, request.data ) + @extend_schema( + request=AppointmentBookingSpec, + ) @action(detail=True, methods=["POST"]) def create_appointment(self, request, *args, **kwargs): request_data = AppointmentBookingSpec(**request.data) @@ -57,6 +64,9 @@ def create_appointment(self, request, *args, **kwargs): self.get_object(), request.data, None ) + @extend_schema( + request=CancelAppointmentSpec, + ) @action(detail=False, methods=["POST"]) def cancel_appointment(self, request, *args, **kwargs): request_data = CancelAppointmentSpec(**request.data) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index 26f623d3fc..6727fbef1c 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -3,6 +3,7 @@ from django.db import transaction from django.http.response import Http404 from pydantic import ValidationError +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.generics import get_object_or_404 @@ -183,11 +184,15 @@ def perform_destroy(self, instance): instance.deleted = True instance.save(update_fields=["deleted"]) + def validate_destroy(self, instance): + pass + def destroy(self, request, *args, **kwargs): instance = self.get_object() + self.validate_destroy(instance) self.authorize_destroy(instance) self.perform_destroy(instance) - return Response(status=204) + return Response(status=status.HTTP_204_NO_CONTENT) class EMRUpsertMixin: diff --git a/care/emr/api/viewsets/batch_request.py b/care/emr/api/viewsets/batch_request.py index 5af72f38ca..77cebc3d72 100644 --- a/care/emr/api/viewsets/batch_request.py +++ b/care/emr/api/viewsets/batch_request.py @@ -1,4 +1,5 @@ from django.db import transaction +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -26,6 +27,9 @@ class BatchRequestView(GenericViewSet): def get_exception_handler(self): return emr_exception_handler + @extend_schema( + request=BatchRequest, + ) def create(self, request, *args, **kwargs): requests = BatchRequest(**request.data) errored = False diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index b8e36e5ee6..11eee096c3 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -75,6 +75,7 @@ class EncounterFilters(filters.FilterSet): field_name="patient__phone_number", lookup_expr="icontains" ) name = filters.CharFilter(field_name="patient__name", lookup_expr="icontains") + location = filters.UUIDFilter(field_name="current_location__external_id") live = LiveFilter() @@ -123,7 +124,14 @@ def get_queryset(self): qs = ( super() .get_queryset() - .select_related("patient", "facility", "appointment") + .select_related( + "patient", + "facility", + "appointment", + "current_location", + "created_by", + "updated_by", + ) .order_by("-created_date") ) if ( @@ -175,6 +183,10 @@ def organizations(self, request, *args, **kwargs): class EncounterOrganizationManageSpec(BaseModel): organization: UUID4 + @extend_schema( + request=EncounterOrganizationManageSpec, + responses={200: FacilityOrganizationReadSpec}, + ) @action(detail=True, methods=["POST"]) def organizations_add(self, request, *args, **kwargs): instance = self.get_object() @@ -195,6 +207,9 @@ def organizations_add(self, request, *args, **kwargs): ) return Response(FacilityOrganizationReadSpec.serialize(organization).to_json()) + @extend_schema( + request=EncounterOrganizationManageSpec, + ) @action(detail=True, methods=["DELETE"]) def organizations_remove(self, request, *args, **kwargs): instance = self.get_object() @@ -213,7 +228,7 @@ def organizations_remove(self, request, *args, **kwargs): EncounterOrganization.objects.filter( encounter=instance, organization=organization ).delete() - return Response({}, status=204) + return Response({}, status=status.HTTP_204_NO_CONTENT) def _check_discharge_summary_access(self, encounter): if not AuthorizationController.call( @@ -284,6 +299,9 @@ def validate_email(cls, value): django_validate_email(value) return value + @extend_schema( + request=EmailDischargeSummarySpec, + ) @action(detail=True, methods=["POST"]) def email_discharge_summary(self, request, *args, **kwargs): encounter = self.get_object() diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index f416a3edf0..594c7b373c 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -112,23 +112,24 @@ def authorize_destroy(self, instance): raise PermissionDenied("Only Super Admins can delete Facilities") @method_decorator(parser_classes([MultiPartParser])) - @action(methods=["POST"], detail=True) + @action(methods=["POST", "DELETE"], detail=True) def cover_image(self, request, external_id): facility = self.get_object() self.authorize_update({}, facility) - serializer = FacilityImageUploadSerializer(facility, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) - @cover_image.mapping.delete - def cover_image_delete(self, *args, **kwargs): - facility = self.get_object() - self.authorize_update({}, facility) - delete_cover_image(facility.cover_image_url, "cover_images") - facility.cover_image_url = None - facility.save() - return Response(status=204) + if request.method == "POST": + serializer = FacilityImageUploadSerializer(facility, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + if request.method == "DELETE": + if not facility.cover_image_url: + return Response({"detail": "No cover image to delete"}, status=404) + delete_cover_image(facility.cover_image_url, "cover_images") + facility.cover_image_url = None + facility.save() + return Response(status=204) + return Response({"detail": "Method not allowed"}, status=405) class FacilitySchedulableUsersViewSet(EMRModelReadOnlyViewSet): diff --git a/care/emr/api/viewsets/file_upload.py b/care/emr/api/viewsets/file_upload.py index 033d0ba98c..eb1a7845bc 100644 --- a/care/emr/api/viewsets/file_upload.py +++ b/care/emr/api/viewsets/file_upload.py @@ -1,5 +1,6 @@ from django.utils import timezone from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -110,6 +111,7 @@ def get_queryset(self): file_authorizer(self.request.user, obj.file_type, obj.associating_id, "read") return super().get_queryset() + @extend_schema(responses={200: FileUploadListSpec}) @action(detail=True, methods=["POST"]) def mark_upload_completed(self, request, *args, **kwargs): obj = self.get_object() @@ -121,6 +123,10 @@ def mark_upload_completed(self, request, *args, **kwargs): class ArchiveRequestSpec(BaseModel): archive_reason: str + @extend_schema( + request=ArchiveRequestSpec, + responses={200: FileUploadListSpec}, + ) @action(detail=True, methods=["POST"]) def archive(self, request, *args, **kwargs): obj = self.get_object() diff --git a/care/emr/api/viewsets/location.py b/care/emr/api/viewsets/location.py new file mode 100644 index 0000000000..752caf0250 --- /dev/null +++ b/care/emr/api/viewsets/location.py @@ -0,0 +1,393 @@ +from django_filters import rest_framework as filters +from pydantic import UUID4, BaseModel +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import get_object_or_404 +from rest_framework.response import Response + +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRDestroyMixin, + EMRListMixin, + EMRModelViewSet, + EMRRetrieveMixin, + EMRUpdateMixin, +) +from care.emr.models import ( + Encounter, + FacilityLocation, + FacilityLocationEncounter, + FacilityLocationOrganization, +) +from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser +from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec +from care.emr.resources.location.spec import ( + FacilityLocationEncounterCreateSpec, + FacilityLocationEncounterReadSpec, + FacilityLocationEncounterUpdateSpec, + FacilityLocationListSpec, + FacilityLocationModeChoices, + FacilityLocationRetrieveSpec, + FacilityLocationUpdateSpec, + FacilityLocationWriteSpec, + LocationAvailabilityStatusChoices, + LocationEncounterAvailabilityStatusChoices, +) +from care.facility.models import Facility +from care.security.authorization import AuthorizationController +from care.utils.lock import Lock + + +class FacilityLocationFilter(filters.FilterSet): + parent = filters.UUIDFilter(field_name="parent__external_id") + name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + +class FacilityLocationViewSet(EMRModelViewSet): + database_model = FacilityLocation + pydantic_model = FacilityLocationWriteSpec + pydantic_read_model = FacilityLocationListSpec + pydantic_retrieve_model = FacilityLocationRetrieveSpec + pydantic_update_model = FacilityLocationUpdateSpec + filterset_class = FacilityLocationFilter + filter_backends = [filters.DjangoFilterBackend] + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def validate_destroy(self, instance): + # Validate that there is no children if exists + if FacilityLocation.objects.filter(parent=instance).exists(): + raise ValidationError("Location has active children") + # TODO Add validation to check if patient association exists + + def validate_data(self, instance, model_obj=None): + facility = self.get_facility_obj() + if not model_obj and instance.parent: + parent = get_object_or_404(FacilityLocation, external_id=instance.parent) + if parent.facility_id != facility.id: + raise PermissionDenied("Parent Incompatible with Location") + if parent.mode == FacilityLocationModeChoices.instance.value: + raise ValidationError("Instances cannot have children") + + def authorize_create(self, instance): + facility = self.get_facility_obj() + if instance.parent: + parent = get_object_or_404(FacilityLocation, external_id=instance.parent) + else: + parent = None + if not AuthorizationController.call( + "can_create_facility_location_obj", self.request.user, parent, facility + ): + raise PermissionDenied("You do not have permission to create a location") + if instance.organizations: + for organization in instance.organizations: + organization_obj = get_object_or_404( + FacilityOrganization, external_id=organization + ) + self.authorize_organization(facility, organization_obj) + + def authorize_update(self, request_obj, model_instance): + if not AuthorizationController.call( + "can_update_facility_location_obj", self.request.user, model_instance + ): + raise PermissionDenied("You do not have permission to update this location") + + def authorize_destroy(self, instance): + self.authorize_update({}, instance) + + def perform_create(self, instance): + facility = self.get_facility_obj() + instance.facility = facility + return super().perform_create(instance) + + def get_queryset(self): + facility = self.get_facility_obj() + base_qs = FacilityLocation.objects.filter(facility=facility) + if "mine" in self.request.GET: + # Filter based on direct association + organization_ids = list( + FacilityOrganizationUser.objects.filter( + user=self.request.user, organization__facility=facility + ).values_list("organization_id", flat=True) + ) + base_qs = base_qs.filter( + id__in=FacilityLocationOrganization.objects.filter( + organization_id__in=organization_ids + ).values_list("location_id", flat=True) + ) + return AuthorizationController.call( + "get_accessible_facility_locations", base_qs, self.request.user, facility + ) + + @action(detail=True, methods=["GET"]) + def organizations(self, request, *args, **kwargs): + # AuthZ is controlled from the get_queryset method, no need to repeat + instance = self.get_object() + encounter_organizations = FacilityLocationOrganization.objects.filter( + location=instance + ).select_related("organization") + data = [ + FacilityOrganizationReadSpec.serialize( + encounter_organization.organization + ).to_json() + for encounter_organization in encounter_organizations + ] + return Response({"results": data}) + + class FacilityLocationOrganizationManageSpec(BaseModel): + organization: UUID4 + + def authorize_organization(self, facility, organization): + if organization.facility.id != facility.id: + raise PermissionDenied("Organization Incompatible with Location") + if not AuthorizationController.call( + "can_manage_facility_organization_obj", self.request.user, organization + ): + raise PermissionDenied("You do not have permission to given organizations") + + @action(detail=True, methods=["POST"]) + def organizations_add(self, request, *args, **kwargs): + instance = self.get_object() + request_data = self.FacilityLocationOrganizationManageSpec(**request.data) + organization = get_object_or_404( + FacilityOrganization, external_id=request_data.organization + ) + self.authorize_update({}, instance) + self.authorize_organization(instance.facility, organization) + location_organization = FacilityLocationOrganization.objects.filter( + location=instance, organization=organization + ) + if location_organization.exists(): + raise ValidationError("Organization already exists") + FacilityLocationOrganization.objects.create( + location=instance, organization=organization + ) + return Response(FacilityOrganizationReadSpec.serialize(organization).to_json()) + + @action(detail=True, methods=["POST"]) + def organizations_remove(self, request, *args, **kwargs): + instance = self.get_object() + request_data = self.FacilityLocationOrganizationManageSpec(**request.data) + organization = get_object_or_404( + FacilityOrganization, external_id=request_data.organization + ) + self.authorize_update({}, instance) + self.authorize_organization(instance.facility, organization) + encounter_organization = FacilityLocationOrganization.objects.filter( + location=instance, organization=organization + ) + if not encounter_organization.exists(): + raise ValidationError("Organization does not exist") + FacilityLocationOrganization.objects.filter( + encounter=instance, organization=organization + ).delete() + instance.save() # Recalculate Metadata + instance.cascade_changes() # Recalculate Metadata for children as well. + return Response({}, status=204) + + class FacilityLocationEncounterAssignSpec(BaseModel): + encounter: UUID4 + + @action(detail=True, methods=["POST"]) + def associate_encounter(self, request, *args, **kwargs): + instance = self.get_object() + facility = self.get_facility_obj() + request_data = self.FacilityLocationEncounterAssignSpec(**request.data) + encounter = get_object_or_404(Encounter, external_id=request_data.encounter) + if instance.facility_id != encounter.facility_id: + raise PermissionDenied("Encounter Incompatible with Location") + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, instance + ): + raise PermissionDenied("You do not have permission to given location") + if not AuthorizationController.call( + "can_update_encounter_obj", self.request.user, encounter + ): + raise PermissionDenied("You do not have permission to update encounter") + # TODO, Association models yet to be built + + +class FacilityLocationEncounterFilter(filters.FilterSet): + encounter = filters.UUIDFilter(field_name="encounter__external_id") + + +class FacilityLocationEncounterViewSet( + EMRCreateMixin, + EMRRetrieveMixin, + EMRUpdateMixin, + EMRListMixin, + EMRDestroyMixin, + EMRBaseViewSet, +): + database_model = FacilityLocationEncounter + pydantic_model = FacilityLocationEncounterCreateSpec + pydantic_read_model = FacilityLocationEncounterReadSpec + pydantic_update_model = FacilityLocationEncounterUpdateSpec + filterset_class = FacilityLocationEncounterFilter + filter_backends = [filters.DjangoFilterBackend] + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def get_location_obj(self): + return get_object_or_404( + FacilityLocation, external_id=self.kwargs["location_external_id"] + ) + + def authorize_update(self, request_obj, model_instance): + return self.authorize_create(model_instance) + + def authorize_destroy(self, instance): + return self.authorize_create(instance) + + def reset_encounter_location_association(self, location): + """ + Reset encounters to the right location. + """ + active_location_encounter = FacilityLocationEncounter.objects.filter( + location=location, + status=LocationEncounterAvailabilityStatusChoices.active.value, + ).first() + all_encounters = Encounter.objects.filter(current_location=location) + if active_location_encounter: + active_location_encounter.encounter.current_location = location + active_location_encounter.encounter.save(update_fields=["current_location"]) + all_encounters = all_encounters.exclude( + id=active_location_encounter.encounter_id + ) + all_encounters.update(current_location=None) + + def reset_location_availability_status(self, location): + """ + Reset location availability status to the right status. + """ + if FacilityLocationEncounter.objects.filter( + location=location, + status=LocationEncounterAvailabilityStatusChoices.active.value, + ).exists(): + location.availability_status = ( + LocationAvailabilityStatusChoices.unavailable.value + ) + else: + location.availability_status = ( + LocationAvailabilityStatusChoices.available.value + ) + location.save(update_fields=["availability_status"]) + + def authorize_create(self, instance): + facility = self.get_facility_obj() + location = self.get_location_obj() + encounter = instance.encounter + if not isinstance(instance.encounter, Encounter): + encounter = get_object_or_404(Encounter, external_id=encounter) + if location.facility_id != encounter.facility_id: + raise PermissionDenied("Encounter Incompatible with Location") + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, location + ): + raise PermissionDenied("You do not have permission to given location") + if not AuthorizationController.call( + "can_update_encounter_obj", self.request.user, encounter + ): + raise PermissionDenied("You do not have permission to update encounter") + + def perform_create(self, instance): + location = self.get_location_obj() + with Lock(f"facility_location:{location.id}"): + instance.location = location + self._validate_data(instance) + super().perform_create(instance) + self.reset_encounter_location_association(location) + self.reset_location_availability_status(location) + + def perform_update(self, instance): + location = self.get_location_obj() + with Lock(f"facility_location:{location.id}"): + # Keep in mind that instance here is an ORM instance and not pydantic + self._validate_data(instance, self.get_object()) + super().perform_update(instance) + self.reset_encounter_location_association(location) + self.reset_location_availability_status(location) + + def perform_destroy(self, instance): + super().perform_destroy(instance) + self.reset_encounter_location_association(instance.location) + self.reset_location_availability_status(instance.location) + + def _validate_data(self, instance, model_obj=None): + """ + This method will be called separately to maintain a lock when the validation is being performed + """ + location = self.get_location_obj() + if location.mode == FacilityLocationModeChoices.instance.value: + raise ValidationError("Cannot assign encounters for Location instances") + + start_datetime = instance.start_datetime + base_qs = FacilityLocationEncounter.objects.filter(location=location) + if model_obj: + # Validate if the current dates are not in conflict with other dates + end_datetime = model_obj.end_datetime + base_qs = base_qs.exclude(id=model_obj.id) + status = model_obj.status + else: + status = instance.status + end_datetime = instance.end_datetime + # Validate end time is greater than start time + if end_datetime and start_datetime > end_datetime: + raise ValidationError("End Datetime should be greater than Start Datetime") + # Completed, reserved or planned status should have end_datetime + if ( + status + in ( + LocationEncounterAvailabilityStatusChoices.completed.value, + LocationEncounterAvailabilityStatusChoices.reserved.value, + LocationEncounterAvailabilityStatusChoices.planned.value, + ) + and not end_datetime + ): + raise ValidationError("End Datetime is required for completed status") + + # Ensure that there is no conflict in the schedule + if end_datetime: + if base_qs.filter( + start_datetime__lte=end_datetime, end_datetime__gte=start_datetime + ).exists(): + raise ValidationError("Conflict in schedule") + elif base_qs.filter(start_datetime__gte=start_datetime).exists(): + raise ValidationError("Conflict in schedule") + + # Ensure that there is no other association at this point + if ( + status == LocationEncounterAvailabilityStatusChoices.active.value + and base_qs.filter( + status=LocationEncounterAvailabilityStatusChoices.active.value + ).exists() + ): + raise ValidationError( + "Another active encounter already exists for this location" + ) + + # Don't allow changes to the status once the status has reached completed + + if ( + model_obj + and model_obj.status + == LocationEncounterAvailabilityStatusChoices.completed.value + and instance.status != model_obj.status + ): + raise ValidationError("Cannot change status after marking completed") + + def get_queryset(self): + location = self.get_location_obj() + facility = self.get_facility_obj() + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, location + ): + raise PermissionDenied("You do not have permission to given location") + return FacilityLocationEncounter.objects.filter(location=location) diff --git a/care/emr/api/viewsets/medication_request.py b/care/emr/api/viewsets/medication_request.py index f96c88894b..7001f3dd61 100644 --- a/care/emr/api/viewsets/medication_request.py +++ b/care/emr/api/viewsets/medication_request.py @@ -19,8 +19,17 @@ from care.users.models import User +class StatusFilter(filters.CharFilter): + def filter(self, qs, value): + if value: + statuses = value.split(",") + return qs.filter(status__in=statuses) + return qs + + class MedicationRequestFilter(filters.FilterSet): encounter = filters.UUIDFilter(field_name="encounter__external_id") + status = StatusFilter() class MedicationRequestViewSet( diff --git a/care/emr/api/viewsets/observation.py b/care/emr/api/viewsets/observation.py index f954a80e29..510667d207 100644 --- a/care/emr/api/viewsets/observation.py +++ b/care/emr/api/viewsets/observation.py @@ -1,4 +1,5 @@ from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field from rest_framework.decorators import action from rest_framework.response import Response @@ -55,6 +56,9 @@ def get_queryset(self): return queryset.order_by("-modified_date") + @extend_schema( + request=ObservationAnalyseRequest, + ) @action(methods=["POST"], detail=False) def analyse(self, request, **kwargs): request_params = ObservationAnalyseRequest(**request.data) diff --git a/care/emr/api/viewsets/patient.py b/care/emr/api/viewsets/patient.py index 1c3e2bb009..51581f064f 100644 --- a/care/emr/api/viewsets/patient.py +++ b/care/emr/api/viewsets/patient.py @@ -2,6 +2,7 @@ from django_filters import CharFilter, FilterSet from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -81,6 +82,9 @@ class SearchRequestSpec(BaseModel): date_of_birth: datetime.date | None = None year_of_birth: int | None = None + @extend_schema( + request=SearchRequestSpec, + ) @action(detail=False, methods=["POST"]) def search(self, request, *args, **kwargs): max_page_size = 200 @@ -102,6 +106,9 @@ class SearchRetrieveRequestSpec(BaseModel): year_of_birth: int partial_id: str + @extend_schema( + request=SearchRetrieveRequestSpec, responses={200: PatientRetrieveSpec} + ) @action(detail=False, methods=["POST"]) def search_retrieve(self, request, *args, **kwargs): request_data = self.SearchRetrieveRequestSpec(**request.data) @@ -126,6 +133,7 @@ class PatientUserCreateSpec(BaseModel): user: UUID4 role: UUID4 + @extend_schema(request=PatientUserCreateSpec, responses={200: UserSpec}) @action(detail=True, methods=["POST"]) def add_user(self, request, *args, **kwargs): request_data = self.PatientUserCreateSpec(**self.request.data) @@ -141,6 +149,7 @@ def add_user(self, request, *args, **kwargs): class PatientUserDeleteSpec(BaseModel): user: UUID4 + @extend_schema(request=PatientUserDeleteSpec, responses={200: {}}) @action(detail=True, methods=["POST"]) def delete_user(self, request, *args, **kwargs): request_data = self.PatientUserDeleteSpec(**self.request.data) diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index 6273d1b6f1..b6767b22df 100644 --- a/care/emr/api/viewsets/questionnaire.py +++ b/care/emr/api/viewsets/questionnaire.py @@ -1,6 +1,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -132,6 +133,10 @@ def get_queryset(self): ) return queryset.select_related("created_by", "updated_by") + @extend_schema( + request=QuestionnaireSubmitRequest, + responses=QuestionnaireResponseReadSpec, + ) @action(detail=True, methods=["POST"]) def submit(self, request, *args, **kwargs): request_params = QuestionnaireSubmitRequest(**request.data) @@ -178,6 +183,7 @@ def get_organizations(self, request, *args, **kwargs): class QuestionnaireTagsSetSchema(BaseModel): tags: list[str] + @extend_schema(request=QuestionnaireTagsSetSchema) @action(detail=True, methods=["POST"]) def set_tags(self, request, *args, **kwargs): questionnaire = self.get_object() @@ -196,6 +202,7 @@ def set_tags(self, request, *args, **kwargs): class QuestionnaireOrganizationUpdateSchema(BaseModel): organizations: list[UUID4] + @extend_schema(request=QuestionnaireOrganizationUpdateSchema) @action(detail=True, methods=["POST"]) def set_organizations(self, request, *args, **kwargs): """ diff --git a/care/emr/api/viewsets/resource_request.py b/care/emr/api/viewsets/resource_request.py index 6981f6da53..1b87fa4eb8 100644 --- a/care/emr/api/viewsets/resource_request.py +++ b/care/emr/api/viewsets/resource_request.py @@ -1,4 +1,5 @@ from django.db.models import Q +from django_filters import rest_framework as filters from rest_framework.generics import get_object_or_404 from care.emr.api.viewsets.base import ( @@ -21,11 +22,22 @@ ) +class ResourceRequestFilters(filters.FilterSet): + origin_facility = filters.UUIDFilter(field_name="origin_facility__external_id") + approving_facility = filters.UUIDFilter( + field_name="approving_facility__external_id" + ) + assigned_facility = filters.UUIDFilter(field_name="assigned_facility__external_id") + related_patient = filters.UUIDFilter(field_name="related_patient__external_id") + + class ResourceRequestViewSet(EMRModelViewSet): database_model = ResourceRequest pydantic_model = ResourceRequestCreateSpec pydantic_read_model = ResourceRequestListSpec pydantic_retrieve_model = ResourceRequestRetrieveSpec + filterset_class = ResourceRequestFilters + filter_backends = [filters.DjangoFilterBackend] @classmethod def build_queryset(cls, queryset, user): diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index af5300cd0a..41815002b3 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -60,9 +60,10 @@ def perform_destroy(self, instance): with Lock(f"booking:resource:{instance.resource.id}"), transaction.atomic(): # Check if there are any tokens allocated for this schedule in the future availabilities = instance.availability_set.all() + availability_ids = list(availabilities.values_list("id")) has_future_bookings = TokenSlot.objects.filter( resource=instance.resource, - availability_id__in=availabilities.values_list("id", flat=True), + availability_id__in=availability_ids, start_datetime__gt=timezone.now(), allocated__gt=0, ).exists() @@ -71,10 +72,10 @@ def perform_destroy(self, instance): "Cannot delete schedule as there are future bookings associated with it" ) availabilities.update(deleted=True) - TokenSlot.objects.filter( - resource=instance.resource, - availability_id__in=availabilities.values_list("id", flat=True), - ).update(deleted=True) + slots = TokenSlot.objects.filter( + resource=instance.resource, availability_id__in=availability_ids + ) + slots.update(deleted=True) super().perform_destroy(instance) def validate_data(self, instance, model_obj=None): @@ -179,6 +180,7 @@ def perform_destroy(self, instance): raise ValidationError( "Cannot delete availability as there are future bookings associated with it" ) + TokenSlot.objects.filter(availability_id=instance.id).update(deleted=True) super().perform_destroy(instance) def authorize_create(self, instance): diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index ad9e35851b..97d2f19a9f 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -98,25 +98,27 @@ def check_availability(self, request, username): return Response(status=200) @method_decorator(parser_classes([MultiPartParser])) - @action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated]) + @action( + detail=True, methods=["POST", "DELETE"], permission_classes=[IsAuthenticated] + ) def profile_picture(self, request, *args, **kwargs): user = self.get_object() if not self.authorize_update({}, user): raise PermissionDenied("Permission Denied") - serializer = UserImageUploadSerializer(user, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(status=200) - @profile_picture.mapping.delete - def profile_picture_delete(self, request, *args, **kwargs): - user = self.get_object() - if not self.authorize_update({}, user): - raise PermissionDenied("Permission Denied") - delete_cover_image(user.profile_picture_url, "avatars") - user.profile_picture_url = None - user.save() - return Response(status=204) + if request.method == "POST": + serializer = UserImageUploadSerializer(user, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=200) + if request.method == "DELETE": + if not user.profile_picture_url: + return Response({"detail": "No cover image to delete"}, status=404) + delete_cover_image(user.profile_picture_url, "avatars") + user.profile_picture_url = None + user.save() + return Response(status=204) + return Response({"detail": "Method not allowed"}, status=405) @action( detail=True, diff --git a/care/emr/apps.py b/care/emr/apps.py index 52b407ce5d..1244982f5c 100644 --- a/care/emr/apps.py +++ b/care/emr/apps.py @@ -5,3 +5,6 @@ class EMRConfig(AppConfig): name = "care.emr" verbose_name = _("Electronic Medical Record") + + def ready(self): + pass diff --git a/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py b/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py new file mode 100644 index 0000000000..4cd3d8df19 --- /dev/null +++ b/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.4 on 2025-01-26 20:41 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0012_alter_condition_encounter'), + ('facility', '0476_facility_default_internal_organization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FacilityLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('status', models.CharField(max_length=255)), + ('operational_status', models.CharField(max_length=255)), + ('name', models.CharField(max_length=255)), + ('description', models.CharField(max_length=255)), + ('mode', models.CharField(max_length=255)), + ('location_type', models.JSONField(blank=True, default=dict, null=True)), + ('form', models.CharField(max_length=255)), + ('facility_organization_cache', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)), + ('has_children', models.BooleanField(default=False)), + ('level_cache', models.IntegerField(default=0)), + ('parent_cache', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)), + ('metadata', models.JSONField(default=dict)), + ('cached_parent_json', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='facility.facility')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.facilitylocation')), + ('root_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='root', to='emr.facilitylocation')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FacilityLocationOrganization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilitylocation')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilityorganization')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py b/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py new file mode 100644 index 0000000000..6ba8773e5b --- /dev/null +++ b/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.4 on 2025-01-26 21:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0013_facilitylocation_facilitylocationorganization'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='encounter', + name='current_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.facilitylocation'), + ), + migrations.CreateModel( + name='FacilityLocationEncounter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('start_datetime', models.DateTimeField()), + ('end_datetime', models.DateTimeField(blank=True, default=None, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('encounter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.encounter')), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilitylocation')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py b/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py new file mode 100644 index 0000000000..33ef73dfa0 --- /dev/null +++ b/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.4 on 2025-01-27 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0014_encounter_current_location_facilitylocationencounter'), + ] + + operations = [ + migrations.AddField( + model_name='facilitylocation', + name='availability_status', + field=models.CharField(default='', max_length=25), + preserve_default=False, + ), + migrations.AddField( + model_name='facilitylocationencounter', + name='status', + field=models.CharField(default='', max_length=25), + preserve_default=False, + ), + ] diff --git a/care/emr/models/__init__.py b/care/emr/models/__init__.py index ef2f4a6b99..2fd98a5daf 100644 --- a/care/emr/models/__init__.py +++ b/care/emr/models/__init__.py @@ -7,3 +7,4 @@ from .encounter import * # noqa F403 from .patient import * # noqa F403 from .file_upload import * # noqa F403 +from .location import * # noqa F403 diff --git a/care/emr/models/encounter.py b/care/emr/models/encounter.py index 0e018919e1..9fbdeb3fef 100644 --- a/care/emr/models/encounter.py +++ b/care/emr/models/encounter.py @@ -21,6 +21,10 @@ class Encounter(EMRBaseModel): # Organization fields facility_organization_cache = ArrayField(models.IntegerField(), default=list) + current_location = models.ForeignKey( + "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True + ) # Cached field, used for easier querying + def sync_organization_cache(self): orgs = set() for encounter_organization in EncounterOrganization.objects.filter( diff --git a/care/emr/models/location.py b/care/emr/models/location.py new file mode 100644 index 0000000000..1bc353b83f --- /dev/null +++ b/care/emr/models/location.py @@ -0,0 +1,157 @@ +from datetime import datetime, timedelta + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils import timezone + +from care.emr.models import EMRBaseModel, Encounter, FacilityOrganization +from config.celery_app import app + + +class FacilityLocation(EMRBaseModel): + status = models.CharField(max_length=255) + operational_status = models.CharField(max_length=255) + name = models.CharField(max_length=255) + availability_status = models.CharField( + max_length=25 + ) # Populated from FacilityLocationEncounter + description = models.CharField(max_length=255) + mode = models.CharField(max_length=255) + location_type = models.JSONField(default=dict, null=True, blank=True) + form = models.CharField(max_length=255) + facility_organization_cache = ArrayField(models.IntegerField(), default=list) + facility = models.ForeignKey("facility.Facility", on_delete=models.PROTECT) + parent = models.ForeignKey( + "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True + ) + has_children = models.BooleanField(default=False) + level_cache = models.IntegerField(default=0) + parent_cache = ArrayField(models.IntegerField(), default=list) + metadata = models.JSONField(default=dict) + cached_parent_json = models.JSONField(default=dict) + root_location = models.ForeignKey( + "self", on_delete=models.CASCADE, related_name="root", null=True, blank=True + ) + cache_expiry_days = 15 + + def get_parent_json(self): + from care.emr.resources.location.spec import FacilityLocationListSpec + + if self.parent_id: + if self.cached_parent_json and timezone.now() < datetime.fromisoformat( + self.cached_parent_json["cache_expiry"] + ): + return self.cached_parent_json + self.parent.get_parent_json() + temp_data = FacilityLocationListSpec.serialize(self.parent).to_json() + temp_data["cache_expiry"] = str( + timezone.now() + timedelta(days=self.cache_expiry_days) + ) + self.cached_parent_json = temp_data + super().save(update_fields=["cached_parent_json"]) + return self.cached_parent_json + return {} + + @classmethod + def validate_uniqueness(cls, queryset, pydantic_instance, model_instance): + if model_instance: + name = model_instance.name + level_cache = model_instance.level_cache + root_location = model_instance.root_location + queryset = queryset.exclude(id=model_instance.id) + else: + name = pydantic_instance.name + if pydantic_instance.parent: + parent = cls.objects.get(external_id=pydantic_instance.parent) + level_cache = parent.level_cache + 1 + root_location = parent.root_location + if not root_location: + root_location = parent + else: + level_cache = 0 + root_location = None + if root_location: + queryset = queryset.filter(root_location=root_location) + else: + queryset = queryset.filter(root_location__isnull=True) + queryset = queryset.filter(level_cache=level_cache, name=name) + return queryset.exists() + + def sync_organization_cache(self): + orgs = set() + for encounter_organization in FacilityLocationOrganization.objects.filter( + location=self + ): + orgs = orgs.union( + { + *encounter_organization.organization.parent_cache, + encounter_organization.organization.id, + } + ) + + facility_root_org = FacilityOrganization.objects.filter( + org_type="root", facility=self.facility + ).first() + if facility_root_org: + orgs = orgs.union({facility_root_org.id}) + + self.facility_organization_cache = list(orgs) + super().save(update_fields=["facility_organization_cache"]) + + def save(self, *args, **kwargs): + if not self.id: + super().save(*args, **kwargs) + if self.parent: + self.level_cache = self.parent.level_cache + 1 + if self.parent.root_location is None: + self.root_location = self.parent + else: + self.root_location = self.parent.root_location + if not self.parent.has_children: + self.parent.has_children = True + self.parent.save(update_fields=["has_children"]) + else: + self.cached_parent_json = {} + super().save(*args, **kwargs) + self.sync_organization_cache() + + def cascade_changes(self): + handle_cascade.delay(self.id) + + +class FacilityLocationOrganization(EMRBaseModel): + """ + This relation denotes which organization can access a given Facility Location + """ + + location = models.ForeignKey(FacilityLocation, on_delete=models.CASCADE) + organization = models.ForeignKey( + "emr.FacilityOrganization", on_delete=models.CASCADE + ) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + handle_cascade.delay(self.location.id) + + +class FacilityLocationEncounter(EMRBaseModel): + """ + This relation denotes how a bed was associated to an encounter + """ + + status = models.CharField(max_length=25) + location = models.ForeignKey(FacilityLocation, on_delete=models.CASCADE) + encounter = models.ForeignKey(Encounter, on_delete=models.CASCADE) + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField(default=None, null=True, blank=True) + + +@app.task +def handle_cascade(base_location): + """ + Cascade changes to a location organization to all its children + """ + + for child in FacilityLocation.objects.filter(parent_location_id=base_location): + child.save(update_fields=["cached_parent_json"]) + handle_cascade(child) diff --git a/care/emr/models/organization.py b/care/emr/models/organization.py index dc1e8609e9..1cef2a8eac 100644 --- a/care/emr/models/organization.py +++ b/care/emr/models/organization.py @@ -46,8 +46,7 @@ def get_parent_json(self): self.cached_parent_json["cache_expiry"] ): return self.cached_parent_json - if self.parent: - self.parent.get_parent_json() + self.parent.get_parent_json() self.cached_parent_json = { "id": str(self.parent.external_id), "name": self.parent.name, diff --git a/care/emr/resources/base.py b/care/emr/resources/base.py index 830a2f25c6..6d016185b1 100644 --- a/care/emr/resources/base.py +++ b/care/emr/resources/base.py @@ -145,6 +145,15 @@ def as_questionnaire(cls, parent_classes=None): # noqa PLR0912 def to_json(self): return self.model_dump(mode="json", exclude=["meta"]) + @classmethod + def serialize_audit_users(cls, mapping, obj): + from care.emr.resources.user.spec import UserSpec + + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by) + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + PhoneNumber = Annotated[ Union[str, phonenumbers.PhoneNumber()], # noqa: UP007 diff --git a/care/emr/resources/common/quantity.py b/care/emr/resources/common/quantity.py index 9d33d343d2..5eea313340 100644 --- a/care/emr/resources/common/quantity.py +++ b/care/emr/resources/common/quantity.py @@ -15,6 +15,7 @@ class Quantity(BaseModel): ) unit: Coding | None = Field(None, description="A human-readable form of the unit.") meta: dict | None = Field(None) - code: Coding = Field( + code: Coding | None = Field( + None, description="A computer processable form of the unit in some unit representation system.", ) diff --git a/care/emr/resources/encounter/spec.py b/care/emr/resources/encounter/spec.py index bd4e6ddf02..48a31006c2 100644 --- a/care/emr/resources/encounter/spec.py +++ b/care/emr/resources/encounter/spec.py @@ -17,6 +17,7 @@ ) from care.emr.resources.facility.spec import FacilityBareMinimumSpec from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec +from care.emr.resources.location.spec import FacilityLocationListSpec from care.emr.resources.patient.spec import PatientListSpec from care.emr.resources.scheduling.slot.spec import TokenBookingReadSpec from care.emr.resources.user.spec import UserSpec @@ -43,7 +44,13 @@ class HospitalizationSpec(BaseModel): class EncounterSpecBase(EMRResource): __model__ = Encounter - __exclude__ = ["patient", "organizations", "facility", "appointment"] + __exclude__ = [ + "patient", + "organizations", + "facility", + "appointment", + "current_location", + ] id: UUID4 = None status: StatusChoices @@ -110,6 +117,7 @@ class EncounterRetrieveSpec(EncounterListSpec): created_by: dict = {} updated_by: dict = {} organizations: list[dict] = [] + current_location: dict | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -123,6 +131,11 @@ def perform_extra_serialization(cls, mapping, obj): FacilityOrganizationReadSpec.serialize(encounter_org.organization).to_json() for encounter_org in organizations ] + mapping["current_location"] = None + if obj.current_location: + mapping["current_location"] = FacilityLocationListSpec.serialize( + obj.current_location + ).to_json() if obj.created_by: mapping["created_by"] = UserSpec.serialize(obj.created_by) if obj.updated_by: diff --git a/care/emr/resources/location/__init__.py b/care/emr/resources/location/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/location/spec.py b/care/emr/resources/location/spec.py new file mode 100644 index 0000000000..f6f9e0b904 --- /dev/null +++ b/care/emr/resources/location/spec.py @@ -0,0 +1,176 @@ +import datetime +from enum import Enum + +from pydantic import UUID4, model_validator + +from care.emr.models import Encounter, FacilityLocationEncounter +from care.emr.models.location import FacilityLocation +from care.emr.resources.base import EMRResource +from care.emr.resources.common import Coding +from care.emr.resources.user.spec import UserSpec + + +class LocationEncounterAvailabilityStatusChoices(str, Enum): + planned = "planned" + active = "active" + reserved = "reserved" + completed = "completed" + + +class LocationAvailabilityStatusChoices(str, Enum): + available = "available" + unavailable = "unavailable" + + +class StatusChoices(str, Enum): + active = "active" + inactive = "inactive" + unknown = "unknown" + + +class FacilityLocationOperationalStatusChoices(str, Enum): + C = "C" + H = "H" + O = "O" # noqa E741 + U = "U" + K = "K" + I = "I" # noqa E741 + + +class FacilityLocationModeChoices(str, Enum): + instance = "instance" + kind = "kind" + + +class FacilityLocationFormChoices(str, Enum): + si = "si" + bu = "bu" + wi = "wi" + wa = "wa" + lvl = "lvl" + co = "co" + ro = "ro" + bd = "bd" + ve = "ve" + ho = "ho" + ca = "ca" + rd = "rd" + area = "area" + jdn = "jdn" + vi = "vi" + + +class FacilityLocationBaseSpec(EMRResource): + __model__ = FacilityLocation + __exclude__ = ["parent", "facility", "organizations", "root_location"] + + id: UUID4 | None = None + + +class FacilityLocationSpec(FacilityLocationBaseSpec): + status: StatusChoices + operational_status: FacilityLocationOperationalStatusChoices + name: str + description: str + location_type: Coding | None = None + form: FacilityLocationFormChoices + + +class FacilityLocationUpdateSpec(FacilityLocationSpec): + pass + + +class FacilityLocationWriteSpec(FacilityLocationSpec): + parent: UUID4 | None = None + organizations: list[UUID4] + mode: FacilityLocationModeChoices + + @model_validator(mode="after") + def validate_parent_organization(self): + if ( + self.parent + and not FacilityLocation.objects.filter( + external_id=self.parent, mode=FacilityLocationModeChoices.kind.value + ).exists() + ): + err = "Parent not found" + raise ValueError(err) + return self + + def perform_extra_deserialization(self, is_update, obj): + if self.parent: + obj.parent = FacilityLocation.objects.get(external_id=self.parent) + else: + obj.parent = None + obj.availability_status = LocationAvailabilityStatusChoices.available.value + + +class FacilityLocationListSpec(FacilityLocationSpec): + parent: dict + mode: str + has_children: bool + availability_status: str + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + mapping["parent"] = obj.get_parent_json() + + +class FacilityLocationRetrieveSpec(FacilityLocationListSpec): + created_by: dict | None = None + updated_by: dict | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + super().perform_extra_serialization(mapping, obj) + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by) + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + + +class FacilityLocationEncounterBaseSpec(EMRResource): + __model__ = FacilityLocationEncounter + __exclude__ = ["encounter", "location"] + + id: UUID4 | None = None + + +class FacilityLocationEncounterCreateSpec(FacilityLocationEncounterBaseSpec): + status: LocationEncounterAvailabilityStatusChoices + encounter: UUID4 + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + + @model_validator(mode="after") + def validate_encounter(self): + if not Encounter.objects.filter(external_id=self.encounter).exists(): + err = "Encounter not found" + raise ValueError(err) + return self + + def perform_extra_deserialization(self, is_update, obj): + obj.encounter = Encounter.objects.get(external_id=self.encounter) + + +class FacilityLocationEncounterUpdateSpec(FacilityLocationEncounterBaseSpec): + status: LocationEncounterAvailabilityStatusChoices + + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + + +class FacilityLocationEncounterReadSpec(FacilityLocationEncounterBaseSpec): + encounter: UUID4 + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + status: str + + created_by: dict | None = None + updated_by: dict | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + cls.serialize_audit_users(mapping, obj) diff --git a/care/emr/resources/medication/administration/spec.py b/care/emr/resources/medication/administration/spec.py index 360da30246..57e7c6ae32 100644 --- a/care/emr/resources/medication/administration/spec.py +++ b/care/emr/resources/medication/administration/spec.py @@ -3,12 +3,12 @@ from pydantic import UUID4, BaseModel, Field, field_validator -from care.emr.fhir.schema.base import Coding, Quantity from care.emr.models.encounter import Encounter from care.emr.models.medication_administration import MedicationAdministration from care.emr.models.medication_request import MedicationRequest from care.emr.registries.care_valueset.care_valueset import validate_valueset from care.emr.resources.base import EMRResource +from care.emr.resources.common import Coding, Quantity from care.emr.resources.medication.valueset.administration_method import ( CARE_ADMINISTRATION_METHOD_VALUESET, ) diff --git a/care/emr/tests/test_schedule_api.py b/care/emr/tests/test_schedule_api.py index 2663a93538..894dfed579 100644 --- a/care/emr/tests/test_schedule_api.py +++ b/care/emr/tests/test_schedule_api.py @@ -213,16 +213,12 @@ def test_update_schedule_with_permissions(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - # First create a schedule - schedule = self.create_schedule() - - # Then update it updated_data = { "name": "Updated Schedule Name", - "valid_from": schedule.valid_from, - "valid_to": schedule.valid_to, + "valid_from": self.schedule.valid_from, + "valid_to": self.schedule.valid_to, } - update_url = self._get_schedule_url(schedule.external_id) + update_url = self._get_schedule_url(self.schedule.external_id) response = self.client.put(update_url, updated_data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["name"], "Updated Schedule Name") @@ -236,14 +232,12 @@ def test_update_schedule_without_permissions(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - schedule = self.create_schedule() - updated_data = { "name": "Updated Schedule Name", - "valid_from": schedule.valid_from, - "valid_to": schedule.valid_to, + "valid_from": self.schedule.valid_from, + "valid_to": self.schedule.valid_to, } - update_url = self._get_schedule_url(schedule.external_id) + update_url = self._get_schedule_url(self.schedule.external_id) response = self.client.put(update_url, updated_data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -257,11 +251,16 @@ def test_delete_schedule_with_permissions(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - schedule = self.create_schedule() - delete_url = self._get_schedule_url(schedule.external_id) + delete_url = self._get_schedule_url(self.schedule.external_id) response = self.client.delete(delete_url) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.availability.refresh_from_db() + self.slot.refresh_from_db() + + self.assertTrue(self.availability.deleted) + self.assertTrue(self.slot.deleted) + def test_delete_schedule_without_permissions(self): """Users without can_write_user_schedule permission cannot delete schedules.""" # First create a schedule with permissions @@ -271,8 +270,7 @@ def test_delete_schedule_without_permissions(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - schedule = self.create_schedule() - delete_url = self._get_schedule_url(schedule.external_id) + delete_url = self._get_schedule_url(self.schedule.external_id) response = self.client.delete(delete_url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -631,6 +629,8 @@ def setUp(self): facility=self.facility, ) self.schedule = self.create_schedule() + self.availability = self.create_availability() + self.slot = self.create_slot() self.base_url = reverse( "schedule-availability-list", @@ -687,6 +687,17 @@ def create_availability(self, **kwargs): ), ) + def create_slot(self, **kwargs): + data = { + "resource": self.resource, + "availability": self.availability, + "start_datetime": datetime.now(UTC) + timedelta(minutes=30), + "end_datetime": datetime.now(UTC) + timedelta(minutes=60), + "allocated": 0, + } + data.update(kwargs) + return TokenSlot.objects.create(**data) + def generate_availability_data(self, **kwargs): """Helper to generate valid availability data.""" return { @@ -732,26 +743,29 @@ def test_delete_availability_with_permissions(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - availability = self.create_availability() - delete_url = self._get_availability_url(availability.external_id) + delete_url = self._get_availability_url(self.availability.external_id) response = self.client.delete(delete_url) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.availability.refresh_from_db() + self.slot.refresh_from_db() + + self.assertTrue(self.availability.deleted) + self.assertTrue(self.slot.deleted) + def test_delete_availability_without_permissions(self): """Users without can_write_user_schedule permission cannot delete availability.""" permissions = [UserSchedulePermissions.can_list_user_schedule.name] role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - availability = self.create_availability() - delete_url = self._get_availability_url(availability.external_id) + delete_url = self._get_availability_url(self.availability.external_id) response = self.client.delete(delete_url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_availability_without_queryset_list_permissions(self): """Users without can_list_user_schedule permission cannot delete availability.""" - availability = self.create_availability() - delete_url = self._get_availability_url(availability.external_id) + delete_url = self._get_availability_url(self.availability.external_id) response = self.client.delete(delete_url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -764,10 +778,9 @@ def test_delete_availability_with_future_bookings(self): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user(self.organization, self.user, role) - availability = self.create_availability() token_slot = TokenSlot.objects.create( resource=self.resource, - availability=availability, + availability=self.availability, start_datetime=datetime.now(UTC) + timedelta(days=4), end_datetime=datetime.now(UTC) + timedelta(days=5), ) @@ -778,7 +791,7 @@ def test_delete_availability_with_future_bookings(self): ) token_slot.allocated = 1 token_slot.save() - delete_url = self._get_availability_url(availability.external_id) + delete_url = self._get_availability_url(self.availability.external_id) response = self.client.delete(delete_url) self.assertContains( response, diff --git a/care/security/authorization/__init__.py b/care/security/authorization/__init__.py index 1fe9ef9aad..80fd8c7fa3 100644 --- a/care/security/authorization/__init__.py +++ b/care/security/authorization/__init__.py @@ -7,3 +7,4 @@ from .facility import * # noqa from .user import * # noqa from .user_schedule import * # noqa +from .facility_location import * # noqa diff --git a/care/security/authorization/encounter.py b/care/security/authorization/encounter.py index 91e26d3e03..47eceadcb0 100644 --- a/care/security/authorization/encounter.py +++ b/care/security/authorization/encounter.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from care.emr.models.organization import FacilityOrganizationUser from care.emr.resources.encounter.constants import COMPLETED_CHOICES from care.security.authorization.base import ( @@ -20,10 +22,14 @@ def can_view_encounter_obj(self, user, encounter): """ Check if the user has permission to read encounter under this facility """ + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) + return self.check_permission_in_facility_organization( [EncounterPermissions.can_read_encounter.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def can_submit_encounter_questionnaire_obj(self, user, encounter): @@ -33,10 +39,15 @@ def can_submit_encounter_questionnaire_obj(self, user, encounter): if encounter.status in COMPLETED_CHOICES: # Cannot write to a closed encounter return False + + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) + return self.check_permission_in_facility_organization( [EncounterPermissions.can_submit_encounter_questionnaire.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def can_update_encounter_obj(self, user, encounter): @@ -46,10 +57,13 @@ def can_update_encounter_obj(self, user, encounter): if encounter.status in COMPLETED_CHOICES: # Cannot write to a closed encounter return False + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) return self.check_permission_in_facility_organization( [EncounterPermissions.can_write_encounter.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def get_filtered_encounters(self, qs, user, facility): @@ -63,7 +77,10 @@ def get_filtered_encounters(self, qs, user, facility): user=user, organization__facility=facility, role_id__in=roles ).values_list("organization_id", flat=True) ) - return qs.filter(facility_organization_cache__overlap=organization_ids) + return qs.filter( + Q(facility_organization_cache__overlap=organization_ids) + | Q(current_location__facility_organization_cache__overlap=organization_ids) + ) AuthorizationController.register_internal_controller(EncounterAccess) diff --git a/care/security/authorization/facility_location.py b/care/security/authorization/facility_location.py new file mode 100644 index 0000000000..2ed3b209ea --- /dev/null +++ b/care/security/authorization/facility_location.py @@ -0,0 +1,70 @@ +from care.emr.models import FacilityOrganization +from care.emr.models.organization import FacilityOrganizationUser +from care.security.authorization.base import ( + AuthorizationController, + AuthorizationHandler, +) +from care.security.permissions.facility_organization import ( + FacilityOrganizationPermissions, +) +from care.security.permissions.location import FacilityLocationPermissions + + +class FacilityLocationAccess(AuthorizationHandler): + def can_list_facility_location_obj(self, user, facility, location): + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_list_facility_locations.name], + user, + facility=facility, + orgs=location.facility_organization_cache, + ) + + def can_create_facility_location_obj(self, user, location, facility): + """ + Check if the user has permission to create locations under the given location + """ + + if location: + # If a parent is present then the user should have permission to create locations under the parent + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_write_facility_locations.name], + user, + location.facility_organization_cache, + ) + # If no parent exists, the user must have sufficient permissions in the root organization + root_organization = FacilityOrganization.objects.get( + facility=facility, org_type="root" + ) + return self.check_permission_in_facility_organization( + [FacilityOrganizationPermissions.can_create_facility_organization.name], + user, + [root_organization.id], + ) + + def can_update_facility_location_obj(self, user, location): + """ + Check if the user has permission to write locations under the given location + """ + + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_write_facility_locations.name], + user, + location.facility_organization_cache, + ) + + def get_accessible_facility_locations(self, qs, user, facility): + if user.is_superuser: + return qs + + roles = self.get_role_from_permissions( + [FacilityLocationPermissions.can_list_facility_locations.name] + ) + organization_ids = list( + FacilityOrganizationUser.objects.filter( + user=user, organization__facility=facility, role_id__in=roles + ).values_list("organization_id", flat=True) + ) + return qs.filter(facility_organization_cache__overlap=organization_ids) + + +AuthorizationController.register_internal_controller(FacilityLocationAccess) diff --git a/care/security/authorization/patient.py b/care/security/authorization/patient.py index 707f6d18ca..5485d4b73d 100644 --- a/care/security/authorization/patient.py +++ b/care/security/authorization/patient.py @@ -18,11 +18,18 @@ def find_roles_on_patient(self, user, patient): encounters = ( Encounter.objects.filter(patient=patient) .exclude(status__in=COMPLETED_CHOICES) - .values_list("facility_organization_cache", flat=True) + .values_list( + "facility_organization_cache", + "current_location__facility_organization_cache", + ) ) encounter_set = set() for encounter in encounters: - encounter_set = encounter_set.union(set(encounter)) + encounter_set = encounter_set.union(set(encounter[0])) + # Through Location + if encounter[1]: + encounter_set = encounter_set.union(set(encounter[1])) + # Find roles based on Location and roles = FacilityOrganizationUser.objects.filter( organization_id__in=encounter_set, user=user ).values_list("role_id", flat=True) diff --git a/care/security/permissions/base.py b/care/security/permissions/base.py index 6c7eedd0f4..55596c6fd9 100644 --- a/care/security/permissions/base.py +++ b/care/security/permissions/base.py @@ -1,9 +1,9 @@ -from care.security.models import RoleAssociation, RolePermission from care.security.permissions.encounter import EncounterPermissions from care.security.permissions.facility import FacilityPermissions from care.security.permissions.facility_organization import ( FacilityOrganizationPermissions, ) +from care.security.permissions.location import FacilityLocationPermissions from care.security.permissions.organization import OrganizationPermissions from care.security.permissions.patient import PatientPermissions from care.security.permissions.questionnaire import QuestionnairePermissions @@ -33,6 +33,7 @@ class PermissionController: PatientPermissions, UserPermissions, UserSchedulePermissions, + FacilityLocationPermissions, ] cache = {} @@ -48,29 +49,6 @@ def build_cache(cls): for permission in handler: cls.cache[permission.name] = permission.value - @classmethod - def has_permission(cls, user, permission, context, context_id): - # TODO : Cache permissions and invalidate when they change - # TODO : Fetch the user role from the previous role management implementation as well. - # Need to maintain some sort of mapping from previous generation to new generation of roles - from care.security.roles.role import RoleController - - mapped_role = RoleController.map_old_role_to_new(user.role) - permission_roles = RolePermission.objects.filter( - permission__slug=permission, permission__context=context - ).values("role_id") - if RoleAssociation.objects.filter( - context_id=context_id, context=context, role__in=permission_roles, user=user - ).exists(): - return True - # Check for old cases - return RolePermission.objects.filter( - permission__slug=permission, - permission__context=context, - role__name=mapped_role.name, - role__context=mapped_role.context.value, - ).exists() - @classmethod def get_permissions(cls): if not cls.cache: diff --git a/care/security/permissions/location.py b/care/security/permissions/location.py new file mode 100644 index 0000000000..4fd81ff046 --- /dev/null +++ b/care/security/permissions/location.py @@ -0,0 +1,45 @@ +import enum + +from care.security.permissions.constants import Permission, PermissionContext +from care.security.roles.role import ( + ADMIN_ROLE, + DOCTOR_ROLE, + FACILITY_ADMIN_ROLE, + GEO_ADMIN, + NURSE_ROLE, + STAFF_ROLE, +) + + +class FacilityLocationPermissions(enum.Enum): + can_list_facility_locations = Permission( + "Can List Facility Locations", + "", + PermissionContext.FACILITY, + [ + ADMIN_ROLE, + DOCTOR_ROLE, + FACILITY_ADMIN_ROLE, + GEO_ADMIN, + NURSE_ROLE, + STAFF_ROLE, + ], + ) + can_write_facility_locations = Permission( + "Can Create/Update Facility Locations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) + can_list_facility_location_organizations = Permission( + "Can List Facility Location Organizations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) + can_create_facility_location_organizations = Permission( + "Can Create/Update Facility Location Organizations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) diff --git a/care/templates/base.html b/care/templates/base.html index a2d766b9ec..00f4845933 100644 --- a/care/templates/base.html +++ b/care/templates/base.html @@ -5,10 +5,11 @@ - {% block title %}Care{% endblock title %} + {% block title %}Open HealthCare Network | Care{% endblock title %} + - @@ -50,14 +50,6 @@
- {% if messages %} - {% for message in messages %} -
{{ message }} -
- {% endfor %} - {% endif %} - {% block content %} {% endblock content %} @@ -71,10 +63,6 @@ {% block javascript %} - - - - @@ -82,17 +70,6 @@ {% endblock javascript %} - -