this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge Changes

+4124 -2120
+2 -1
CONTRIBUTING.md
··· 44 44 45 45 ```bash 46 46 sudo -u postgres psql 47 - CREATE DATABASE care; 47 + CREATE ROLE my_username LOGIN PASSWORD 'my_password'; 48 + CREATE DATABASE care WITH OWNER = my_username; 48 49 ``` 49 50 put the following in your `.env` file 50 51 ```bash
+6
Pipfile
··· 3 3 verify_ssl = true 4 4 name = "pypi" 5 5 6 + [[source]] 7 + url = "https://github.com/ohcnetwork/python-magic-bin/releases/expanded_assets/v0.1" 8 + verify_ssl = true 9 + name = "python-magic-bin" 10 + 6 11 [packages] 7 12 argon2-cffi = "==23.1.0" 8 13 authlib = "==1.4.0" ··· 45 50 django-anymail = {extras = ["amazon-ses"], version = "*"} 46 51 pydantic-extra-types = "2.10.2" 47 52 phonenumberslite = "==8.13.54" 53 + python-magic = {version = "==0.4.28", index = "python-magic-bin"} 48 54 49 55 [dev-packages] 50 56 boto3-stubs = { extras = ["s3", "boto3"], version = "*" }
+408 -363
Pipfile.lock
··· 1 1 { 2 2 "_meta": { 3 3 "hash": { 4 - "sha256": "16393b45f30caac84caa5f467aa52bbf7d803a786442c125e5b673f081f3cec5" 4 + "sha256": "275d7de966e81cfc641ecb4a57b8c212ae879489d6aa43f64d32edddbd79a3ca" 5 5 }, 6 6 "pipfile-spec": 6, 7 7 "requires": { ··· 12 12 "name": "pypi", 13 13 "url": "https://pypi.org/simple", 14 14 "verify_ssl": true 15 + }, 16 + { 17 + "name": "python-magic-bin", 18 + "url": "https://github.com/ohcnetwork/python-magic-bin/releases/expanded_assets/v0.1", 19 + "verify_ssl": true 15 20 } 16 21 ] 17 22 }, 18 23 "default": { 19 24 "aiohappyeyeballs": { 20 25 "hashes": [ 21 - "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", 22 - "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8" 26 + "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", 27 + "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" 23 28 ], 24 - "markers": "python_version >= '3.8'", 25 - "version": "==2.4.4" 29 + "markers": "python_version >= '3.9'", 30 + "version": "==2.6.1" 26 31 }, 27 32 "aiohttp": { 28 33 "hashes": [ 29 - "sha256:0450ada317a65383b7cce9576096150fdb97396dcfe559109b403c7242faffef", 30 - "sha256:0b5263dcede17b6b0c41ef0c3ccce847d82a7da98709e75cf7efde3e9e3b5cae", 31 - "sha256:0d5176f310a7fe6f65608213cc74f4228e4f4ce9fd10bcb2bb6da8fc66991462", 32 - "sha256:0ed49efcd0dc1611378beadbd97beb5d9ca8fe48579fc04a6ed0844072261b6a", 33 - "sha256:145a73850926018ec1681e734cedcf2716d6a8697d90da11284043b745c286d5", 34 - "sha256:1987770fb4887560363b0e1a9b75aa303e447433c41284d3af2840a2f226d6e0", 35 - "sha256:246067ba0cf5560cf42e775069c5d80a8989d14a7ded21af529a4e10e3e0f0e6", 36 - "sha256:2c311e2f63e42c1bf86361d11e2c4a59f25d9e7aabdbdf53dc38b885c5435cdb", 37 - "sha256:2cee3b117a8d13ab98b38d5b6bdcd040cfb4181068d05ce0c474ec9db5f3c5bb", 38 - "sha256:2de1378f72def7dfb5dbd73d86c19eda0ea7b0a6873910cc37d57e80f10d64e1", 39 - "sha256:30f546358dfa0953db92ba620101fefc81574f87b2346556b90b5f3ef16e55ce", 40 - "sha256:34245498eeb9ae54c687a07ad7f160053911b5745e186afe2d0c0f2898a1ab8a", 41 - "sha256:392432a2dde22b86f70dd4a0e9671a349446c93965f261dbaecfaf28813e5c42", 42 - "sha256:3c0600bcc1adfaaac321422d615939ef300df81e165f6522ad096b73439c0f58", 43 - "sha256:4016e383f91f2814e48ed61e6bda7d24c4d7f2402c75dd28f7e1027ae44ea204", 44 - "sha256:40cd36749a1035c34ba8d8aaf221b91ca3d111532e5ccb5fa8c3703ab1b967ed", 45 - "sha256:413ad794dccb19453e2b97c2375f2ca3cdf34dc50d18cc2693bd5aed7d16f4b9", 46 - "sha256:4a93d28ed4b4b39e6f46fd240896c29b686b75e39cc6992692e3922ff6982b4c", 47 - "sha256:4ee84c2a22a809c4f868153b178fe59e71423e1f3d6a8cd416134bb231fbf6d3", 48 - "sha256:50c5c7b8aa5443304c55c262c5693b108c35a3b61ef961f1e782dd52a2f559c7", 49 - "sha256:525410e0790aab036492eeea913858989c4cb070ff373ec3bc322d700bdf47c1", 50 - "sha256:526c900397f3bbc2db9cb360ce9c35134c908961cdd0ac25b1ae6ffcaa2507ff", 51 - "sha256:54775858c7f2f214476773ce785a19ee81d1294a6bedc5cc17225355aab74802", 52 - "sha256:584096938a001378484aa4ee54e05dc79c7b9dd933e271c744a97b3b6f644957", 53 - "sha256:6130459189e61baac5a88c10019b21e1f0c6d00ebc770e9ce269475650ff7f73", 54 - "sha256:67453e603cea8e85ed566b2700efa1f6916aefbc0c9fcb2e86aaffc08ec38e78", 55 - "sha256:68d54234c8d76d8ef74744f9f9fc6324f1508129e23da8883771cdbb5818cbef", 56 - "sha256:6dfe7f984f28a8ae94ff3a7953cd9678550dbd2a1f9bda5dd9c5ae627744c78e", 57 - "sha256:74bd573dde27e58c760d9ca8615c41a57e719bff315c9adb6f2a4281a28e8798", 58 - "sha256:7603ca26d75b1b86160ce1bbe2787a0b706e592af5b2504e12caa88a217767b0", 59 - "sha256:76719dd521c20a58a6c256d058547b3a9595d1d885b830013366e27011ffe804", 60 - "sha256:7c3623053b85b4296cd3925eeb725e386644fd5bc67250b3bb08b0f144803e7b", 61 - "sha256:7e44eba534381dd2687be50cbd5f2daded21575242ecfdaf86bbeecbc38dae8e", 62 - "sha256:7fe3d65279bfbee8de0fb4f8c17fc4e893eed2dba21b2f680e930cc2b09075c5", 63 - "sha256:8340def6737118f5429a5df4e88f440746b791f8f1c4ce4ad8a595f42c980bd5", 64 - "sha256:84ede78acde96ca57f6cf8ccb8a13fbaf569f6011b9a52f870c662d4dc8cd854", 65 - "sha256:850ff6155371fd802a280f8d369d4e15d69434651b844bde566ce97ee2277420", 66 - "sha256:87a2e00bf17da098d90d4145375f1d985a81605267e7f9377ff94e55c5d769eb", 67 - "sha256:88d385b8e7f3a870146bf5ea31786ef7463e99eb59e31db56e2315535d811f55", 68 - "sha256:8a2fb742ef378284a50766e985804bd6adb5adb5aa781100b09befdbfa757b65", 69 - "sha256:8dc0fba9a74b471c45ca1a3cb6e6913ebfae416678d90529d188886278e7f3f6", 70 - "sha256:8fa1510b96c08aaad49303ab11f8803787c99222288f310a62f493faf883ede1", 71 - "sha256:8fd12d0f989c6099e7b0f30dc6e0d1e05499f3337461f0b2b0dadea6c64b89df", 72 - "sha256:9060addfa4ff753b09392efe41e6af06ea5dd257829199747b9f15bfad819460", 73 - "sha256:930ffa1925393381e1e0a9b82137fa7b34c92a019b521cf9f41263976666a0d6", 74 - "sha256:936d8a4f0f7081327014742cd51d320296b56aa6d324461a13724ab05f4b2933", 75 - "sha256:97fe431f2ed646a3b56142fc81d238abcbaff08548d6912acb0b19a0cadc146b", 76 - "sha256:9bd8695be2c80b665ae3f05cb584093a1e59c35ecb7d794d1edd96e8cc9201d7", 77 - "sha256:9dec0000d2d8621d8015c293e24589d46fa218637d820894cb7356c77eca3259", 78 - "sha256:a478aa11b328983c4444dacb947d4513cb371cd323f3845e53caeda6be5589d5", 79 - "sha256:a481a574af914b6e84624412666cbfbe531a05667ca197804ecc19c97b8ab1b0", 80 - "sha256:a4ac6a0f0f6402854adca4e3259a623f5c82ec3f0c049374133bcb243132baf9", 81 - "sha256:a5e69046f83c0d3cb8f0d5bd9b8838271b1bc898e01562a04398e160953e8eb9", 82 - "sha256:a7442662afebbf7b4c6d28cb7aab9e9ce3a5df055fc4116cc7228192ad6cb484", 83 - "sha256:aa8a8caca81c0a3e765f19c6953416c58e2f4cc1b84829af01dd1c771bb2f91f", 84 - "sha256:ab3247d58b393bda5b1c8f31c9edece7162fc13265334217785518dd770792b8", 85 - "sha256:b10a47e5390c4b30a0d58ee12581003be52eedd506862ab7f97da7a66805befb", 86 - "sha256:b34508f1cd928ce915ed09682d11307ba4b37d0708d1f28e5774c07a7674cac9", 87 - "sha256:b8d3bb96c147b39c02d3db086899679f31958c5d81c494ef0fc9ef5bb1359b3d", 88 - "sha256:b9d45dbb3aaec05cf01525ee1a7ac72de46a8c425cb75c003acd29f76b1ffe94", 89 - "sha256:bf4480a5438f80e0f1539e15a7eb8b5f97a26fe087e9828e2c0ec2be119a9f72", 90 - "sha256:c160a04283c8c6f55b5bf6d4cad59bb9c5b9c9cd08903841b25f1f7109ef1259", 91 - "sha256:c96a43822f1f9f69cc5c3706af33239489a6294be486a0447fb71380070d4d5f", 92 - "sha256:c9fd9dcf9c91affe71654ef77426f5cf8489305e1c66ed4816f5a21874b094b9", 93 - "sha256:cddb31f8474695cd61fc9455c644fc1606c164b93bff2490390d90464b4655df", 94 - "sha256:ce1bb21fc7d753b5f8a5d5a4bae99566386b15e716ebdb410154c16c91494d7f", 95 - "sha256:d1c031a7572f62f66f1257db37ddab4cb98bfaf9b9434a3b4840bf3560f5e788", 96 - "sha256:d589264dbba3b16e8951b6f145d1e6b883094075283dafcab4cdd564a9e353a0", 97 - "sha256:dc065a4285307607df3f3686363e7f8bdd0d8ab35f12226362a847731516e42c", 98 - "sha256:e10c440d142fa8b32cfdb194caf60ceeceb3e49807072e0dc3a8887ea80e8c16", 99 - "sha256:e3552fe98e90fdf5918c04769f338a87fa4f00f3b28830ea9b78b1bdc6140e0d", 100 - "sha256:e392804a38353900c3fd8b7cacbea5132888f7129f8e241915e90b85f00e3250", 101 - "sha256:e4cecdb52aaa9994fbed6b81d4568427b6002f0a91c322697a4bfcc2b2363f5a", 102 - "sha256:e5148ca8955affdfeb864aca158ecae11030e952b25b3ae15d4e2b5ba299bad2", 103 - "sha256:e6b2732ef3bafc759f653a98881b5b9cdef0716d98f013d376ee8dfd7285abf1", 104 - "sha256:ea756b5a7bac046d202a9a3889b9a92219f885481d78cd318db85b15cc0b7bcf", 105 - "sha256:edb69b9589324bdc40961cdf0657815df674f1743a8d5ad9ab56a99e4833cfdd", 106 - "sha256:f0203433121484b32646a5f5ea93ae86f3d9559d7243f07e8c0eab5ff8e3f70e", 107 - "sha256:f6a19bcab7fbd8f8649d6595624856635159a6527861b9cdc3447af288a00c00", 108 - "sha256:f752e80606b132140883bb262a457c475d219d7163d996dc9072434ffb0784c4", 109 - "sha256:f7914ab70d2ee8ab91c13e5402122edbc77821c66d2758abb53aabe87f013287" 34 + "sha256:00c8ac69e259c60976aa2edae3f13d9991cf079aaa4d3cd5a49168ae3748dee3", 35 + "sha256:01816f07c9cc9d80f858615b1365f8319d6a5fd079cd668cc58e15aafbc76a54", 36 + "sha256:02876bf2f69b062584965507b07bc06903c2dc93c57a554b64e012d636952654", 37 + "sha256:0e9eb7e5764abcb49f0e2bd8f5731849b8728efbf26d0cac8e81384c95acec3f", 38 + "sha256:0f6b2c5b4a4d22b8fb2c92ac98e0747f5f195e8e9448bfb7404cd77e7bfa243f", 39 + "sha256:1982c98ac62c132d2b773d50e2fcc941eb0b8bad3ec078ce7e7877c4d5a2dce7", 40 + "sha256:1e83fb1991e9d8982b3b36aea1e7ad27ea0ce18c14d054c7a404d68b0319eebb", 41 + "sha256:25de43bb3cf83ad83efc8295af7310219af6dbe4c543c2e74988d8e9c8a2a917", 42 + "sha256:28a772757c9067e2aee8a6b2b425d0efaa628c264d6416d283694c3d86da7689", 43 + "sha256:2a4a13dfbb23977a51853b419141cd0a9b9573ab8d3a1455c6e63561387b52ff", 44 + "sha256:2a8a6bc19818ac3e5596310ace5aa50d918e1ebdcc204dc96e2f4d505d51740c", 45 + "sha256:2eabb269dc3852537d57589b36d7f7362e57d1ece308842ef44d9830d2dc3c90", 46 + "sha256:35cda4e07f5e058a723436c4d2b7ba2124ab4e0aa49e6325aed5896507a8a42e", 47 + "sha256:42d689a5c0a0c357018993e471893e939f555e302313d5c61dfc566c2cad6185", 48 + "sha256:4586a68730bd2f2b04a83e83f79d271d8ed13763f64b75920f18a3a677b9a7f0", 49 + "sha256:47dc018b1b220c48089b5b9382fbab94db35bef2fa192995be22cbad3c5730c8", 50 + "sha256:507ab05d90586dacb4f26a001c3abf912eb719d05635cbfad930bdbeb469b36c", 51 + "sha256:5194143927e494616e335d074e77a5dac7cd353a04755330c9adc984ac5a628e", 52 + "sha256:51c3ff9c7a25f3cad5c09d9aacbc5aefb9267167c4652c1eb737989b554fe278", 53 + "sha256:55789e93c5ed71832e7fac868167276beadf9877b85697020c46e9a75471f55f", 54 + "sha256:5724cc77f4e648362ebbb49bdecb9e2b86d9b172c68a295263fa072e679ee69d", 55 + "sha256:5ad8f1c19fe277eeb8bc45741c6d60ddd11d705c12a4d8ee17546acff98e0802", 56 + "sha256:5ceb81a4db2decdfa087381b5fc5847aa448244f973e5da232610304e199e7b2", 57 + "sha256:64815c6f02e8506b10113ddbc6b196f58dbef135751cc7c32136df27b736db09", 58 + "sha256:66047eacbc73e6fe2462b77ce39fc170ab51235caf331e735eae91c95e6a11e4", 59 + "sha256:669dd33f028e54fe4c96576f406ebb242ba534dd3a981ce009961bf49960f117", 60 + "sha256:684eea71ab6e8ade86b9021bb62af4bf0881f6be4e926b6b5455de74e420783a", 61 + "sha256:6b35aab22419ba45f8fc290d0010898de7a6ad131e468ffa3922b1b0b24e9d2e", 62 + "sha256:7104d5b3943c6351d1ad7027d90bdd0ea002903e9f610735ac99df3b81f102ee", 63 + "sha256:718d5deb678bc4b9d575bfe83a59270861417da071ab44542d0fcb6faa686636", 64 + "sha256:747ec46290107a490d21fe1ff4183bef8022b848cf9516970cb31de6d9460088", 65 + "sha256:7836587eef675a17d835ec3d98a8c9acdbeb2c1d72b0556f0edf4e855a25e9c1", 66 + "sha256:78e4dd9c34ec7b8b121854eb5342bac8b02aa03075ae8618b6210a06bbb8a115", 67 + "sha256:7b77ee42addbb1c36d35aca55e8cc6d0958f8419e458bb70888d8c69a4ca833d", 68 + "sha256:7c1b20a1ace54af7db1f95af85da530fe97407d9063b7aaf9ce6a32f44730778", 69 + "sha256:7f27eec42f6c3c1df09cfc1f6786308f8b525b8efaaf6d6bd76c1f52c6511f6a", 70 + "sha256:82c249f2bfa5ecbe4a1a7902c81c0fba52ed9ebd0176ab3047395d02ad96cfcb", 71 + "sha256:85fa0b18558eb1427090912bd456a01f71edab0872f4e0f9e4285571941e4090", 72 + "sha256:89ce611b1eac93ce2ade68f1470889e0173d606de20c85a012bfa24be96cf867", 73 + "sha256:8ce789231404ca8fff7f693cdce398abf6d90fd5dae2b1847477196c243b1fbb", 74 + "sha256:90d571c98d19a8b6e793b34aa4df4cee1e8fe2862d65cc49185a3a3d0a1a3996", 75 + "sha256:9229d8613bd8401182868fe95688f7581673e1c18ff78855671a4b8284f47bcb", 76 + "sha256:93a1f7d857c4fcf7cabb1178058182c789b30d85de379e04f64c15b7e88d66fb", 77 + "sha256:967b93f21b426f23ca37329230d5bd122f25516ae2f24a9cea95a30023ff8283", 78 + "sha256:9840be675de208d1f68f84d578eaa4d1a36eee70b16ae31ab933520c49ba1325", 79 + "sha256:9862d077b9ffa015dbe3ce6c081bdf35135948cb89116e26667dd183550833d1", 80 + "sha256:9b5b37c863ad5b0892cc7a4ceb1e435e5e6acd3f2f8d3e11fa56f08d3c67b820", 81 + "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef", 82 + "sha256:9fe4eb0e7f50cdb99b26250d9328faef30b1175a5dbcfd6d0578d18456bac567", 83 + "sha256:a01fe9f1e05025eacdd97590895e2737b9f851d0eb2e017ae9574d9a4f0b6252", 84 + "sha256:a08ad95fcbd595803e0c4280671d808eb170a64ca3f2980dd38e7a72ed8d1fea", 85 + "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d", 86 + "sha256:a7d474c5c1f0b9405c1565fafdc4429fa7d986ccbec7ce55bc6a330f36409cad", 87 + "sha256:a86dc177eb4c286c19d1823ac296299f59ed8106c9536d2b559f65836e0fb2c6", 88 + "sha256:aa36c35e94ecdb478246dd60db12aba57cfcd0abcad43c927a8876f25734d496", 89 + "sha256:ab915a57c65f7a29353c8014ac4be685c8e4a19e792a79fe133a8e101111438e", 90 + "sha256:af55314407714fe77a68a9ccaab90fdb5deb57342585fd4a3a8102b6d4370080", 91 + "sha256:afcb6b275c2d2ba5d8418bf30a9654fa978b4f819c2e8db6311b3525c86fe637", 92 + "sha256:b27961d65639128336b7a7c3f0046dcc62a9443d5ef962e3c84170ac620cec47", 93 + "sha256:b5b95787335c483cd5f29577f42bbe027a412c5431f2f80a749c80d040f7ca9f", 94 + "sha256:b73a2b139782a07658fbf170fe4bcdf70fc597fae5ffe75e5b67674c27434a9f", 95 + "sha256:b88aca5adbf4625e11118df45acac29616b425833c3be7a05ef63a6a4017bfdb", 96 + "sha256:b992778d95b60a21c4d8d4a5f15aaab2bd3c3e16466a72d7f9bfd86e8cea0d4b", 97 + "sha256:ba40b7ae0f81c7029583a338853f6607b6d83a341a3dcde8bed1ea58a3af1df9", 98 + "sha256:baae005092e3f200de02699314ac8933ec20abf998ec0be39448f6605bce93df", 99 + "sha256:c4bea08a6aad9195ac9b1be6b0c7e8a702a9cec57ce6b713698b4a5afa9c2e33", 100 + "sha256:c6070bcf2173a7146bb9e4735b3c62b2accba459a6eae44deea0eb23e0035a23", 101 + "sha256:c929f9a7249a11e4aa5c157091cfad7f49cc6b13f4eecf9b747104befd9f56f2", 102 + "sha256:c97be90d70f7db3aa041d720bfb95f4869d6063fcdf2bb8333764d97e319b7d0", 103 + "sha256:ce10ddfbe26ed5856d6902162f71b8fe08545380570a885b4ab56aecfdcb07f4", 104 + "sha256:cf1f31f83d16ec344136359001c5e871915c6ab685a3d8dee38e2961b4c81730", 105 + "sha256:d2b25b2eeb35707113b2d570cadc7c612a57f1c5d3e7bb2b13870fe284e08fc0", 106 + "sha256:d33851d85537bbf0f6291ddc97926a754c8f041af759e0aa0230fe939168852b", 107 + "sha256:e06cf4852ce8c4442a59bae5a3ea01162b8fcb49ab438d8548b8dc79375dad8a", 108 + "sha256:e271beb2b1dabec5cd84eb488bdabf9758d22ad13471e9c356be07ad139b3012", 109 + "sha256:f55d0f242c2d1fcdf802c8fabcff25a9d85550a4cf3a9cf5f2a6b5742c992839", 110 + "sha256:f81cba651db8795f688c589dd11a4fbb834f2e59bbf9bb50908be36e416dc760", 111 + "sha256:fa1fb1b61881c8405829c50e9cc5c875bfdbf685edf57a76817dfb50643e4a1a", 112 + "sha256:fa48dac27f41b36735c807d1ab093a8386701bbf00eb6b89a0f69d9fa26b3671", 113 + "sha256:fbfef0666ae9e07abfa2c54c212ac18a1f63e13e0760a769f70b5717742f3ece", 114 + "sha256:fe7065e2215e4bba63dc00db9ae654c1ba3950a5fff691475a32f511142fcddb" 110 115 ], 111 116 "markers": "python_version >= '3.9'", 112 - "version": "==3.11.12" 117 + "version": "==3.11.13" 113 118 }, 114 119 "aiosignal": { 115 120 "hashes": [ ··· 181 186 }, 182 187 "attrs": { 183 188 "hashes": [ 184 - "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", 185 - "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" 189 + "sha256:18a06db706db43ac232cce80443fcd9f2500702059ecf53489e3c5a3f417acaf", 190 + "sha256:611344ff0a5fed735d86d7784610c84f8126b95e549bcad9ff61b4242f2d386b" 186 191 ], 187 192 "markers": "python_version >= '3.8'", 188 - "version": "==25.1.0" 193 + "version": "==25.2.0" 189 194 }, 190 195 "authlib": { 191 196 "hashes": [ ··· 215 220 }, 216 221 "botocore": { 217 222 "hashes": [ 218 - "sha256:53feff270078c23ba852fb2638fde6c5f74084cfc019dd5433e865cd04065c60", 219 - "sha256:546d0c071e9c8aeaca399d71bec414abe6434460f7d6640cbd92d4b1c3eb443e" 223 + "sha256:4a63bcef7ecf6146fd3a61dc4f9b33b7473b49bdaf1770e9aaca6eee0c9eab62", 224 + "sha256:4e3f19913887a58502e71ef8d696fe7eaa54de7813ff73390cd5883f837dfa6e" 220 225 ], 221 226 "markers": "python_version >= '3.8'", 222 - "version": "==1.36.14" 227 + "version": "==1.36.26" 223 228 }, 224 229 "celery": { 225 230 "hashes": [ ··· 442 447 }, 443 448 "cryptography": { 444 449 "hashes": [ 445 - "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", 446 - "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731", 447 - "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", 448 - "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", 449 - "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", 450 - "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", 451 - "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", 452 - "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", 453 - "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", 454 - "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", 455 - "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", 456 - "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c", 457 - "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", 458 - "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", 459 - "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", 460 - "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", 461 - "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa", 462 - "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", 463 - "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", 464 - "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", 465 - "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", 466 - "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", 467 - "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", 468 - "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", 469 - "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", 470 - "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756", 471 - "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4" 450 + "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", 451 + "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", 452 + "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", 453 + "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", 454 + "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", 455 + "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", 456 + "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", 457 + "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", 458 + "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", 459 + "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", 460 + "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", 461 + "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", 462 + "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", 463 + "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", 464 + "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", 465 + "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", 466 + "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", 467 + "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", 468 + "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", 469 + "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", 470 + "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", 471 + "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", 472 + "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", 473 + "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", 474 + "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", 475 + "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", 476 + "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", 477 + "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", 478 + "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", 479 + "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", 480 + "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", 481 + "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", 482 + "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", 483 + "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", 484 + "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308" 472 485 ], 473 486 "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", 474 - "version": "==44.0.0" 487 + "version": "==44.0.2" 475 488 }, 476 489 "django": { 477 490 "hashes": [ ··· 1168 1181 }, 1169 1182 "propcache": { 1170 1183 "hashes": [ 1171 - "sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4", 1172 - "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", 1173 - "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", 1174 - "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", 1175 - "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", 1176 - "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", 1177 - "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", 1178 - "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", 1179 - "sha256:19a0f89a7bb9d8048d9c4370c9c543c396e894c76be5525f5e1ad287f1750ddf", 1180 - "sha256:1ac2f5fe02fa75f56e1ad473f1175e11f475606ec9bd0be2e78e4734ad575034", 1181 - "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", 1182 - "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", 1183 - "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", 1184 - "sha256:2d3af2e79991102678f53e0dbf4c35de99b6b8b58f29a27ca0325816364caaba", 1185 - "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", 1186 - "sha256:3156628250f46a0895f1f36e1d4fbe062a1af8718ec3ebeb746f1d23f0c5dc4d", 1187 - "sha256:31f5af773530fd3c658b32b6bdc2d0838543de70eb9a2156c03e410f7b0d3aae", 1188 - "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", 1189 - "sha256:39d51fbe4285d5db5d92a929e3e21536ea3dd43732c5b177c7ef03f918dff9f2", 1190 - "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", 1191 - "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", 1192 - "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", 1193 - "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", 1194 - "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", 1195 - "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", 1196 - "sha256:574faa3b79e8ebac7cb1d7930f51184ba1ccf69adfdec53a12f319a06030a68b", 1197 - "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", 1198 - "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", 1199 - "sha256:5d97151bc92d2b2578ff7ce779cdb9174337390a535953cbb9452fb65164c587", 1200 - "sha256:5eee736daafa7af6d0a2dc15cc75e05c64f37fc37bafef2e00d77c14171c2097", 1201 - "sha256:6445804cf4ec763dc70de65a3b0d9954e868609e83850a47ca4f0cb64bd79fea", 1202 - "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", 1203 - "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", 1204 - "sha256:6a9a8c34fb7bb609419a211e59da8887eeca40d300b5ea8e56af98f6fbbb1541", 1205 - "sha256:6b3f39a85d671436ee3d12c017f8fdea38509e4f25b28eb25877293c98c243f6", 1206 - "sha256:6b6fb63ae352e13748289f04f37868099e69dba4c2b3e271c46061e82c745634", 1207 - "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", 1208 - "sha256:781e65134efaf88feb447e8c97a51772aa75e48b794352f94cb7ea717dedda0d", 1209 - "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", 1210 - "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", 1211 - "sha256:887d9b0a65404929641a9fabb6452b07fe4572b269d901d622d8a34a4e9043b2", 1212 - "sha256:8b3489ff1ed1e8315674d0775dc7d2195fb13ca17b3808721b54dbe9fd020faf", 1213 - "sha256:92fc4500fcb33899b05ba73276dfb684a20d31caa567b7cb5252d48f896a91b1", 1214 - "sha256:9403db39be1393618dd80c746cb22ccda168efce239c73af13c3763ef56ffc04", 1215 - "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", 1216 - "sha256:999779addc413181912e984b942fbcc951be1f5b3663cd80b2687758f434c583", 1217 - "sha256:9caac6b54914bdf41bcc91e7eb9147d331d29235a7c967c150ef5df6464fd1bb", 1218 - "sha256:a7a078f5d37bee6690959c813977da5291b24286e7b962e62a94cec31aa5188b", 1219 - "sha256:a7e65eb5c003a303b94aa2c3852ef130230ec79e349632d030e9571b87c4698c", 1220 - "sha256:a96dc1fa45bd8c407a0af03b2d5218392729e1822b0c32e62c5bf7eeb5fb3958", 1221 - "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", 1222 - "sha256:accb6150ce61c9c4b7738d45550806aa2b71c7668c6942f17b0ac182b6142fd4", 1223 - "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", 1224 - "sha256:ae1aa1cd222c6d205853b3013c69cd04515f9d6ab6de4b0603e2e1c33221303e", 1225 - "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", 1226 - "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", 1227 - "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", 1228 - "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", 1229 - "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", 1230 - "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", 1231 - "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", 1232 - "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", 1233 - "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", 1234 - "sha256:cba4cfa1052819d16699e1d55d18c92b6e094d4517c41dd231a8b9f87b6fa681", 1235 - "sha256:cea7daf9fc7ae6687cf1e2c049752f19f146fdc37c2cc376e7d0032cf4f25347", 1236 - "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", 1237 - "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", 1238 - "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", 1239 - "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", 1240 - "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", 1241 - "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", 1242 - "sha256:d9631c5e8b5b3a0fda99cb0d29c18133bca1e18aea9effe55adb3da1adef80d3", 1243 - "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", 1244 - "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", 1245 - "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", 1246 - "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", 1247 - "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", 1248 - "sha256:f089118d584e859c62b3da0892b88a83d611c2033ac410e929cb6754eec0ed16", 1249 - "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", 1250 - "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", 1251 - "sha256:f7a31fc1e1bd362874863fdeed71aed92d348f5336fd84f2197ba40c59f061bd", 1252 - "sha256:f9479aa06a793c5aeba49ce5c5692ffb51fcd9a7016e017d555d5e2b0045d212" 1184 + "sha256:02df07041e0820cacc8f739510078f2aadcfd3fc57eaeeb16d5ded85c872c89e", 1185 + "sha256:03acd9ff19021bd0567582ac88f821b66883e158274183b9e5586f678984f8fe", 1186 + "sha256:03c091bb752349402f23ee43bb2bff6bd80ccab7c9df6b88ad4322258d6960fc", 1187 + "sha256:07700939b2cbd67bfb3b76a12e1412405d71019df00ca5697ce75e5ef789d829", 1188 + "sha256:0c3e893c4464ebd751b44ae76c12c5f5c1e4f6cbd6fbf67e3783cd93ad221863", 1189 + "sha256:119e244ab40f70a98c91906d4c1f4c5f2e68bd0b14e7ab0a06922038fae8a20f", 1190 + "sha256:11ae6a8a01b8a4dc79093b5d3ca2c8a4436f5ee251a9840d7790dccbd96cb649", 1191 + "sha256:15010f29fbed80e711db272909a074dc79858c6d28e2915704cfc487a8ac89c6", 1192 + "sha256:19d36bb351ad5554ff20f2ae75f88ce205b0748c38b146c75628577020351e3c", 1193 + "sha256:1c8f7d896a16da9455f882870a507567d4f58c53504dc2d4b1e1d386dfe4588a", 1194 + "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c", 1195 + "sha256:24c04f8fbf60094c531667b8207acbae54146661657a1b1be6d3ca7773b7a545", 1196 + "sha256:2578541776769b500bada3f8a4eeaf944530516b6e90c089aa368266ed70c49e", 1197 + "sha256:26a67e5c04e3119594d8cfae517f4b9330c395df07ea65eab16f3d559b7068fe", 1198 + "sha256:2b975528998de037dfbc10144b8aed9b8dd5a99ec547f14d1cb7c5665a43f075", 1199 + "sha256:2d15bc27163cd4df433e75f546b9ac31c1ba7b0b128bfb1b90df19082466ff57", 1200 + "sha256:2d913d36bdaf368637b4f88d554fb9cb9d53d6920b9c5563846555938d5450bf", 1201 + "sha256:3302c5287e504d23bb0e64d2a921d1eb4a03fb93a0a0aa3b53de059f5a5d737d", 1202 + "sha256:36ca5e9a21822cc1746023e88f5c0af6fce3af3b85d4520efb1ce4221bed75cc", 1203 + "sha256:3b812b3cb6caacd072276ac0492d249f210006c57726b6484a1e1805b3cfeea0", 1204 + "sha256:3c6ec957025bf32b15cbc6b67afe233c65b30005e4c55fe5768e4bb518d712f1", 1205 + "sha256:41de3da5458edd5678b0f6ff66691507f9885f5fe6a0fb99a5d10d10c0fd2d64", 1206 + "sha256:42924dc0c9d73e49908e35bbdec87adedd651ea24c53c29cac103ede0ea1d340", 1207 + "sha256:4544699674faf66fb6b4473a1518ae4999c1b614f0b8297b1cef96bac25381db", 1208 + "sha256:46ed02532cb66612d42ae5c3929b5e98ae330ea0f3900bc66ec5f4862069519b", 1209 + "sha256:49ea05212a529c2caffe411e25a59308b07d6e10bf2505d77da72891f9a05641", 1210 + "sha256:4fa0e7c9c3cf7c276d4f6ab9af8adddc127d04e0fcabede315904d2ff76db626", 1211 + "sha256:507c5357a8d8b4593b97fb669c50598f4e6cccbbf77e22fa9598aba78292b4d7", 1212 + "sha256:549722908de62aa0b47a78b90531c022fa6e139f9166be634f667ff45632cc92", 1213 + "sha256:58e6d2a5a7cb3e5f166fd58e71e9a4ff504be9dc61b88167e75f835da5764d07", 1214 + "sha256:5a16167118677d94bb48bfcd91e420088854eb0737b76ec374b91498fb77a70e", 1215 + "sha256:5d62c4f6706bff5d8a52fd51fec6069bef69e7202ed481486c0bc3874912c787", 1216 + "sha256:5fa159dcee5dba00c1def3231c249cf261185189205073bde13797e57dd7540a", 1217 + "sha256:6032231d4a5abd67c7f71168fd64a47b6b451fbcb91c8397c2f7610e67683810", 1218 + "sha256:63f26258a163c34542c24808f03d734b338da66ba91f410a703e505c8485791d", 1219 + "sha256:65a37714b8ad9aba5780325228598a5b16c47ba0f8aeb3dc0514701e4413d7c0", 1220 + "sha256:67054e47c01b7b349b94ed0840ccae075449503cf1fdd0a1fdd98ab5ddc2667b", 1221 + "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043", 1222 + "sha256:6985a593417cdbc94c7f9c3403747335e450c1599da1647a5af76539672464d3", 1223 + "sha256:6a1948df1bb1d56b5e7b0553c0fa04fd0e320997ae99689488201f19fa90d2e7", 1224 + "sha256:6b5b7fd6ee7b54e01759f2044f936dcf7dea6e7585f35490f7ca0420fe723c0d", 1225 + "sha256:6c929916cbdb540d3407c66f19f73387f43e7c12fa318a66f64ac99da601bcdf", 1226 + "sha256:6f4d7a7c0aff92e8354cceca6fe223973ddf08401047920df0fcb24be2bd5138", 1227 + "sha256:728af36011bb5d344c4fe4af79cfe186729efb649d2f8b395d1572fb088a996c", 1228 + "sha256:742840d1d0438eb7ea4280f3347598f507a199a35a08294afdcc560c3739989d", 1229 + "sha256:75e872573220d1ee2305b35c9813626e620768248425f58798413e9c39741f46", 1230 + "sha256:794c3dd744fad478b6232289c866c25406ecdfc47e294618bdf1697e69bd64a6", 1231 + "sha256:7c0fdbdf6983526e269e5a8d53b7ae3622dd6998468821d660d0daf72779aefa", 1232 + "sha256:7c5f5290799a3f6539cc5e6f474c3e5c5fbeba74a5e1e5be75587746a940d51e", 1233 + "sha256:7c6e7e4f9167fddc438cd653d826f2222222564daed4116a02a184b464d3ef05", 1234 + "sha256:7cedd25e5f678f7738da38037435b340694ab34d424938041aa630d8bac42663", 1235 + "sha256:7e2e068a83552ddf7a39a99488bcba05ac13454fb205c847674da0352602082f", 1236 + "sha256:8319293e85feadbbfe2150a5659dbc2ebc4afdeaf7d98936fb9a2f2ba0d4c35c", 1237 + "sha256:8526b0941ec5a40220fc4dfde76aed58808e2b309c03e9fa8e2260083ef7157f", 1238 + "sha256:8884ba1a0fe7210b775106b25850f5e5a9dc3c840d1ae9924ee6ea2eb3acbfe7", 1239 + "sha256:8cb625bcb5add899cb8ba7bf716ec1d3e8f7cdea9b0713fa99eadf73b6d4986f", 1240 + "sha256:8d663fd71491dde7dfdfc899d13a067a94198e90695b4321084c6e450743b8c7", 1241 + "sha256:8ee1983728964d6070ab443399c476de93d5d741f71e8f6e7880a065f878e0b9", 1242 + "sha256:997e7b8f173a391987df40f3b52c423e5850be6f6df0dcfb5376365440b56667", 1243 + "sha256:9be90eebc9842a93ef8335291f57b3b7488ac24f70df96a6034a13cb58e6ff86", 1244 + "sha256:9ddd49258610499aab83b4f5b61b32e11fce873586282a0e972e5ab3bcadee51", 1245 + "sha256:9ecde3671e62eeb99e977f5221abcf40c208f69b5eb986b061ccec317c82ebd0", 1246 + "sha256:9ff4e9ecb6e4b363430edf2c6e50173a63e0820e549918adef70515f87ced19a", 1247 + "sha256:a254537b9b696ede293bfdbc0a65200e8e4507bc9f37831e2a0318a9b333c85c", 1248 + "sha256:a2b9bf8c79b660d0ca1ad95e587818c30ccdb11f787657458d6f26a1ea18c568", 1249 + "sha256:a61a68d630e812b67b5bf097ab84e2cd79b48c792857dc10ba8a223f5b06a2af", 1250 + "sha256:a7080b0159ce05f179cfac592cda1a82898ca9cd097dacf8ea20ae33474fbb25", 1251 + "sha256:a8fd93de4e1d278046345f49e2238cdb298589325849b2645d4a94c53faeffc5", 1252 + "sha256:a94ffc66738da99232ddffcf7910e0f69e2bbe3a0802e54426dbf0714e1c2ffe", 1253 + "sha256:aa806bbc13eac1ab6291ed21ecd2dd426063ca5417dd507e6be58de20e58dfcf", 1254 + "sha256:b0c1a133d42c6fc1f5fbcf5c91331657a1ff822e87989bf4a6e2e39b818d0ee9", 1255 + "sha256:b58229a844931bca61b3a20efd2be2a2acb4ad1622fc026504309a6883686fbf", 1256 + "sha256:bb2f144c6d98bb5cbc94adeb0447cfd4c0f991341baa68eee3f3b0c9c0e83767", 1257 + "sha256:be90c94570840939fecedf99fa72839aed70b0ced449b415c85e01ae67422c90", 1258 + "sha256:bf0d9a171908f32d54f651648c7290397b8792f4303821c42a74e7805bfb813c", 1259 + "sha256:bf15fc0b45914d9d1b706f7c9c4f66f2b7b053e9517e40123e137e8ca8958b3d", 1260 + "sha256:bf4298f366ca7e1ad1d21bbb58300a6985015909964077afd37559084590c929", 1261 + "sha256:c441c841e82c5ba7a85ad25986014be8d7849c3cfbdb6004541873505929a74e", 1262 + "sha256:cacea77ef7a2195f04f9279297684955e3d1ae4241092ff0cfcef532bb7a1c32", 1263 + "sha256:cd54895e4ae7d32f1e3dd91261df46ee7483a735017dc6f987904f194aa5fd14", 1264 + "sha256:d1323cd04d6e92150bcc79d0174ce347ed4b349d748b9358fd2e497b121e03c8", 1265 + "sha256:d383bf5e045d7f9d239b38e6acadd7b7fdf6c0087259a84ae3475d18e9a2ae8b", 1266 + "sha256:d3e7420211f5a65a54675fd860ea04173cde60a7cc20ccfbafcccd155225f8bc", 1267 + "sha256:d8074c5dd61c8a3e915fa8fc04754fa55cfa5978200d2daa1e2d4294c1f136aa", 1268 + "sha256:df03cd88f95b1b99052b52b1bb92173229d7a674df0ab06d2b25765ee8404bce", 1269 + "sha256:e45377d5d6fefe1677da2a2c07b024a6dac782088e37c0b1efea4cfe2b1be19b", 1270 + "sha256:e53d19c2bf7d0d1e6998a7e693c7e87300dd971808e6618964621ccd0e01fe4e", 1271 + "sha256:e560fd75aaf3e5693b91bcaddd8b314f4d57e99aef8a6c6dc692f935cc1e6bbf", 1272 + "sha256:ec5060592d83454e8063e487696ac3783cc48c9a329498bafae0d972bc7816c9", 1273 + "sha256:ecc2920630283e0783c22e2ac94427f8cca29a04cfdf331467d4f661f4072dac", 1274 + "sha256:ed7161bccab7696a473fe7ddb619c1d75963732b37da4618ba12e60899fefe4f", 1275 + "sha256:ee0bd3a7b2e184e88d25c9baa6a9dc609ba25b76daae942edfb14499ac7ec374", 1276 + "sha256:ee25f1ac091def37c4b59d192bbe3a206298feeb89132a470325bf76ad122a1e", 1277 + "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d", 1278 + "sha256:f47d52fd9b2ac418c4890aad2f6d21a6b96183c98021f0a48497a904199f006e", 1279 + "sha256:f857034dc68d5ceb30fb60afb6ff2103087aea10a01b613985610e007053a121", 1280 + "sha256:fb91d20fa2d3b13deea98a690534697742029f4fb83673a3501ae6e3746508b5", 1281 + "sha256:fddb8870bdb83456a489ab67c6b3040a8d5a55069aa6f72f9d872235fbc52f54" 1253 1282 ], 1254 1283 "markers": "python_version >= '3.9'", 1255 - "version": "==0.2.1" 1284 + "version": "==0.3.0" 1256 1285 }, 1257 1286 "psycopg": { 1258 1287 "extras": [ ··· 1433 1462 ], 1434 1463 "version": "==0.15.0" 1435 1464 }, 1465 + "python-magic": { 1466 + "hashes": [ 1467 + "sha256:15014af73a01ee7a411dd446f558963bc9fe0f78f6e4d6fb4d905e4567040309", 1468 + "sha256:20142991b4aea876f6bf2ddd88fb4cd7d1017e12467d22f6bda1c21d4e99e32a", 1469 + "sha256:3add737d417f49114d9c661c2cfdd5504cfe7dcdf6cbf45a43904bfad0a4cc28", 1470 + "sha256:4c7d6caf3bedcd54b4de328c9c554e33fc9a3a53dd301c8cde8ae737c2fc851a", 1471 + "sha256:638eac2127e73300711e2c2870f629d8956d085f847cc3804c02befea122bfaa", 1472 + "sha256:79aa6d26bc9911aae623e607824bbad8fa6ace6e4748ccb2e86b9db681386b2a", 1473 + "sha256:9591c37f7e1f404bc51b4698a57d848423058b7db132af7c6f416ec6b3ac88cd", 1474 + "sha256:b2ac43adae6a266210bacdb6af9e05137e28a27b3e21442a9da5e5fb07246084", 1475 + "sha256:d8c12c5e358f37eb959107a7cddceb42323f3e783d79fce74b98216da369c294", 1476 + "sha256:f435be713a6432cc1896aabc6e2f7330605644868d17e37d47682ee9fc9ad26c" 1477 + ], 1478 + "index": "python-magic-bin", 1479 + "version": "==0.4.28" 1480 + }, 1436 1481 "python-slugify": { 1437 1482 "hashes": [ 1438 1483 "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", ··· 1555 1600 }, 1556 1601 "rpds-py": { 1557 1602 "hashes": [ 1558 - "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518", 1559 - "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059", 1560 - "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61", 1561 - "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5", 1562 - "sha256:0b8db6b5b2d4491ad5b6bdc2bc7c017eec108acbf4e6785f42a9eb0ba234f4c9", 1563 - "sha256:0c150c7a61ed4a4f4955a96626574e9baf1adf772c2fb61ef6a5027e52803543", 1564 - "sha256:0f3cec041684de9a4684b1572fe28c7267410e02450f4561700ca5a3bc6695a2", 1565 - "sha256:1352ae4f7c717ae8cba93421a63373e582d19d55d2ee2cbb184344c82d2ae55a", 1566 - "sha256:177c7c0fce2855833819c98e43c262007f42ce86651ffbb84f37883308cb0e7d", 1567 - "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56", 1568 - "sha256:1a60bce91f81ddaac922a40bbb571a12c1070cb20ebd6d49c48e0b101d87300d", 1569 - "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd", 1570 - "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b", 1571 - "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4", 1572 - "sha256:214b7a953d73b5e87f0ebece4a32a5bd83c60a3ecc9d4ec8f1dca968a2d91e99", 1573 - "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d", 1574 - "sha256:24e8abb5878e250f2eb0d7859a8e561846f98910326d06c0d51381fed59357bd", 1575 - "sha256:26fd7cac7dd51011a245f29a2cc6489c4608b5a8ce8d75661bb4a1066c52dfbe", 1576 - "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1", 1577 - "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e", 1578 - "sha256:2b8f60e1b739a74bab7e01fcbe3dddd4657ec685caa04681df9d562ef15b625f", 1579 - "sha256:2de29005e11637e7a2361fa151f780ff8eb2543a0da1413bb951e9f14b699ef3", 1580 - "sha256:2e8b55d8517a2fda8d95cb45d62a5a8bbf9dd0ad39c5b25c8833efea07b880ca", 1581 - "sha256:2fa4331c200c2521512595253f5bb70858b90f750d39b8cbfd67465f8d1b596d", 1582 - "sha256:3445e07bf2e8ecfeef6ef67ac83de670358abf2996916039b16a218e3d95e97e", 1583 - "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc", 1584 - "sha256:378753b4a4de2a7b34063d6f95ae81bfa7b15f2c1a04a9518e8644e81807ebea", 1585 - "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38", 1586 - "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b", 1587 - "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c", 1588 - "sha256:4041711832360a9b75cfb11b25a6a97c8fb49c07b8bd43d0d02b45d0b499a4ff", 1589 - "sha256:44d61b4b7d0c2c9ac019c314e52d7cbda0ae31078aabd0f22e583af3e0d79723", 1590 - "sha256:4617e1915a539a0d9a9567795023de41a87106522ff83fbfaf1f6baf8e85437e", 1591 - "sha256:4b232061ca880db21fa14defe219840ad9b74b6158adb52ddf0e87bead9e8493", 1592 - "sha256:5246b14ca64a8675e0a7161f7af68fe3e910e6b90542b4bfb5439ba752191df6", 1593 - "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83", 1594 - "sha256:583f6a1993ca3369e0f80ba99d796d8e6b1a3a2a442dd4e1a79e652116413091", 1595 - "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1", 1596 - "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627", 1597 - "sha256:59f4a79c19232a5774aee369a0c296712ad0e77f24e62cad53160312b1c1eaa1", 1598 - "sha256:5f0e260eaf54380380ac3808aa4ebe2d8ca28b9087cf411649f96bad6900c728", 1599 - "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16", 1600 - "sha256:64607d4cbf1b7e3c3c8a14948b99345eda0e161b852e122c6bb71aab6d1d798c", 1601 - "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45", 1602 - "sha256:666ecce376999bf619756a24ce15bb14c5bfaf04bf00abc7e663ce17c3f34fe7", 1603 - "sha256:68049202f67380ff9aa52f12e92b1c30115f32e6895cd7198fa2a7961621fc5a", 1604 - "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730", 1605 - "sha256:6c7b99ca52c2c1752b544e310101b98a659b720b21db00e65edca34483259967", 1606 - "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25", 1607 - "sha256:70eb60b3ae9245ddea20f8a4190bd79c705a22f8028aaf8bbdebe4716c3fab24", 1608 - "sha256:70fb28128acbfd264eda9bf47015537ba3fe86e40d046eb2963d75024be4d055", 1609 - "sha256:7b2513ba235829860b13faa931f3b6846548021846ac808455301c23a101689d", 1610 - "sha256:7ef9d9da710be50ff6809fed8f1963fecdfecc8b86656cadfca3bc24289414b0", 1611 - "sha256:81e69b0a0e2537f26d73b4e43ad7bc8c8efb39621639b4434b76a3de50c6966e", 1612 - "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7", 1613 - "sha256:8bd7c8cfc0b8247c8799080fbff54e0b9619e17cdfeb0478ba7295d43f635d7c", 1614 - "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f", 1615 - "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd", 1616 - "sha256:9bd7228827ec7bb817089e2eb301d907c0d9827a9e558f22f762bb690b131652", 1617 - "sha256:9beeb01d8c190d7581a4d59522cd3d4b6887040dcfc744af99aa59fef3e041a8", 1618 - "sha256:a63cbdd98acef6570c62b92a1e43266f9e8b21e699c363c0fef13bd530799c11", 1619 - "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333", 1620 - "sha256:ac0a03221cdb5058ce0167ecc92a8c89e8d0decdc9e99a2ec23380793c4dcb96", 1621 - "sha256:b0b4136a252cadfa1adb705bb81524eee47d9f6aab4f2ee4fa1e9d3cd4581f64", 1622 - "sha256:b25bc607423935079e05619d7de556c91fb6adeae9d5f80868dde3468657994b", 1623 - "sha256:b3d504047aba448d70cf6fa22e06cb09f7cbd761939fdd47604f5e007675c24e", 1624 - "sha256:bb47271f60660803ad11f4c61b42242b8c1312a31c98c578f79ef9387bbde21c", 1625 - "sha256:bbb232860e3d03d544bc03ac57855cd82ddf19c7a07651a7c0fdb95e9efea8b9", 1626 - "sha256:bc27863442d388870c1809a87507727b799c8460573cfbb6dc0eeaef5a11b5ec", 1627 - "sha256:bc51abd01f08117283c5ebf64844a35144a0843ff7b2983e0648e4d3d9f10dbb", 1628 - "sha256:be2eb3f2495ba669d2a985f9b426c1797b7d48d6963899276d22f23e33d47e37", 1629 - "sha256:bf9db5488121b596dbfc6718c76092fda77b703c1f7533a226a5a9f65248f8ad", 1630 - "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9", 1631 - "sha256:cfbc454a2880389dbb9b5b398e50d439e2e58669160f27b60e5eca11f68ae17c", 1632 - "sha256:cff63a0272fcd259dcc3be1657b07c929c466b067ceb1c20060e8d10af56f5bf", 1633 - "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4", 1634 - "sha256:d20cfb4e099748ea39e6f7b16c91ab057989712d31761d3300d43134e26e165f", 1635 - "sha256:d48424e39c2611ee1b84ad0f44fb3b2b53d473e65de061e3f460fc0be5f1939d", 1636 - "sha256:e0fa2d4ec53dc51cf7d3bb22e0aa0143966119f42a0c3e4998293a3dd2856b09", 1637 - "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d", 1638 - "sha256:e35ba67d65d49080e8e5a1dd40101fccdd9798adb9b050ff670b7d74fa41c566", 1639 - "sha256:e3fb866d9932a3d7d0c82da76d816996d1667c44891bd861a0f97ba27e84fc74", 1640 - "sha256:e61b02c3f7a1e0b75e20c3978f7135fd13cb6cf551bf4a6d29b999a88830a338", 1641 - "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15", 1642 - "sha256:e79dd39f1e8c3504be0607e5fc6e86bb60fe3584bec8b782578c3b0fde8d932c", 1643 - "sha256:e89391e6d60251560f0a8f4bd32137b077a80d9b7dbe6d5cab1cd80d2746f648", 1644 - "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84", 1645 - "sha256:eaf16ae9ae519a0e237a0f528fd9f0197b9bb70f40263ee57ae53c2b8d48aeb3", 1646 - "sha256:eb0c341fa71df5a4595f9501df4ac5abfb5a09580081dffbd1ddd4654e6e9123", 1647 - "sha256:f276b245347e6e36526cbd4a266a417796fc531ddf391e43574cf6466c492520", 1648 - "sha256:f47ad3d5f3258bd7058d2d506852217865afefe6153a36eb4b6928758041d831", 1649 - "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e", 1650 - "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf", 1651 - "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b", 1652 - "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2", 1653 - "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3", 1654 - "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130", 1655 - "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b", 1656 - "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de", 1657 - "sha256:fb4f868f712b2dd4bcc538b0a0c1f63a2b1d584c925e69a224d759e7070a12d5", 1658 - "sha256:fb6116dfb8d1925cbdb52595560584db42a7f664617a1f7d7f6e32f138cdf37d", 1659 - "sha256:fda7cb070f442bf80b642cd56483b5548e43d366fe3f39b98e67cce780cded00", 1660 - "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e" 1603 + "sha256:09cd7dbcb673eb60518231e02874df66ec1296c01a4fcd733875755c02014b19", 1604 + "sha256:0f3288930b947cbebe767f84cf618d2cbe0b13be476e749da0e6a009f986248c", 1605 + "sha256:0fced9fd4a07a1ded1bac7e961ddd9753dd5d8b755ba8e05acba54a21f5f1522", 1606 + "sha256:112b8774b0b4ee22368fec42749b94366bd9b536f8f74c3d4175d4395f5cbd31", 1607 + "sha256:11dd60b2ffddba85715d8a66bb39b95ddbe389ad2cfcf42c833f1bcde0878eaf", 1608 + "sha256:178f8a60fc24511c0eb756af741c476b87b610dba83270fce1e5a430204566a4", 1609 + "sha256:1b08027489ba8fedde72ddd233a5ea411b85a6ed78175f40285bd401bde7466d", 1610 + "sha256:1bf5be5ba34e19be579ae873da515a2836a2166d8d7ee43be6ff909eda42b72b", 1611 + "sha256:1ed7de3c86721b4e83ac440751329ec6a1102229aa18163f84c75b06b525ad7e", 1612 + "sha256:1eedaaccc9bb66581d4ae7c50e15856e335e57ef2734dbc5fd8ba3e2a4ab3cb6", 1613 + "sha256:243241c95174b5fb7204c04595852fe3943cc41f47aa14c3828bc18cd9d3b2d6", 1614 + "sha256:26bb3e8de93443d55e2e748e9fd87deb5f8075ca7bc0502cfc8be8687d69a2ec", 1615 + "sha256:271fa2184cf28bdded86bb6217c8e08d3a169fe0bbe9be5e8d96e8476b707122", 1616 + "sha256:28358c54fffadf0ae893f6c1050e8f8853e45df22483b7fff2f6ab6152f5d8bf", 1617 + "sha256:285019078537949cecd0190f3690a0b0125ff743d6a53dfeb7a4e6787af154f5", 1618 + "sha256:2893d778d4671ee627bac4037a075168b2673c57186fb1a57e993465dbd79a93", 1619 + "sha256:2a54027554ce9b129fc3d633c92fa33b30de9f08bc61b32c053dc9b537266fed", 1620 + "sha256:2c6ae11e6e93728d86aafc51ced98b1658a0080a7dd9417d24bfb955bb09c3c2", 1621 + "sha256:2cfa07c346a7ad07019c33fb9a63cf3acb1f5363c33bc73014e20d9fe8b01cdd", 1622 + "sha256:35d5631ce0af26318dba0ae0ac941c534453e42f569011585cb323b7774502a5", 1623 + "sha256:3614d280bf7aab0d3721b5ce0e73434acb90a2c993121b6e81a1c15c665298ac", 1624 + "sha256:3902df19540e9af4cc0c3ae75974c65d2c156b9257e91f5101a51f99136d834c", 1625 + "sha256:3aaf141d39f45322e44fc2c742e4b8b4098ead5317e5f884770c8df0c332da70", 1626 + "sha256:3d8abf7896a91fb97e7977d1aadfcc2c80415d6dc2f1d0fca5b8d0df247248f3", 1627 + "sha256:3e77febf227a1dc3220159355dba68faa13f8dca9335d97504abf428469fb18b", 1628 + "sha256:3e9212f52074fc9d72cf242a84063787ab8e21e0950d4d6709886fb62bcb91d5", 1629 + "sha256:3ee9d6f0b38efb22ad94c3b68ffebe4c47865cdf4b17f6806d6c674e1feb4246", 1630 + "sha256:4233df01a250b3984465faed12ad472f035b7cd5240ea3f7c76b7a7016084495", 1631 + "sha256:4263320ed887ed843f85beba67f8b2d1483b5947f2dc73a8b068924558bfeace", 1632 + "sha256:4ab923167cfd945abb9b51a407407cf19f5bee35001221f2911dc85ffd35ff4f", 1633 + "sha256:4caafd1a22e5eaa3732acb7672a497123354bef79a9d7ceed43387d25025e935", 1634 + "sha256:50fb62f8d8364978478b12d5f03bf028c6bc2af04082479299139dc26edf4c64", 1635 + "sha256:55ff4151cfd4bc635e51cfb1c59ac9f7196b256b12e3a57deb9e5742e65941ad", 1636 + "sha256:5b98b6c953e5c2bda51ab4d5b4f172617d462eebc7f4bfdc7c7e6b423f6da957", 1637 + "sha256:5c9ff044eb07c8468594d12602291c635da292308c8c619244e30698e7fc455a", 1638 + "sha256:5e9c206a1abc27e0588cf8b7c8246e51f1a16a103734f7750830a1ccb63f557a", 1639 + "sha256:5fb89edee2fa237584e532fbf78f0ddd1e49a47c7c8cfa153ab4849dc72a35e6", 1640 + "sha256:633462ef7e61d839171bf206551d5ab42b30b71cac8f10a64a662536e057fdef", 1641 + "sha256:66f8d2a17e5838dd6fb9be6baaba8e75ae2f5fa6b6b755d597184bfcd3cb0eba", 1642 + "sha256:6959bb9928c5c999aba4a3f5a6799d571ddc2c59ff49917ecf55be2bbb4e3722", 1643 + "sha256:698a79d295626ee292d1730bc2ef6e70a3ab135b1d79ada8fde3ed0047b65a10", 1644 + "sha256:721f9c4011b443b6e84505fc00cc7aadc9d1743f1c988e4c89353e19c4a968ee", 1645 + "sha256:72e680c1518733b73c994361e4b06441b92e973ef7d9449feec72e8ee4f713da", 1646 + "sha256:75307599f0d25bf6937248e5ac4e3bde5ea72ae6618623b86146ccc7845ed00b", 1647 + "sha256:754fba3084b70162a6b91efceee8a3f06b19e43dac3f71841662053c0584209a", 1648 + "sha256:759462b2d0aa5a04be5b3e37fb8183615f47014ae6b116e17036b131985cb731", 1649 + "sha256:7938c7b0599a05246d704b3f5e01be91a93b411d0d6cc62275f025293b8a11ce", 1650 + "sha256:7b77e07233925bd33fc0022b8537774423e4c6680b6436316c5075e79b6384f4", 1651 + "sha256:7e5413d2e2d86025e73f05510ad23dad5950ab8417b7fc6beaad99be8077138b", 1652 + "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", 1653 + "sha256:7f9682a8f71acdf59fd554b82b1c12f517118ee72c0f3944eda461606dfe7eb9", 1654 + "sha256:8d67beb6002441faef8251c45e24994de32c4c8686f7356a1f601ad7c466f7c3", 1655 + "sha256:9441af1d25aed96901f97ad83d5c3e35e6cd21a25ca5e4916c82d7dd0490a4fa", 1656 + "sha256:98b257ae1e83f81fb947a363a274c4eb66640212516becaff7bef09a5dceacaa", 1657 + "sha256:9e9f3a3ac919406bc0414bbbd76c6af99253c507150191ea79fab42fdb35982a", 1658 + "sha256:a1c66e71ecfd2a4acf0e4bd75e7a3605afa8f9b28a3b497e4ba962719df2be57", 1659 + "sha256:a1e17d8dc8e57d8e0fd21f8f0f0a5211b3fa258b2e444c2053471ef93fe25a00", 1660 + "sha256:a20cb698c4a59c534c6701b1c24a968ff2768b18ea2991f886bd8985ce17a89f", 1661 + "sha256:a970bfaf130c29a679b1d0a6e0f867483cea455ab1535fb427566a475078f27f", 1662 + "sha256:a98f510d86f689fcb486dc59e6e363af04151e5260ad1bdddb5625c10f1e95f8", 1663 + "sha256:a9d3b728f5a5873d84cba997b9d617c6090ca5721caaa691f3b1a78c60adc057", 1664 + "sha256:ad76f44f70aac3a54ceb1813ca630c53415da3a24fd93c570b2dfb4856591017", 1665 + "sha256:ae28144c1daa61366205d32abd8c90372790ff79fc60c1a8ad7fd3c8553a600e", 1666 + "sha256:b03a8d50b137ee758e4c73638b10747b7c39988eb8e6cd11abb7084266455165", 1667 + "sha256:b5a96fcac2f18e5a0a23a75cd27ce2656c66c11c127b0318e508aab436b77428", 1668 + "sha256:b5ef909a37e9738d146519657a1aab4584018746a18f71c692f2f22168ece40c", 1669 + "sha256:b79f5ced71efd70414a9a80bbbfaa7160da307723166f09b69773153bf17c590", 1670 + "sha256:b91cceb5add79ee563bd1f70b30896bd63bc5f78a11c1f00a1e931729ca4f1f4", 1671 + "sha256:b92f5654157de1379c509b15acec9d12ecf6e3bc1996571b6cb82a4302060447", 1672 + "sha256:c04ca91dda8a61584165825907f5c967ca09e9c65fe8966ee753a3f2b019fe1e", 1673 + "sha256:c1f8afa346ccd59e4e5630d5abb67aba6a9812fddf764fd7eb11f382a345f8cc", 1674 + "sha256:c5334a71f7dc1160382d45997e29f2637c02f8a26af41073189d79b95d3321f1", 1675 + "sha256:c617d7453a80e29d9973b926983b1e700a9377dbe021faa36041c78537d7b08c", 1676 + "sha256:c632419c3870507ca20a37c8f8f5352317aca097639e524ad129f58c125c61c6", 1677 + "sha256:c6760211eee3a76316cf328f5a8bd695b47b1626d21c8a27fb3b2473a884d597", 1678 + "sha256:c698d123ce5d8f2d0cd17f73336615f6a2e3bdcedac07a1291bb4d8e7d82a05a", 1679 + "sha256:c76b32eb2ab650a29e423525e84eb197c45504b1c1e6e17b6cc91fcfeb1a4b1d", 1680 + "sha256:c8f7e90b948dc9dcfff8003f1ea3af08b29c062f681c05fd798e36daa3f7e3e8", 1681 + "sha256:c9e799dac1ffbe7b10c1fd42fe4cd51371a549c6e108249bde9cd1200e8f59b4", 1682 + "sha256:cafa48f2133d4daa028473ede7d81cd1b9f9e6925e9e4003ebdf77010ee02f35", 1683 + "sha256:ce473a2351c018b06dd8d30d5da8ab5a0831056cc53b2006e2a8028172c37ce5", 1684 + "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", 1685 + "sha256:d550d7e9e7d8676b183b37d65b5cd8de13676a738973d330b59dc8312df9c5dc", 1686 + "sha256:d6adb81564af0cd428910f83fa7da46ce9ad47c56c0b22b50872bc4515d91966", 1687 + "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", 1688 + "sha256:d7031d493c4465dbc8d40bd6cafefef4bd472b17db0ab94c53e7909ee781b9ef", 1689 + "sha256:d9f75a06ecc68f159d5d7603b734e1ff6daa9497a929150f794013aa9f6e3f12", 1690 + "sha256:db7707dde9143a67b8812c7e66aeb2d843fe33cc8e374170f4d2c50bd8f2472d", 1691 + "sha256:e0397dd0b3955c61ef9b22838144aa4bef6f0796ba5cc8edfc64d468b93798b4", 1692 + "sha256:e0df046f2266e8586cf09d00588302a32923eb6386ced0ca5c9deade6af9a149", 1693 + "sha256:e14f86b871ea74c3fddc9a40e947d6a5d09def5adc2076ee61fb910a9014fb35", 1694 + "sha256:e5963ea87f88bddf7edd59644a35a0feecf75f8985430124c253612d4f7d27ae", 1695 + "sha256:e768267cbe051dd8d1c5305ba690bb153204a09bf2e3de3ae530de955f5b5580", 1696 + "sha256:e9cb79ecedfc156c0692257ac7ed415243b6c35dd969baa461a6888fc79f2f07", 1697 + "sha256:ed6f011bedca8585787e5082cce081bac3d30f54520097b2411351b3574e1219", 1698 + "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", 1699 + "sha256:f35eff113ad430b5272bbfc18ba111c66ff525828f24898b4e146eb479a2cdda", 1700 + "sha256:f3a6cb95074777f1ecda2ca4fa7717caa9ee6e534f42b7575a8f0d4cb0c24013", 1701 + "sha256:f7356a6da0562190558c4fcc14f0281db191cdf4cb96e7604c06acfcee96df15", 1702 + "sha256:f88626e3f5e57432e6191cd0c5d6d6b319b635e70b40be2ffba713053e5147dd", 1703 + "sha256:fad784a31869747df4ac968a351e070c06ca377549e4ace94775aaa3ab33ee06", 1704 + "sha256:fc869af5cba24d45fb0399b0cfdbcefcf6910bf4dee5d74036a57cf5264b3ff4", 1705 + "sha256:fee513135b5a58f3bb6d89e48326cd5aa308e4bcdf2f7d59f67c861ada482bf8" 1661 1706 ], 1662 1707 "markers": "python_version >= '3.9'", 1663 - "version": "==0.22.3" 1708 + "version": "==0.23.1" 1664 1709 }, 1665 1710 "s3transfer": { 1666 1711 "hashes": [ 1667 - "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", 1668 - "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" 1712 + "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d", 1713 + "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a" 1669 1714 ], 1670 1715 "markers": "python_version >= '3.8'", 1671 - "version": "==0.11.2" 1716 + "version": "==0.11.3" 1672 1717 }, 1673 1718 "sentry-sdk": { 1674 1719 "hashes": [ ··· 1681 1726 }, 1682 1727 "setuptools": { 1683 1728 "hashes": [ 1684 - "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", 1685 - "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" 1729 + "sha256:0a6f876d62f4d978ca1a11ab4daf728d1357731f978543ff18ecdbf9fd071f73", 1730 + "sha256:b6eca2c3070cdc82f71b4cb4bb2946bc0760a210d11362278cf1ff394e6ea32c" 1686 1731 ], 1687 1732 "markers": "python_version >= '3.9'", 1688 - "version": "==75.8.0" 1733 + "version": "==75.9.1" 1689 1734 }, 1690 1735 "simplejson": { 1691 1736 "hashes": [ ··· 1829 1874 }, 1830 1875 "types-cffi": { 1831 1876 "hashes": [ 1832 - "sha256:1c96649618f4b6145f58231acb976e0b448be6b847f7ab733dabe62dfbff6591", 1833 - "sha256:e5b76b4211d7a9185f6ab8d06a106d56c7eb80af7cdb8bfcb4186ade10fb112f" 1877 + "sha256:847fa420d654eb403b5eca7893e19857feac3ba95b3bcbecd6b291c008de1c75", 1878 + "sha256:9d20eef09ec09fb2622d3ebe527ea0d214af2687bd192cfbe6047a7418450f94" 1834 1879 ], 1835 - "markers": "python_version >= '3.8'", 1836 - "version": "==1.16.0.20241221" 1880 + "markers": "python_version >= '3.9'", 1881 + "version": "==1.16.0.20250307" 1837 1882 }, 1838 1883 "types-pyopenssl": { 1839 1884 "hashes": [ ··· 1853 1898 }, 1854 1899 "types-setuptools": { 1855 1900 "hashes": [ 1856 - "sha256:96f7ec8bbd6e0a54ea180d66ad68ad7a1d7954e7281a710ea2de75e355545271", 1857 - "sha256:a9f12980bbf9bcdc23ecd80755789085bad6bfce4060c2275bc2b4ca9f2bc480" 1901 + "sha256:a987269b49488f21961a1d99aa8d281b611625883def6392a93855b31544e405", 1902 + "sha256:ba80953fd1f5f49e552285c024f75b5223096a38a5138a54d18ddd3fa8f6a2d4" 1858 1903 ], 1859 - "markers": "python_version >= '3.8'", 1860 - "version": "==75.8.0.20250110" 1904 + "markers": "python_version >= '3.9'", 1905 + "version": "==75.8.2.20250305" 1861 1906 }, 1862 1907 "typing-extensions": { 1863 1908 "hashes": [ ··· 2058 2103 }, 2059 2104 "botocore": { 2060 2105 "hashes": [ 2061 - "sha256:53feff270078c23ba852fb2638fde6c5f74084cfc019dd5433e865cd04065c60", 2062 - "sha256:546d0c071e9c8aeaca399d71bec414abe6434460f7d6640cbd92d4b1c3eb443e" 2106 + "sha256:4a63bcef7ecf6146fd3a61dc4f9b33b7473b49bdaf1770e9aaca6eee0c9eab62", 2107 + "sha256:4e3f19913887a58502e71ef8d696fe7eaa54de7813ff73390cd5883f837dfa6e" 2063 2108 ], 2064 2109 "markers": "python_version >= '3.8'", 2065 - "version": "==1.36.14" 2110 + "version": "==1.36.26" 2066 2111 }, 2067 2112 "botocore-stubs": { 2068 2113 "hashes": [ 2069 - "sha256:54c7b6fe68d6e2a1eac827b1c0525554509a3481ed6ad5a0f4f2c487e00768d2", 2070 - "sha256:58f8e6eb37427b7bb81aa24984b10c90d124526b2ba741ebbe7d6e5690454ad0" 2114 + "sha256:9b89ba9a98eb9f088a5f82c52488013858092777c17b56265574bbf2d21da422", 2115 + "sha256:bec458a0d054892cdf82466b4d075f30a36fa03ce34f9becbcace5f36ec674bf" 2071 2116 ], 2072 2117 "markers": "python_version >= '3.8'", 2073 - "version": "==1.36.14" 2118 + "version": "==1.37.11" 2074 2119 }, 2075 2120 "certifi": { 2076 2121 "hashes": [ ··· 2290 2335 }, 2291 2336 "decorator": { 2292 2337 "hashes": [ 2293 - "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", 2294 - "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186" 2338 + "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", 2339 + "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" 2295 2340 ], 2296 - "markers": "python_version >= '3.5'", 2297 - "version": "==5.1.1" 2341 + "markers": "python_version >= '3.8'", 2342 + "version": "==5.2.1" 2298 2343 }, 2299 2344 "dirty-equals": { 2300 2345 "hashes": [ ··· 2349 2394 }, 2350 2395 "django-stubs": { 2351 2396 "hashes": [ 2352 - "sha256:04ddc778faded6fb48468a8da9e98b8d12b9ba983faa648d37a73ebde0f024da", 2353 - "sha256:a0fcb3659bab46a6d835cc2d9bff3fc29c36ccea41a10e8b1930427bc0f9f0df" 2397 + "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78", 2398 + "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a" 2354 2399 ], 2355 2400 "markers": "python_version >= '3.8'", 2356 - "version": "==5.1.2" 2401 + "version": "==5.1.3" 2357 2402 }, 2358 2403 "django-stubs-ext": { 2359 2404 "hashes": [ 2360 - "sha256:421c0c3025a68e3ab8e16f065fad9ba93335ecefe2dd92a0cff97a665680266c", 2361 - "sha256:6c559214538d6a26f631ca638ddc3251a0a891d607de8ce01d23d3201ad8ad6c" 2405 + "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0", 2406 + "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd" 2362 2407 ], 2363 2408 "markers": "python_version >= '3.8'", 2364 - "version": "==5.1.2" 2409 + "version": "==5.1.3" 2365 2410 }, 2366 2411 "djangorestframework-stubs": { 2367 2412 "hashes": [ ··· 2425 2470 }, 2426 2471 "identify": { 2427 2472 "hashes": [ 2428 - "sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251", 2429 - "sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881" 2473 + "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", 2474 + "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf" 2430 2475 ], 2431 2476 "markers": "python_version >= '3.9'", 2432 - "version": "==2.6.6" 2477 + "version": "==2.6.9" 2433 2478 }, 2434 2479 "idna": { 2435 2480 "hashes": [ ··· 2595 2640 }, 2596 2641 "mypy-boto3-s3": { 2597 2642 "hashes": [ 2598 - "sha256:368c963969eda65bb3a9df61e87510dd8b3247cce59f559c2ec6c43d5796bef5", 2599 - "sha256:506edd56892452dff5b673e3c79a11b6f8935076ce4a9daaac4cda708a176201" 2643 + "sha256:9c6143c0dabfbd98e6c741e7cc65a33c7f87b8c28eeb373a2bc3e2c923af8283", 2644 + "sha256:bfda17f51efafc2cdcefad7a13f5ac35bd721291476d8558c2d3a21758442be5" 2600 2645 ], 2601 2646 "markers": "python_version >= '3.8'", 2602 - "version": "==1.36.9" 2647 + "version": "==1.36.21" 2603 2648 }, 2604 2649 "mypy-extensions": { 2605 2650 "hashes": [ ··· 2809 2854 }, 2810 2855 "s3transfer": { 2811 2856 "hashes": [ 2812 - "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", 2813 - "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" 2857 + "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d", 2858 + "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a" 2814 2859 ], 2815 2860 "markers": "python_version >= '3.8'", 2816 - "version": "==0.11.2" 2861 + "version": "==0.11.3" 2817 2862 }, 2818 2863 "six": { 2819 2864 "hashes": [ ··· 2857 2902 }, 2858 2903 "types-awscrt": { 2859 2904 "hashes": [ 2860 - "sha256:57ec68d45ef873458df7307ec80578a6334696f088549ab349c3d655e7e3562b", 2861 - "sha256:71181a6c5188352ae934e74a7633d80c82ac5c6f89054bd7d653bb1b5bba240b" 2905 + "sha256:f3f2578ff74a254a79882b95961fb493ba217cebc350b3eb239d1cd948d4d7fa", 2906 + "sha256:fc6eae56f8dc5a3f8cc93cc2c7c332fa82909f8284fbe25e014c575757af397d" 2862 2907 ], 2863 2908 "markers": "python_version >= '3.8'", 2864 - "version": "==0.23.9" 2909 + "version": "==0.24.1" 2865 2910 }, 2866 2911 "types-pyyaml": { 2867 2912 "hashes": [ ··· 2873 2918 }, 2874 2919 "types-requests": { 2875 2920 "hashes": [ 2876 - "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", 2877 - "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747" 2921 + "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1", 2922 + "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b" 2878 2923 ], 2879 - "markers": "python_version >= '3.8'", 2880 - "version": "==2.32.0.20241016" 2924 + "markers": "python_version >= '3.9'", 2925 + "version": "==2.32.0.20250306" 2881 2926 }, 2882 2927 "types-s3transfer": { 2883 2928 "hashes": [ 2884 - "sha256:09c31cff8c79a433fcf703b840b66d1f694a6c70c410ef52015dd4fe07ee0ae2", 2885 - "sha256:3ccb8b90b14434af2fb0d6c08500596d93f3a83fb804a2bb843d9bf4f7c2ca60" 2929 + "sha256:05fde593c84270f19fd053f0b1e08f5a057d7c5f036b9884e68fb8cd3041ac30", 2930 + "sha256:2a76d92c07d4a3cb469e5343b2e7560e0b8078b2e03696a65407b8c44c861b61" 2886 2931 ], 2887 2932 "markers": "python_version >= '3.8'", 2888 - "version": "==0.11.2" 2933 + "version": "==0.11.4" 2889 2934 }, 2890 2935 "typing-extensions": { 2891 2936 "hashes": [ ··· 2905 2950 }, 2906 2951 "virtualenv": { 2907 2952 "hashes": [ 2908 - "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779", 2909 - "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35" 2953 + "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", 2954 + "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac" 2910 2955 ], 2911 2956 "markers": "python_version >= '3.8'", 2912 - "version": "==20.29.1" 2957 + "version": "==20.29.3" 2913 2958 }, 2914 2959 "watchdog": { 2915 2960 "hashes": [ ··· 3131 3176 }, 3132 3177 "jinja2": { 3133 3178 "hashes": [ 3134 - "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", 3135 - "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb" 3179 + "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", 3180 + "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" 3136 3181 ], 3137 3182 "markers": "python_version >= '3.7'", 3138 - "version": "==3.1.5" 3183 + "version": "==3.1.6" 3139 3184 }, 3140 3185 "markdown-it-py": { 3141 3186 "hashes": [
+5 -2
README.md
··· 54 54 make up 55 55 ``` 56 56 57 - to load dummy data for testing run: 57 + to load seed data for testing run: 58 58 59 59 ```bash 60 - make load-dummy-data 60 + make load-seed-data 61 61 ``` 62 + 62 63 Stops and removes the containers without affecting the volumes: 63 64 64 65 ```bash 65 66 make down 66 67 ``` 68 + 67 69 Stops and removes the containers and their volumes: 68 70 69 71 ```bash ··· 76 78 on [ghcr](https://github.com/ohcnetwork/care/pkgs/container/care) 77 79 78 80 For backup and restore use [this](/docs/databases/backup.rst) documentation. 81 + 79 82 ## Contributing 80 83 81 84 We welcome contributions from everyone. Please read our [contributing guidelines](./CONTRIBUTING.md) to get started.
+13 -13
care/emr/api/viewsets/base.py
··· 46 46 return drf_exception_handler(exc, context) 47 47 48 48 49 - class EMRQuestionnaireMixin: 50 - @action(detail=False, methods=["GET"]) 51 - def questionnaire_spec(self, *args, **kwargs): 52 - return Response( 53 - {"version": "1.0", "questions": self.pydantic_model.as_questionnaire()} 54 - ) 55 - 56 - @action(detail=False, methods=["GET"]) 57 - def json_schema_spec(self, *args, **kwargs): 58 - return Response( 59 - {"version": "1.0", "questions": self.pydantic_model.model_json_schema()} 60 - ) 49 + # class EMRQuestionnaireMixin: 50 + # @action(detail=False, methods=["GET"]) 51 + # def questionnaire_spec(self, *args, **kwargs): 52 + # return Response( 53 + # {"version": "1.0", "questions": self.pydantic_model.as_questionnaire()} 54 + # ) 55 + # 56 + # @action(detail=False, methods=["GET"]) 57 + # def json_schema_spec(self, *args, **kwargs): 58 + # return Response( 59 + # {"version": "1.0", "questions": self.pydantic_model.model_json_schema()} 60 + # ) 61 61 62 62 63 63 class EMRRetrieveMixin: ··· 235 235 return emr_exception_handler 236 236 237 237 def get_queryset(self): 238 - return self.filter_queryset(self.database_model.objects.all()) 238 + return self.database_model.objects.all() 239 239 240 240 def get_retrieve_pydantic_model(self): 241 241 if self.pydantic_retrieve_model:
+22 -85
care/emr/api/viewsets/condition.py
··· 4 4 from rest_framework.generics import get_object_or_404 5 5 6 6 from care.emr.api.viewsets.base import ( 7 - EMRBaseViewSet, 8 - EMRCreateMixin, 9 - EMRListMixin, 10 7 EMRModelViewSet, 11 8 EMRQuestionnaireResponseMixin, 12 - EMRRetrieveMixin, 13 - EMRUpdateMixin, 14 - EMRUpsertMixin, 15 9 ) 16 10 from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase 17 11 from care.emr.models.condition import Condition 18 12 from care.emr.models.encounter import Encounter 19 - from care.emr.models.patient import Patient 20 13 from care.emr.registries.system_questionnaire.system_questionnaire import ( 21 14 InternalQuestionnaireRegistry, 22 15 ) 23 16 from care.emr.resources.condition.spec import ( 24 17 CategoryChoices, 25 - ChronicConditionUpdateSpec, 26 18 ConditionReadSpec, 27 19 ConditionSpec, 28 20 ConditionUpdateSpec, 29 21 ) 30 22 from care.emr.resources.questionnaire.spec import SubjectType 31 23 from care.security.authorization import AuthorizationController 24 + from care.utils.filters.multiselect import MultiSelectFilter 32 25 33 26 34 27 class ValidateEncounterMixin: ··· 51 44 52 45 class ConditionFilters(FilterSet): 53 46 encounter = UUIDFilter(field_name="encounter__external_id") 54 - clinical_status = CharFilter(field_name="clinical_status", lookup_expr="iexact") 55 - verification_status = CharFilter( 56 - field_name="verification_status", lookup_expr="iexact" 47 + clinical_status = MultiSelectFilter(field_name="clinical_status") 48 + exclude_clinical_status = MultiSelectFilter( 49 + field_name="clinical_status", exclude=True 50 + ) 51 + verification_status = MultiSelectFilter(field_name="verification_status") 52 + exclude_verification_status = MultiSelectFilter( 53 + field_name="verification_status", exclude=True 57 54 ) 55 + 58 56 severity = CharFilter(field_name="severity", lookup_expr="iexact") 59 57 name = CharFilter(field_name="code__display", lookup_expr="icontains") 58 + category = MultiSelectFilter(field_name="category") 60 59 61 60 62 61 class SymptomViewSet( ··· 113 112 # Filters 114 113 filterset_class = ConditionFilters 115 114 filter_backends = [DjangoFilterBackend] 115 + 116 116 # Questionnaire Spec 117 117 questionnaire_type = "diagnosis" 118 118 questionnaire_title = "Diagnosis" 119 119 questionnaire_description = "Diagnosis" 120 120 questionnaire_subject_type = SubjectType.patient.value 121 121 122 - def perform_create(self, instance): 123 - instance.category = CategoryChoices.encounter_diagnosis.value 124 - super().perform_create(instance) 125 - 126 122 def get_queryset(self): 127 123 # Check if the user has read access to the patient and their EMR Data 128 124 self.authorize_read_encounter() 129 125 return ( 130 126 super() 131 127 .get_queryset() 132 - .filter( 133 - patient__external_id=self.kwargs["patient_external_id"], 134 - category=CategoryChoices.encounter_diagnosis.value, 135 - ) 128 + .filter(patient__external_id=self.kwargs["patient_external_id"]) 136 129 .select_related("patient", "encounter", "created_by", "updated_by") 137 130 ) 138 131 139 - 140 - InternalQuestionnaireRegistry.register(DiagnosisViewSet) 141 - 142 - 143 - class ChronicConditionViewSet( 144 - EMRQuestionnaireResponseMixin, 145 - EMRCreateMixin, 146 - EMRRetrieveMixin, 147 - EMRUpdateMixin, 148 - EMRListMixin, 149 - EMRBaseViewSet, 150 - EMRUpsertMixin, 151 - ): 152 - database_model = Condition 153 - pydantic_model = ConditionSpec 154 - pydantic_read_model = ConditionReadSpec 155 - pydantic_update_model = ChronicConditionUpdateSpec 156 - 157 - # Filters 158 - filterset_class = ConditionFilters 159 - filter_backends = [DjangoFilterBackend] 160 - # Questionnaire Spec 161 - questionnaire_type = "chronic_condition" 162 - questionnaire_title = "Chronic Condition" 163 - questionnaire_description = "Chronic Condition" 164 - questionnaire_subject_type = SubjectType.patient.value 165 - 166 - def get_patient_obj(self): 167 - return get_object_or_404( 168 - Patient, external_id=self.kwargs["patient_external_id"] 169 - ) 170 - 171 - def authorize_create(self, instance): 172 - if not AuthorizationController.call( 173 - "can_write_patient_obj", self.request.user, self.get_patient_obj() 174 - ): 175 - raise PermissionDenied("You do not have permission to update encounter") 176 - 177 132 def authorize_update(self, request_obj, model_instance): 178 - encounter = get_object_or_404(Encounter, external_id=request_obj.encounter) 179 - if not AuthorizationController.call( 180 - "can_update_encounter_obj", 181 - self.request.user, 182 - encounter, 133 + if model_instance.category == CategoryChoices.chronic_condition.value: 134 + if not AuthorizationController.call( 135 + "can_view_clinical_data", self.request.user, model_instance.patient 136 + ): 137 + raise PermissionDenied( 138 + "You do not have permission to update chronic condition" 139 + ) 140 + elif not AuthorizationController.call( 141 + "can_update_encounter_obj", self.request.user, model_instance.encounter 183 142 ): 184 143 raise PermissionDenied("You do not have permission to update encounter") 185 144 186 - def perform_create(self, instance): 187 - instance.category = CategoryChoices.chronic_condition.value 188 - super().perform_create(instance) 189 145 190 - def clean_update_data(self, request_data): 191 - return super().clean_update_data(request_data, keep_fields={"encounter"}) 192 - 193 - def get_queryset(self): 194 - if not AuthorizationController.call( 195 - "can_view_clinical_data", self.request.user, self.get_patient_obj() 196 - ): 197 - raise PermissionDenied("Permission denied for patient data") 198 - return ( 199 - super() 200 - .get_queryset() 201 - .filter( 202 - patient__external_id=self.kwargs["patient_external_id"], 203 - category=CategoryChoices.chronic_condition.value, 204 - ) 205 - .select_related("patient", "encounter", "created_by", "updated_by") 206 - ) 207 - 208 - 209 - InternalQuestionnaireRegistry.register(ChronicConditionViewSet) 146 + InternalQuestionnaireRegistry.register(DiagnosisViewSet)
+1
care/emr/api/viewsets/consent.py
··· 34 34 return ( 35 35 super() 36 36 .get_queryset() 37 + .filter(encounter__patient__external_id=self.kwargs["patient_external_id"]) 37 38 .select_related("encounter", "created_by", "updated_by") 38 39 ) 39 40
+8 -3
care/emr/api/viewsets/device.py
··· 48 48 class DeviceFilters(filters.FilterSet): 49 49 current_encounter = filters.UUIDFilter(field_name="current_encounter__external_id") 50 50 current_location = filters.UUIDFilter(field_name="current_location__external_id") 51 + care_type = filters.CharFilter(field_name="care_type") 51 52 52 53 53 54 class DeviceViewSet(EMRModelViewSet): ··· 106 107 care_device_class = DeviceTypeRegistry.get_care_device_class( 107 108 instance.care_type 108 109 ) 109 - care_device_class().handle_update(self.request.data, instance) 110 + care_device_class().handle_delete(instance) 110 111 super().perform_destroy(instance) 111 112 112 113 def get_queryset(self): ··· 264 265 ).exists(): 265 266 raise ValidationError("Organization is already associated with this device") 266 267 device.managing_organization = organization 267 - device.save(update_fields=["managing_organization"]) 268 + device.save( 269 + update_fields=["managing_organization", "facility_organization_cache"] 270 + ) 268 271 return Response({}) 269 272 270 273 @action(detail=True, methods=["POST"]) ··· 286 289 ) 287 290 288 291 device.managing_organization = None 289 - device.save(update_fields=["managing_organization"]) 292 + device.save( 293 + update_fields=["managing_organization", "facility_organization_cache"] 294 + ) 290 295 return Response({}) 291 296 292 297
+35
care/emr/api/viewsets/encounter.py
··· 31 31 from care.emr.reports import discharge_summary 32 32 from care.emr.resources.encounter.constants import COMPLETED_CHOICES 33 33 from care.emr.resources.encounter.spec import ( 34 + EncounterCareTeamMemberWriteSpec, 34 35 EncounterCreateSpec, 35 36 EncounterListSpec, 36 37 EncounterRetrieveSpec, ··· 40 41 from care.emr.tasks.discharge_summary import generate_discharge_summary_task 41 42 from care.facility.models import Facility 42 43 from care.security.authorization import AuthorizationController 44 + from care.users.models import User 43 45 44 46 45 47 class LiveFilter(filters.CharFilter): ··· 279 281 {"detail": "Discharge Summary will be generated shortly"}, 280 282 status=status.HTTP_202_ACCEPTED, 281 283 ) 284 + 285 + @extend_schema( 286 + request=EncounterCareTeamMemberWriteSpec, responses={200: EncounterRetrieveSpec} 287 + ) 288 + @action(detail=True, methods=["POST"]) 289 + def set_care_team_members(self, request, *args, **kwargs): 290 + request_data = EncounterCareTeamMemberWriteSpec(**request.data) 291 + encounter = self.get_object() 292 + self.authorize_update({}, encounter) 293 + 294 + members = [] 295 + users = [] 296 + for member in request_data.members: 297 + user_obj = get_object_or_404(User, external_id=member.user_id) 298 + if user_obj.id in users: 299 + raise ValidationError({"user": "repeats are not allowed"}) 300 + users.append(user_obj.id) 301 + if not AuthorizationController.call( 302 + "can_view_encounter_obj", request.user, encounter 303 + ): 304 + raise PermissionDenied( 305 + "Treating doctor does not have permission on encounter" 306 + ) 307 + members.append( 308 + { 309 + "user_id": user_obj.id, 310 + "role": member.role.model_dump(mode="json", exclude_defaults=True), 311 + } 312 + ) 313 + 314 + encounter.care_team = members 315 + encounter.save(update_fields=["care_team"]) 316 + return Response({}, status=status.HTTP_200_OK) 282 317 283 318 284 319 def dev_preview_discharge_summary(request, encounter_id):
+4 -3
care/emr/api/viewsets/facility.py
··· 19 19 FacilityReadSpec, 20 20 FacilityRetrieveSpec, 21 21 ) 22 - from care.emr.resources.user.spec import UserSpec 22 + from care.emr.resources.user.spec import PublicUserReadSpec, UserSpec 23 23 from care.facility.models import Facility 24 24 from care.security.authorization import AuthorizationController 25 25 from care.users.models import User ··· 134 134 135 135 class FacilitySchedulableUsersViewSet(EMRModelReadOnlyViewSet): 136 136 database_model = User 137 - pydantic_read_model = UserSpec 137 + pydantic_read_model = PublicUserReadSpec 138 138 authentication_classes = [] 139 139 permission_classes = [] 140 140 ··· 154 154 database_model = User 155 155 pydantic_read_model = UserSpec 156 156 filterset_class = FacilityUserFilter 157 - filter_backends = [DjangoFilterBackend] 157 + filter_backends = [DjangoFilterBackend, drf_filters.SearchFilter] 158 + search_fields = ["first_name", "last_name", "username"] 158 159 159 160 def get_queryset(self): 160 161 return User.objects.filter(
+47 -8
care/emr/api/viewsets/facility_organization.py
··· 1 + from django.conf import settings 1 2 from django.db.models import Q 2 3 from django_filters import rest_framework as filters 3 4 from rest_framework import filters as drf_filters ··· 58 59 raise PermissionDenied( 59 60 "Cannot create organizations under root organization" 60 61 ) 62 + if ( 63 + model_obj is None 64 + and parent.level_cache >= settings.FACILITY_ORGANIZATION_MAX_DEPTH 65 + ): 66 + error = ( 67 + f"Max depth reached ({settings.FACILITY_ORGANIZATION_MAX_DEPTH})" 68 + ) 69 + raise ValidationError(error) 70 + 71 + if model_obj is None: 72 + # validate max number in facility 73 + facility_external_id = self.kwargs["facility_external_id"] 74 + if ( 75 + FacilityOrganization.objects.filter( 76 + facility__external_id=facility_external_id 77 + ).count() 78 + >= settings.MAX_ORGANIZATION_IN_FACILITY 79 + ): 80 + error = f"Max location reached for facility ({settings.MAX_ORGANIZATION_IN_FACILITY})" 81 + raise ValidationError(error) 82 + 61 83 # Validate Uniqueness 62 84 if FacilityOrganization.validate_uniqueness( 63 85 FacilityOrganization.objects.filter(facility=self.get_facility_obj()), ··· 179 201 if model_obj: 180 202 return 181 203 organization = self.get_organization_obj() 204 + # TODO : Optimise by fetching user first, avoiding the extra join to org 182 205 queryset = FacilityOrganizationUser.objects.filter( 183 206 user__external_id=instance.user 184 207 ) 185 - if organization.root_org is None: 186 - queryset = queryset.filter(organization=organization) 187 - else: 188 - queryset = queryset.filter( 189 - Q(organization=organization) 190 - | Q(organization__root_org=organization.root_org) 208 + # Case 1 - Same organization 209 + if queryset.filter(Q(organization=organization)).exists(): 210 + raise ValidationError("User association already exists") 211 + # Case 2 - Adding to a child organization ( parent already linked ) 212 + if organization.parent: 213 + parent_orgs = organization.parent_cache 214 + if queryset.filter(Q(organization__in=parent_orgs)).exists(): 215 + raise ValidationError("User is already linked to a parent organization") 216 + # Case 3 - Adding to a parent organization ( child already linked ) 217 + if queryset.filter( 218 + organization__parent_cache__overlap=[organization.id] 219 + ).exists(): 220 + raise ValidationError("User has association to some child organization") 221 + 222 + def validate_destroy(self, instance): 223 + if ( 224 + instance.organization.org_type == "root" 225 + and FacilityOrganizationUser.objects.filter( 226 + organization=self.get_organization_obj() 227 + ).count() 228 + <= 1 229 + ): 230 + raise ValidationError( 231 + "Cannot delete the last user from the root organization" 191 232 ) 192 - if queryset.exists(): 193 - raise ValidationError("User association already exists") 194 233 195 234 def authorize_destroy(self, instance): 196 235 organization = self.get_organization_obj()
+66 -1
care/emr/api/viewsets/file_upload.py
··· 1 + import base64 2 + 3 + import magic 4 + from django.conf import settings 5 + from django.core.files.base import ContentFile 6 + from django.db import transaction 1 7 from django.utils import timezone 2 8 from django_filters import rest_framework as filters 3 9 from drf_spectacular.utils import extend_schema 4 10 from pydantic import BaseModel 5 11 from rest_framework.decorators import action 6 - from rest_framework.exceptions import PermissionDenied 12 + from rest_framework.exceptions import PermissionDenied, ValidationError 7 13 from rest_framework.generics import get_object_or_404 8 14 from rest_framework.response import Response 9 15 ··· 77 83 class FileUploadFilter(filters.FilterSet): 78 84 is_archived = filters.BooleanFilter(field_name="is_archived") 79 85 file_category = FileCategoryFilter() 86 + name = filters.CharFilter(field_name="name", lookup_expr="icontains") 80 87 81 88 82 89 class FileUploadViewSet( ··· 166 173 ] 167 174 ) 168 175 return Response(FileUploadListSpec.serialize(obj).to_json()) 176 + 177 + @action(detail=False, methods=["POST"], url_path="upload-file") 178 + def upload_file(self, request, *args, **kwargs): 179 + file_name = request.data.get("original_name") 180 + file_data = request.data.get("file_data") 181 + 182 + if not file_name or not file_data: 183 + raise ValidationError( 184 + "Missing required fields: 'original_name' or 'file_data'" 185 + ) 186 + 187 + try: 188 + file_content = base64.b64decode(file_data) 189 + except Exception as e: 190 + error = "Invalid base64-encoded file data" 191 + raise ValidationError(error) from e 192 + 193 + uploaded_file = ContentFile(file_content, name=file_name) 194 + 195 + max_file_size = settings.MAX_FILE_UPLOAD_SIZE * 1024 * 1024 196 + if uploaded_file.size > max_file_size: 197 + error = f"File size exceeds the limit of {max_file_size / (1024 * 1024)}MB" 198 + raise ValidationError(error) 199 + 200 + try: 201 + mime_type = magic.from_buffer(file_content[:2048], mime=True) 202 + except Exception as e: 203 + error = "Error detecting file type." 204 + raise ValidationError(error) from e 205 + 206 + if mime_type not in settings.ALLOWED_MIME_TYPES: 207 + error = f"File type '{mime_type}' is not allowed" 208 + raise ValidationError(error) 209 + 210 + request_data = { 211 + "original_name": file_name, 212 + "name": request.data.get("name"), 213 + "associating_id": request.data.get("associating_id"), 214 + "file_type": request.data.get("file_type"), 215 + "file_category": request.data.get("file_category"), 216 + "mime_type": mime_type, 217 + } 218 + 219 + with transaction.atomic(): 220 + file_upload = FileUploadCreateSpec(**request_data).de_serialize() 221 + file_upload._just_created = False # noqa SLF001 222 + self.authorize_create(file_upload) 223 + file_upload.save() 224 + 225 + try: 226 + file_upload.files_manager.put_object(file_upload, uploaded_file) 227 + file_upload.upload_completed = True 228 + file_upload.save(skip_internal_name=True) 229 + except Exception as e: 230 + error_msg = "Failed to upload file to storage" 231 + raise ValidationError(error_msg) from e 232 + 233 + return Response(FileUploadRetrieveSpec.serialize(file_upload).to_json())
+25 -2
care/emr/api/viewsets/location.py
··· 1 + from django.conf import settings 1 2 from django.db import transaction 2 3 from django_filters import rest_framework as filters 3 4 from drf_spectacular.utils import extend_schema 4 5 from pydantic import UUID4, BaseModel 6 + from rest_framework import filters as rest_framework_filters 5 7 from rest_framework.decorators import action 6 8 from rest_framework.exceptions import PermissionDenied, ValidationError 7 9 from rest_framework.generics import get_object_or_404 ··· 64 66 pydantic_retrieve_model = FacilityLocationRetrieveSpec 65 67 pydantic_update_model = FacilityLocationUpdateSpec 66 68 filterset_class = FacilityLocationFilter 67 - filter_backends = [filters.DjangoFilterBackend] 69 + filter_backends = [ 70 + filters.DjangoFilterBackend, 71 + rest_framework_filters.OrderingFilter, 72 + ] 73 + ordering_fields = ["sort_index"] 68 74 69 75 def get_facility_obj(self): 70 76 return get_object_or_404( ··· 89 95 90 96 def validate_data(self, instance, model_obj=None): 91 97 facility = self.get_facility_obj() 92 - if not model_obj and instance.parent: 98 + if model_obj is None and instance.parent: 93 99 parent = get_object_or_404(FacilityLocation, external_id=instance.parent) 94 100 if parent.facility_id != facility.id: 95 101 raise PermissionDenied("Parent Incompatible with Location") 96 102 if parent.mode == FacilityLocationModeChoices.instance.value: 97 103 raise ValidationError("Instances cannot have children") 104 + 105 + # Validate Depth 106 + if parent.level_cache >= settings.LOCATION_MAX_DEPTH: 107 + error = f"Max depth reached ({settings.LOCATION_MAX_DEPTH})" 108 + raise ValidationError(error) 109 + 110 + if model_obj is None: 111 + # validate number of locations in facility 112 + facility_external_id = self.kwargs["facility_external_id"] 113 + if ( 114 + FacilityLocation.objects.filter( 115 + facility__external_id=facility_external_id 116 + ).count() 117 + >= settings.MAX_LOCATION_IN_FACILITY 118 + ): 119 + error = f"Max location reached for facility ({settings.MAX_LOCATION_IN_FACILITY})" 120 + raise ValidationError(error) 98 121 99 122 def authorize_create(self, instance): 100 123 facility = self.get_facility_obj()
+2 -9
care/emr/api/viewsets/medication_statement.py
··· 12 12 MedicationStatementUpdateSpec, 13 13 ) 14 14 from care.emr.resources.questionnaire.spec import SubjectType 15 - 16 - 17 - class StatusFilter(filters.CharFilter): 18 - def filter(self, qs, value): 19 - if value: 20 - statuses = value.split(",") 21 - return qs.filter(status__in=statuses) 22 - return qs 15 + from care.utils.filters.multiselect import MultiSelectFilter 23 16 24 17 25 18 class MedicationStatementFilter(filters.FilterSet): 26 19 encounter = filters.UUIDFilter(field_name="encounter__external_id") 27 - status = StatusFilter() 20 + status = MultiSelectFilter(field_name="status") 28 21 name = filters.CharFilter(field_name="medication__display", lookup_expr="icontains") 29 22 30 23
+1
care/emr/api/viewsets/meta_artifact.py
··· 118 118 associating_type=associating_type, 119 119 associating_external_id=associating_id, 120 120 ) 121 + .order_by("-modified_date") 121 122 )
+22 -8
care/emr/api/viewsets/organization.py
··· 1 + from django.conf import settings 1 2 from django.db.models import Q 2 3 from django_filters import rest_framework as filters 3 4 from rest_framework.decorators import action ··· 77 78 Organization.objects.all(), instance, model_obj 78 79 ): 79 80 raise ValidationError("Organization already exists with same name") 81 + 82 + if instance.parent and model_obj is None: 83 + parent = get_object_or_404(Organization, external_id=instance.parent) 84 + 85 + # Validate Depth 86 + if parent.level_cache >= settings.ORGANIZATION_MAX_DEPTH: 87 + error = f"Max depth reached ({settings.ORGANIZATION_MAX_DEPTH})" 88 + raise ValidationError(error) 80 89 81 90 def authorize_destroy(self, instance): 82 91 if Organization.objects.filter(parent=instance).exists(): ··· 226 235 if model_obj: 227 236 return 228 237 organization = self.get_organization_obj() 238 + # TODO : Optimise by fetching user first, avoiding the extra join to org 229 239 queryset = OrganizationUser.objects.filter(user__external_id=instance.user) 230 - if organization.root_org is None: 231 - queryset = queryset.filter(organization=organization) 232 - else: 233 - queryset = queryset.filter( 234 - Q(organization=organization) 235 - | Q(organization__root_org=organization.root_org) 236 - ) 237 - if queryset.exists(): 240 + # Case 1 - Same organization 241 + if queryset.filter(Q(organization=organization)).exists(): 238 242 raise ValidationError("User association already exists") 243 + # Case 2 - Adding to a child organization ( parent already linked ) 244 + if organization.parent: 245 + parent_orgs = organization.parent_cache 246 + if queryset.filter(Q(organization__in=parent_orgs)).exists(): 247 + raise ValidationError("User is already linked to a parent organization") 248 + # Case 3 - Adding to a parent organization ( child already linked ) 249 + if queryset.filter( 250 + organization__parent_cache__overlap=[organization.id] 251 + ).exists(): 252 + raise ValidationError("User has association to some child organization") 239 253 240 254 def authorize_update(self, request_obj, model_instance): 241 255 organization = self.get_organization_obj()
+23
care/emr/api/viewsets/patient.py
··· 1 1 import datetime 2 2 3 + from django.utils import timezone 3 4 from django_filters import CharFilter, FilterSet 4 5 from django_filters.rest_framework import DjangoFilterBackend 5 6 from drf_spectacular.utils import extend_schema ··· 17 18 PatientListSpec, 18 19 PatientPartialSpec, 19 20 PatientRetrieveSpec, 21 + PatientUpdateSpec, 20 22 ) 21 23 from care.emr.resources.scheduling.slot.spec import TokenBookingReadSpec 22 24 from care.emr.resources.user.spec import UserSpec ··· 34 36 database_model = Patient 35 37 pydantic_model = PatientCreateSpec 36 38 pydantic_read_model = PatientListSpec 39 + pydantic_update_model = PatientUpdateSpec 37 40 pydantic_retrieve_model = PatientRetrieveSpec 38 41 filterset_class = PatientFilters 39 42 filter_backends = [DjangoFilterBackend] ··· 51 54 def authorize_destroy(self, instance): 52 55 if not self.request.user.is_superuser: 53 56 raise PermissionDenied("Cannot delete patient") 57 + 58 + def validate_data(self, instance, model_obj=None): 59 + dob = instance.date_of_birth or (model_obj and model_obj.date_of_birth) 60 + deceased = instance.deceased_datetime or ( 61 + model_obj and model_obj.deceased_datetime 62 + ) 63 + 64 + if dob and deceased and dob > deceased.date(): 65 + raise ValidationError("Date of birth cannot be after the date of death") 66 + 67 + age = instance.age or ( 68 + model_obj 69 + and model_obj.year_of_birth 70 + and timezone.now().year - model_obj.year_of_birth 71 + ) 72 + 73 + if age and deceased: 74 + calculated_birth_year = timezone.now().year - age 75 + if calculated_birth_year > deceased.year: 76 + raise ValidationError("Year of birth cannot be after the year of death") 54 77 55 78 def get_queryset(self): 56 79 qs = (
+3 -1
care/emr/api/viewsets/questionnaire_response.py
··· 42 42 Encounter, external_id=self.request.GET["encounter"] 43 43 ) 44 44 else: 45 - obj = get_object_or_404(QuestionnaireResponse, self.kwargs["external_id"]) 45 + obj = get_object_or_404( 46 + QuestionnaireResponse, external_id=self.kwargs["external_id"] 47 + ) 46 48 patient = obj.patient 47 49 encounter = obj.encounter 48 50 if encounter:
+10 -6
care/emr/api/viewsets/resource_request.py
··· 76 76 ) 77 77 78 78 def get_queryset(self): 79 - queryset = ResourceRequest.objects.all().select_related( 80 - "origin_facility", 81 - "approving_facility", 82 - "assigned_facility", 83 - "related_patient", 84 - "assigned_to", 79 + queryset = ( 80 + ResourceRequest.objects.all() 81 + .select_related( 82 + "origin_facility", 83 + "approving_facility", 84 + "assigned_facility", 85 + "related_patient", 86 + "assigned_to", 87 + ) 88 + .order_by("-created_date") 85 89 ) 86 90 if self.request.user.is_superuser: 87 91 return queryset
+11 -7
care/emr/api/viewsets/scheduling/availability.py
··· 18 18 from care.emr.models.scheduling.schedule import Availability, SchedulableUserResource 19 19 from care.emr.resources.scheduling.schedule.spec import SlotTypeOptions 20 20 from care.emr.resources.scheduling.slot.spec import ( 21 - CANCELLED_STATUS_CHOICES, 21 + COMPLETED_STATUS_CHOICES, 22 22 TokenBookingReadSpec, 23 23 TokenSlotBaseSpec, 24 24 ) ··· 107 107 108 108 def lock_create_appointment(token_slot, patient, created_by, reason_for_visit): 109 109 with Lock(f"booking:resource:{token_slot.resource.id}"), transaction.atomic(): 110 - if token_slot.start_datetime < timezone.now(): 110 + if token_slot.end_datetime < timezone.now(): 111 111 raise ValidationError("Slot is already past") 112 112 if token_slot.allocated >= token_slot.availability.tokens_per_slot: 113 113 raise ValidationError("Slot is already full") 114 114 if ( 115 115 TokenBooking.objects.filter(token_slot=token_slot, patient=patient) 116 - .exclude(status__in=CANCELLED_STATUS_CHOICES) 116 + .exclude(status__in=COMPLETED_STATUS_CHOICES) 117 117 .exists() 118 118 ): 119 119 raise ValidationError("Patient already has a booking for this slot") ··· 193 193 # Create everything else 194 194 for _slot in slots: 195 195 slot = slots[_slot] 196 + end_datetime = datetime.datetime.combine( 197 + request_data.day, slot["end_time"], tzinfo=None 198 + ) 199 + # Skip creating slots in the past 200 + if end_datetime < timezone.make_naive(timezone.now()): 201 + continue 196 202 TokenSlot.objects.create( 197 203 resource=schedulable_resource_obj, 198 204 start_datetime=datetime.datetime.combine( 199 205 request_data.day, slot["start_time"], tzinfo=None 200 206 ), 201 - end_datetime=datetime.datetime.combine( 202 - request_data.day, slot["end_time"], tzinfo=None 203 - ), 207 + end_datetime=end_datetime, 204 208 availability_id=slot["availability_id"], 205 209 ) 206 210 # Compare and figure out what needs to be created ··· 230 234 patient=patient, 231 235 token_slot__start_datetime__gte=care_now(), 232 236 ) 233 - .exclude(status__in=CANCELLED_STATUS_CHOICES) 237 + .exclude(status__in=COMPLETED_STATUS_CHOICES) 234 238 .count() 235 239 >= settings.MAX_APPOINTMENTS_PER_PATIENT 236 240 ):
+13 -3
care/emr/api/viewsets/scheduling/booking.py
··· 1 1 from typing import Literal 2 2 3 3 from django.db import transaction 4 - from django_filters import CharFilter, DateFromToRangeFilter, FilterSet, UUIDFilter 4 + from django_filters import ( 5 + CharFilter, 6 + DateFromToRangeFilter, 7 + FilterSet, 8 + UUIDFilter, 9 + ) 5 10 from django_filters.rest_framework import DjangoFilterBackend 6 11 from pydantic import UUID4, BaseModel 7 12 from rest_framework.decorators import action ··· 49 54 patient = UUIDFilter(field_name="patient__external_id") 50 55 51 56 def filter_by_user(self, queryset, name, value): 57 + facility_external_id = self.request.parser_context.get("kwargs", {}).get( 58 + "facility_external_id" 59 + ) 52 60 resource = SchedulableUserResource.objects.filter( 53 - user__external_id=value 61 + user__external_id=value, 62 + facility__external_id=facility_external_id, 54 63 ).first() 55 64 if not resource: 56 65 return queryset.none() ··· 160 169 facility_users = FacilityOrganizationUser.objects.filter( 161 170 organization__facility=facility, 162 171 user_id__in=SchedulableUserResource.objects.filter( 163 - facility=facility 172 + facility=facility, 173 + user__deleted=False, 164 174 ).values("user_id"), 165 175 ) 166 176
+19 -1
care/emr/api/viewsets/user.py
··· 1 - from django.db import transaction 1 + from django.db import IntegrityError, transaction 2 2 from django.utils.decorators import method_decorator 3 3 from django_filters import rest_framework as filters 4 4 from rest_framework import filters as drf_filters ··· 18 18 UserTypeRoleMapping, 19 19 UserUpdateSpec, 20 20 ) 21 + from care.emr.utils.send_password_reset_mail import send_password_creation_email 21 22 from care.security.authorization import AuthorizationController 22 23 from care.security.models import RoleModel 23 24 from care.users.api.serializers.user import UserImageUploadSerializer, UserSerializer ··· 45 46 filter_backends = [filters.DjangoFilterBackend, drf_filters.SearchFilter] 46 47 search_fields = ["first_name", "last_name", "username"] 47 48 49 + def get_queryset(self): 50 + return User.objects.filter(deleted=False) 51 + 48 52 def perform_create(self, instance): 49 53 with transaction.atomic(): 50 54 super().perform_create(instance) ··· 69 73 name=UserTypeRoleMapping[instance.user_type].value.name, 70 74 ), 71 75 ) 76 + if not instance.has_usable_password(): 77 + try: 78 + send_password_creation_email(instance) 79 + except Exception as e: 80 + raise IntegrityError( 81 + "User creation failed due to email error." 82 + ) from e # to fail the transaction 72 83 73 84 def authorize_update(self, request_obj, model_instance): 74 85 if self.request.user.is_superuser: ··· 79 90 def authorize_create(self, instance): 80 91 if not AuthorizationController.call("can_create_user", self.request.user): 81 92 raise PermissionDenied("You do not have permission to create Users") 93 + 94 + def perform_destroy(self, instance): 95 + if instance.last_login: 96 + instance.deleted = True 97 + instance.save(update_fields=["deleted"]) 98 + else: 99 + instance.delete() 82 100 83 101 def authorize_destroy(self, instance): 84 102 return self.request.user.is_superuser
+137 -2
care/emr/api/viewsets/valueset.py
··· 1 + from django.core.cache import cache 1 2 from django_filters import rest_framework as filters 2 3 from django_filters.rest_framework import DjangoFilterBackend 3 4 from drf_spectacular.utils import extend_schema 4 5 from pydantic import BaseModel, Field 5 6 from rest_framework.decorators import action 7 + from rest_framework.exceptions import ValidationError 6 8 from rest_framework.response import Response 7 9 8 10 from care.emr.api.viewsets.base import EMRModelViewSet 9 - from care.emr.fhir.resources.code_concept import CodeConceptResource 10 - from care.emr.models.valueset import ValueSet 11 + from care.emr.fhir.resources.code_concept import CodeConceptResource, MinimalCodeConcept 12 + from care.emr.models.valueset import ( 13 + RecentViewsManager, 14 + UserValueSetPreference, 15 + ValueSet, 16 + ) 11 17 from care.emr.resources.common.coding import Coding 12 18 from care.emr.resources.valueset.spec import ValueSetReadSpec, ValueSetSpec 13 19 ··· 39 45 "expand", 40 46 "validate_code", 41 47 "preview_search", 48 + "favourites", 49 + "add_favourite", 50 + "remove_favourite", 51 + "clear_favourites", 52 + "recent_views", 53 + "add_recent_view", 54 + "remove_recent_view", 55 + "clear_recent_views", 42 56 ]: 43 57 return True 44 58 # Only superusers have write permission over valuesets ··· 50 64 def get_serializer_class(self): 51 65 return ValueSetSpec 52 66 67 + def get_recent_view_cache_key(self, valueset_slug, user_id): 68 + return f"user_valueset_code_prefs:{valueset_slug}:{user_id}:recent_views" 69 + 70 + def get_favourites_cache_key(self, valueset_slug, user_id): 71 + return f"user_valueset_code_prefs:{valueset_slug}:{user_id}:favourites" 72 + 53 73 @extend_schema(request=ExpandRequest, responses={200: None}, methods=["POST"]) 54 74 @action(detail=True, methods=["POST"]) 55 75 def expand(self, request, *args, **kwargs): ··· 98 118 {"error": "No results found for the given system and code"}, status=404 99 119 ) 100 120 return Response(result) 121 + 122 + @action(detail=True, methods=["GET"]) 123 + def favourites(self, request, *args, **kwargs): 124 + valueset_slug = kwargs.get(self.lookup_field) 125 + user_id = request.user.external_id 126 + cache_key = self.get_favourites_cache_key(valueset_slug, user_id) 127 + favs = cache.get(cache_key) 128 + if favs is None: 129 + try: 130 + pref = UserValueSetPreference.objects.get( 131 + user=request.user, valueset=self.get_object() 132 + ) 133 + favs = pref.favorite_codes 134 + except UserValueSetPreference.DoesNotExist: 135 + favs = [] 136 + cache.set(cache_key, favs) 137 + return Response(favs) 138 + 139 + @action(detail=True, methods=["POST"]) 140 + def add_favourite(self, request, *args, **kwargs): 141 + valueset_slug = kwargs.get(self.lookup_field) 142 + user = request.user 143 + cache_key = self.get_favourites_cache_key(valueset_slug, user.external_id) 144 + code_obj = MinimalCodeConcept(**request.data) 145 + 146 + valueset = self.get_object() 147 + if not valueset.lookup(code_obj): 148 + raise ValidationError("Invalid code value") 149 + 150 + pref, created = UserValueSetPreference.objects.get_or_create( 151 + user=user, valueset=valueset, defaults={"favorite_codes": []} 152 + ) 153 + favs = pref.favorite_codes 154 + if not any(fav.get("code") == code_obj.code for fav in favs): 155 + favs.append(code_obj.model_dump()) 156 + pref.favorite_codes = favs 157 + pref.save(update_fields=["favorite_codes"]) 158 + cache.set(cache_key, favs) 159 + message = f"Code {code_obj.code} added to favourites" 160 + else: 161 + message = f"Code {code_obj.code} already exists in favourites" 162 + return Response({"message": message}) 163 + 164 + @action(detail=True, methods=["POST"]) 165 + def remove_favourite(self, request, *args, **kwargs): 166 + valueset_slug = kwargs.get(self.lookup_field) 167 + user = request.user 168 + cache_key = self.get_favourites_cache_key(valueset_slug, user.external_id) 169 + code_obj = MinimalCodeConcept(**request.data) 170 + 171 + valueset = self.get_object() 172 + 173 + try: 174 + pref = UserValueSetPreference.objects.get(user=user, valueset=valueset) 175 + favs = pref.favorite_codes 176 + new_favs = [fav for fav in favs if fav.get("code") != code_obj.code] 177 + pref.favorite_codes = new_favs 178 + pref.save(update_fields=["favorite_codes"]) 179 + cache.set(cache_key, new_favs) 180 + message = f"Code {code_obj.code} removed from favourites" 181 + except UserValueSetPreference.DoesNotExist: 182 + message = "No favourites found to remove from" 183 + return Response({"message": message}) 184 + 185 + @action(detail=True, methods=["POST"]) 186 + def clear_favourites(self, request, *args, **kwargs): 187 + valueset_slug = kwargs.get(self.lookup_field) 188 + user = request.user 189 + cache_key = self.get_favourites_cache_key(valueset_slug, user.external_id) 190 + try: 191 + pref = UserValueSetPreference.objects.get( 192 + user=user, valueset=self.get_object() 193 + ) 194 + pref.favorite_codes = [] 195 + pref.save(update_fields=["favorite_codes"]) 196 + cache.delete(cache_key) 197 + message = "All favourites cleared" 198 + except UserValueSetPreference.DoesNotExist: 199 + message = "No favourites found" 200 + return Response({"message": message}) 201 + 202 + @extend_schema(request=MinimalCodeConcept, responses={200: None}, methods=["POST"]) 203 + @action(detail=True, methods=["POST"]) 204 + def add_recent_view(self, request, *args, **kwargs): 205 + valueset_slug = kwargs.get(self.lookup_field) 206 + user_id = request.user.external_id 207 + cache_key = self.get_recent_view_cache_key(valueset_slug, user_id) 208 + code_obj = MinimalCodeConcept(**request.data) 209 + RecentViewsManager.add_recent_view(cache_key, code_obj.model_dump()) 210 + return Response({"message": f"Code {code_obj.code} added to recent views"}) 211 + 212 + @extend_schema(request=MinimalCodeConcept, responses={200: None}, methods=["POST"]) 213 + @action(detail=True, methods=["POST"]) 214 + def remove_recent_view(self, request, *args, **kwargs): 215 + valueset_slug = kwargs.get(self.lookup_field) 216 + user_id = request.user.external_id 217 + cache_key = self.get_recent_view_cache_key(valueset_slug, user_id) 218 + code_obj = MinimalCodeConcept(**request.data) 219 + RecentViewsManager.remove_recent_view(cache_key, code_obj.model_dump()) 220 + return Response({"message": f"Code {code_obj.code} removed from recent views"}) 221 + 222 + @action(detail=True, methods=["GET"]) 223 + def recent_views(self, request, *args, **kwargs): 224 + valueset_slug = kwargs.get(self.lookup_field) 225 + user_id = request.user.external_id 226 + cache_key = self.get_recent_view_cache_key(valueset_slug, user_id) 227 + return Response(RecentViewsManager.get_recent_views(cache_key)) 228 + 229 + @action(detail=True, methods=["POST"]) 230 + def clear_recent_views(self, request, *args, **kwargs): 231 + valueset_slug = kwargs.get(self.lookup_field) 232 + user_id = request.user.external_id 233 + cache_key = self.get_recent_view_cache_key(valueset_slug, user_id) 234 + RecentViewsManager.clear_recent_views(cache_key) 235 + return Response({"message": "All recent views cleared"})
+8 -8
care/emr/fhir/resources/valueset.py
··· 42 42 {"name": "coding", "valueCoding": code.model_dump(exclude_defaults=True)}, 43 43 ] 44 44 request_json = {"resourceType": "Parameters", "parameter": parameters} 45 + 45 46 full_result = self.query("POST", "ValueSet/$validate-code", request_json) 46 - try: 47 - results = full_result["parameter"] 48 - for result in results: 49 - if result["name"] == "result": 50 - return result["valueBoolean"] 51 - except Exception as e: 52 - err = "Unknown Value Returned from Terminology Server" 53 - raise Exception(err) from e 47 + if "parameter" not in full_result: 48 + raise ValueError("Valueset does not have specified code") 49 + results = full_result["parameter"] 50 + for result in results: 51 + if result["name"] == "result": 52 + return result["valueBoolean"] 53 + return False 54 54 55 55 def search(self): 56 56 parameters = []
+18
care/emr/migrations/0022_facilitylocation_sort_index.py
··· 1 + # Generated by Django 5.1.4 on 2025-03-15 16:29 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0021_metaartifact'), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name='facilitylocation', 15 + name='sort_index', 16 + field=models.IntegerField(default=0), 17 + ), 18 + ]
+18
care/emr/migrations/0023_allergyintolerance_allergy_intolerance_type.py
··· 1 + # Generated by Django 5.1.4 on 2025-03-19 14:42 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0022_facilitylocation_sort_index'), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name='allergyintolerance', 15 + name='allergy_intolerance_type', 16 + field=models.CharField(default='allergy', max_length=20), 17 + ), 18 + ]
+37
care/emr/migrations/0023_uservaluesetpreference.py
··· 1 + # Generated by Django 5.1.4 on 2025-03-18 05:38 2 + 3 + import django.db.models.deletion 4 + import uuid 5 + from django.conf import settings 6 + from django.db import migrations, models 7 + 8 + 9 + class Migration(migrations.Migration): 10 + 11 + dependencies = [ 12 + ('emr', '0022_facilitylocation_sort_index'), 13 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 + ] 15 + 16 + operations = [ 17 + migrations.CreateModel( 18 + name='UserValueSetPreference', 19 + fields=[ 20 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), 22 + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), 23 + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), 24 + ('deleted', models.BooleanField(db_index=True, default=False)), 25 + ('history', models.JSONField(default=dict)), 26 + ('meta', models.JSONField(default=dict)), 27 + ('favorite_codes', models.JSONField(default=list)), 28 + ('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)), 29 + ('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)), 30 + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 31 + ('valueset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.valueset')), 32 + ], 33 + options={ 34 + 'unique_together': {('user', 'valueset')}, 35 + }, 36 + ), 37 + ]
+14
care/emr/migrations/0024_merge_20250320_2002.py
··· 1 + # Generated by Django 5.1.4 on 2025-03-20 14:32 2 + 3 + from django.db import migrations 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0023_allergyintolerance_allergy_intolerance_type'), 10 + ('emr', '0023_uservaluesetpreference'), 11 + ] 12 + 13 + operations = [ 14 + ]
+21
care/emr/migrations/0025_alter_tokenbooking_token_slot.py
··· 1 + # Generated by Django 5.1.4 on 2025-03-25 12:12 2 + 3 + import django.db.models.deletion 4 + from django.db import migrations, models 5 + 6 + 7 + class Migration(migrations.Migration): 8 + 9 + dependencies = [ 10 + ("emr", "0024_merge_20250320_2002"), 11 + ] 12 + 13 + operations = [ 14 + migrations.AlterField( 15 + model_name="tokenbooking", 16 + name="token_slot", 17 + field=models.ForeignKey( 18 + on_delete=django.db.models.deletion.PROTECT, to="emr.tokenslot" 19 + ), 20 + ), 21 + ]
+19
care/emr/migrations/0026_encounter_treating_doctors.py
··· 1 + # Generated by Django 5.1.4 on 2025-04-02 08:34 2 + 3 + import django.contrib.postgres.fields 4 + from django.db import migrations, models 5 + 6 + 7 + class Migration(migrations.Migration): 8 + 9 + dependencies = [ 10 + ('emr', '0025_alter_tokenbooking_token_slot'), 11 + ] 12 + 13 + operations = [ 14 + migrations.AddField( 15 + model_name='encounter', 16 + name='treating_doctors', 17 + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None), 18 + ), 19 + ]
+18
care/emr/migrations/0027_rename_treating_doctors_encounter_care_team.py
··· 1 + # Generated by Django 5.1.4 on 2025-04-02 13:22 2 + 3 + from django.db import migrations 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0026_encounter_treating_doctors'), 10 + ] 11 + 12 + operations = [ 13 + migrations.RenameField( 14 + model_name='encounter', 15 + old_name='treating_doctors', 16 + new_name='care_team', 17 + ), 18 + ]
+22
care/emr/migrations/0028_alter_encounter_care_team.py
··· 1 + # Generated by Django 5.1.4 on 2025-04-02 13:57 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0027_rename_treating_doctors_encounter_care_team'), 10 + ] 11 + 12 + operations = [ 13 + migrations.RemoveField( 14 + model_name='encounter', 15 + name='care_team', 16 + ), 17 + migrations.AddField( 18 + model_name='encounter', 19 + name='care_team', 20 + field=models.JSONField(default=dict), 21 + ) 22 + ]
+18
care/emr/migrations/0029_encounter_discharge_summary_advice.py
··· 1 + # Generated by Django 5.1.4 on 2025-04-03 16:46 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0028_alter_encounter_care_team'), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name='encounter', 15 + name='discharge_summary_advice', 16 + field=models.TextField(blank=True, null=True), 17 + ), 18 + ]
+1
care/emr/models/allergy_intolerance.py
··· 18 18 copied_from = models.BigIntegerField( 19 19 default=None, null=True, blank=True 20 20 ) # If True, the record is a copy maintained of the given ID 21 + allergy_intolerance_type = models.CharField(max_length=20, default="allergy")
+5
care/emr/models/encounter.py
··· 18 18 hospitalization = models.JSONField(default=dict) 19 19 priority = models.CharField(max_length=100, null=True, blank=True) 20 20 external_identifier = models.CharField(max_length=100, null=True, blank=True) 21 + 22 + care_team = models.JSONField(default=dict) 23 + 21 24 # Organization fields 22 25 facility_organization_cache = ArrayField(models.IntegerField(), default=list) 23 26 24 27 current_location = models.ForeignKey( 25 28 "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True 26 29 ) # Cached field, used for easier querying 30 + 31 + discharge_summary_advice = models.TextField(null=True, blank=True) 27 32 28 33 def sync_organization_cache(self): 29 34 orgs = set()
+11 -3
care/emr/models/location.py
··· 2 2 3 3 from django.contrib.postgres.fields import ArrayField 4 4 from django.db import models 5 + from django.db.models import Max 5 6 from django.utils import timezone 6 7 7 8 from care.emr.models import EMRBaseModel, Encounter, FacilityOrganization ··· 32 33 current_encounter = models.ForeignKey( 33 34 Encounter, on_delete=models.SET_NULL, null=True, blank=True, default=None 34 35 ) # Populated from FacilityLocationEncounter 36 + sort_index = models.IntegerField(default=0) 37 + 35 38 cache_expiry_days = 15 36 39 37 40 def get_parent_json(self): ··· 102 105 103 106 def save(self, *args, **kwargs): 104 107 if not self.id: 105 - super().save(*args, **kwargs) 106 108 if self.parent: 107 109 self.level_cache = self.parent.level_cache + 1 108 110 if self.parent.root_location is None: ··· 111 113 self.root_location = self.parent.root_location 112 114 if not self.parent.has_children: 113 115 self.parent.has_children = True 114 - self.parent.save(update_fields=["has_children"]) 115 116 else: 116 117 self.cached_parent_json = {} 117 - super().save(*args, **kwargs) 118 + if not self.sort_index: 119 + self.sort_index = ( 120 + FacilityLocation.objects.filter(parent=self.parent).aggregate( 121 + Max("sort_index", default=0) 122 + )["sort_index__max"] 123 + + 1 124 + ) 125 + super().save(*args, **kwargs) 118 126 self.sync_organization_cache() 119 127 120 128 def cascade_changes(self):
+5 -1
care/emr/models/questionnaire.py
··· 16 16 17 17 @classmethod 18 18 def serialize_model(cls, obj): 19 - return {"name": obj.name, "slug": obj.slug} 19 + return { 20 + "id": obj.external_id, 21 + "name": obj.name, 22 + "slug": obj.slug, 23 + } 20 24 21 25 @classmethod 22 26 def get_tag(cls, tag_id):
+1 -1
care/emr/models/scheduling/booking.py
··· 20 20 21 21 class TokenBooking(EMRBaseModel): 22 22 token_slot = models.ForeignKey( 23 - TokenSlot, on_delete=models.CASCADE, null=False, blank=False 23 + TokenSlot, on_delete=models.PROTECT, null=False, blank=False 24 24 ) 25 25 patient = models.ForeignKey( 26 26 "emr.Patient",
+70
care/emr/models/valueset.py
··· 1 + import json 2 + 3 + from django.conf import settings 1 4 from django.db import models 5 + from django_redis import get_redis_connection 2 6 3 7 from care.emr.fhir.resources.valueset import ValueSetResource 4 8 from care.emr.models import EMRBaseModel ··· 48 52 for system in systems: 49 53 results.append(ValueSetResource().filter(**systems[system]).lookup(code)) 50 54 return any(results) 55 + 56 + 57 + class UserValueSetPreference(EMRBaseModel): 58 + user = models.ForeignKey("users.User", on_delete=models.CASCADE) 59 + valueset = models.ForeignKey("emr.ValueSet", on_delete=models.CASCADE) 60 + favorite_codes = models.JSONField(default=list) 61 + 62 + class Meta: 63 + unique_together = ("user", "valueset") 64 + 65 + MAX_FAVORITES = getattr(settings, "MAX_FAVORITES_FOR_VALUESET", 50) 66 + 67 + 68 + class RecentViewsManager: 69 + _client = None 70 + MAX_RECENT_VIEW = getattr(settings, "MAX_RECENT_VIEW_FOR_VALUESET", 20) 71 + 72 + @classmethod 73 + def get_client(cls): 74 + if cls._client is None: 75 + cls._client = get_redis_connection("default") 76 + return cls._client 77 + 78 + @classmethod 79 + def _remove_by_code(cls, cache_key, code): 80 + client = cls.get_client() 81 + current_items = client.lrange(cache_key, 0, -1) 82 + 83 + for item in current_items: 84 + try: 85 + item_dict = json.loads(item) 86 + if item_dict.get("code") == code: 87 + client.lrem(cache_key, 0, item) 88 + except Exception: # noqa: S112 89 + continue 90 + 91 + @classmethod 92 + def get_recent_views(cls, cache_key): 93 + client = cls.get_client() 94 + items = client.lrange(cache_key, 0, -1) 95 + return [json.loads(item.decode()) for item in items] 96 + 97 + @classmethod 98 + def add_recent_view(cls, cache_key, code_obj): 99 + code = code_obj.get("code") 100 + if not code: 101 + return 102 + 103 + cls._remove_by_code(cache_key, code) 104 + 105 + client = cls.get_client() 106 + code_json = json.dumps(code_obj) 107 + client.lpush(cache_key, code_json) 108 + client.ltrim(cache_key, 0, cls.MAX_RECENT_VIEW - 1) 109 + 110 + @classmethod 111 + def remove_recent_view(cls, cache_key, code_obj): 112 + code = code_obj.get("code") 113 + if not code: 114 + return 115 + cls._remove_by_code(cache_key, code) 116 + 117 + @classmethod 118 + def clear_recent_views(cls, cache_key): 119 + client = cls.get_client() 120 + client.delete(cache_key)
-14
care/emr/registries/device_type/device_registry.py
··· 32 32 """ 33 33 return {} 34 34 35 - def perform_action(self, obj, action, request): 36 - """ 37 - Perform some kind of action on an asset, the HTTP request is proxied through as is. 38 - an HTTP response object is expected as the return. 39 - """ 40 - return # Return an HTTP Response 41 - 42 35 43 36 class DeviceTypeRegistry: 44 37 _device_types = {} ··· 95 88 Return Extra metadata for the given obj during retrieves 96 89 """ 97 90 return {"Hello": "There from retrieve"} 98 - 99 - def perform_action(self, obj, action, request): 100 - """ 101 - Perform some kind of action on an asset, the HTTP request is proxied through as is. 102 - an HTTP response object is expected as the return. 103 - """ 104 - return # Return an HTTP Response 105 91 106 92 107 93 DeviceTypeRegistry.register("camera", SomeCameraPlugin)
+13
care/emr/reports/discharge_summary.py
··· 24 24 from care.emr.resources.condition.spec import CategoryChoices, VerificationStatusChoices 25 25 from care.emr.resources.file_upload.spec import FileCategoryChoices, FileTypeChoices 26 26 from care.emr.resources.medication.request.spec import MedicationRequestStatus 27 + from care.users.models import User 27 28 28 29 logger = logging.getLogger(__name__) 29 30 ··· 118 119 else None 119 120 ) 120 121 122 + user_roles = { 123 + member["user_id"]: member["role"]["display"] for member in encounter.care_team 124 + } 125 + 126 + care_team_users = User.objects.filter(id__in=user_roles.keys()) 127 + 128 + care_team_display = [ 129 + f"{user.full_name} ({user_roles[user.id]})" for user in care_team_users 130 + ] 131 + 121 132 return { 122 133 "encounter": encounter, 123 134 "admission_duration": admission_duration, ··· 129 140 "observations": observations, 130 141 "medication_requests": medication_requests, 131 142 "files": files, 143 + "care_team": care_team_display, 144 + "discharge_summary_advice": encounter.discharge_summary_advice, 132 145 } 133 146 134 147
+15 -12
care/emr/resources/allergy_intolerance/spec.py
··· 1 1 import datetime 2 2 from enum import Enum 3 3 4 - from pydantic import UUID4, Field, field_validator 4 + from pydantic import UUID4, field_validator 5 5 6 6 from care.emr.models.allergy_intolerance import AllergyIntolerance 7 7 from care.emr.models.encounter import Encounter 8 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 9 8 from care.emr.resources.allergy_intolerance.valueset import CARE_ALLERGY_CODE_VALUESET 10 9 from care.emr.resources.base import EMRResource 11 10 from care.emr.resources.common.coding import Coding 12 11 from care.emr.resources.user.spec import UserSpec 12 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 13 13 14 14 15 15 class ClinicalStatusChoices(str, Enum): ··· 46 46 note: str 47 47 48 48 49 + class AllergyIntoleranceTypeOptions(str, Enum): 50 + allergy = "allergy" 51 + intolerance = "intolerance" 52 + 53 + 49 54 class BaseAllergyIntoleranceSpec(EMRResource): 50 55 __model__ = AllergyIntolerance 51 56 __exclude__ = ["patient", "encounter"] ··· 59 64 last_occurrence: datetime.datetime | None = None 60 65 note: str | None = None 61 66 encounter: UUID4 67 + allergy_intolerance_type: AllergyIntoleranceTypeOptions = ( 68 + AllergyIntoleranceTypeOptions.allergy 69 + ) 62 70 63 71 @field_validator("encounter") 64 72 @classmethod ··· 81 89 last_occurrence: datetime.datetime | None = None 82 90 recorded_date: datetime.datetime | None = None 83 91 encounter: UUID4 84 - code: Coding = Field( 85 - {}, json_schema_extra={"slug": CARE_ALLERGY_CODE_VALUESET.slug} 92 + code: ValueSetBoundCoding[CARE_ALLERGY_CODE_VALUESET.slug] 93 + onset: AllergyIntoleranceOnSetSpec = {} 94 + allergy_intolerance_type: AllergyIntoleranceTypeOptions = ( 95 + AllergyIntoleranceTypeOptions.allergy 86 96 ) 87 - onset: AllergyIntoleranceOnSetSpec = {} 88 - 89 - @field_validator("code") 90 - @classmethod 91 - def validate_code(cls, code: int): 92 - return validate_valueset( 93 - "code", cls.model_fields["code"].json_schema_extra["slug"], code 94 - ) 95 97 96 98 @field_validator("encounter") 97 99 @classmethod ··· 124 126 created_by: dict = {} 125 127 updated_by: dict = {} 126 128 note: str | None = None 129 + allergy_intolerance_type: str 127 130 128 131 @classmethod 129 132 def perform_extra_serialization(cls, mapping, obj):
+56 -56
care/emr/resources/base.py
··· 1 1 import datetime 2 - import uuid 3 - from enum import Enum 4 - from types import UnionType 5 - from typing import Annotated, Union, get_origin 2 + from typing import Annotated, Union 6 3 7 4 import phonenumbers 5 + from django.utils.timezone import is_naive 8 6 from pydantic import BaseModel, model_validator 9 7 from pydantic_extra_types.phone_numbers import PhoneNumberValidator 10 - 11 - from care.emr.resources.common.coding import Coding 12 8 13 9 14 10 class EMRResource(BaseModel): ··· 92 88 self.perform_extra_deserialization(is_update, obj) 93 89 return obj 94 90 95 - @classmethod 96 - def as_questionnaire(cls, parent_classes=None): # noqa PLR0912 97 - """ 98 - This is created so that the FE has an idea about bound valuesets and other metadata about the form 99 - Maybe we can speed up this process by starting with model's JSON Schema 100 - Pydantic provides that by default for all models 101 - """ 102 - if not parent_classes: 103 - parent_classes = [] 104 - if cls.__questionnaire_cache__: 105 - return cls.__questionnaire_cache__ 106 - questionnire_obj = [] 107 - for field in cls.model_fields: 108 - field_class = cls.model_fields[field] 109 - field_obj = {"linkId": field} 110 - field_type = field_class.annotation 111 - 112 - if type(field_type) is UnionType: 113 - field_type = field_type.__args__[0] 114 - 115 - if get_origin(field_type) is list: 116 - field_obj["repeats"] = True 117 - field_type = field_type.__args__[0] 118 - 119 - if field_type in parent_classes: 120 - # Avoiding circular references 121 - continue 122 - 123 - if issubclass(field_type, Enum): 124 - field_obj["type"] = "string" 125 - field_obj["answer_options"] = [{x.name: x.value} for x in field_type] 126 - elif issubclass(field_type, datetime.datetime): 127 - field_obj["type"] = "dateTime" 128 - elif issubclass(field_type, str): 129 - field_obj["type"] = "string" 130 - elif issubclass(field_type, int): 131 - field_obj["type"] = "integer" 132 - elif issubclass(field_type, uuid.UUID): 133 - field_obj["type"] = "string" 134 - elif field_type is Coding: 135 - field_obj["type"] = "coding" 136 - field_obj["valueset"] = {"slug": field_class.json_schema_extra["slug"]} 137 - elif issubclass(field_type, EMRResource): 138 - field_obj["type"] = "group" 139 - parent_classes = parent_classes[::] 140 - parent_classes.append(cls) 141 - field_obj["questions"] = field_type.as_questionnaire(parent_classes) 142 - questionnire_obj.append(field_obj) 143 - cls.__questionnaire_cache__ = questionnire_obj 144 - return questionnire_obj 91 + # @classmethod 92 + # def as_questionnaire(cls, parent_classes=None): 93 + # """ 94 + # This is created so that the FE has an idea about bound valuesets and other metadata about the form 95 + # Maybe we can speed up this process by starting with model's JSON Schema 96 + # Pydantic provides that by default for all models 97 + # """ 98 + # if not parent_classes: 99 + # parent_classes = [] 100 + # if cls.__questionnaire_cache__: 101 + # return cls.__questionnaire_cache__ 102 + # questionnire_obj = [] 103 + # for field in cls.model_fields: 104 + # field_class = cls.model_fields[field] 105 + # field_obj = {"linkId": field} 106 + # field_type = field_class.annotation 107 + # 108 + # if type(field_type) is UnionType: 109 + # field_type = field_type.__args__[0] 110 + # 111 + # if get_origin(field_type) is list: 112 + # field_obj["repeats"] = True 113 + # field_type = field_type.__args__[0] 114 + # 115 + # if field_type in parent_classes: 116 + # # Avoiding circular references 117 + # continue 118 + # 119 + # if issubclass(field_type, Enum): 120 + # field_obj["type"] = "string" 121 + # field_obj["answer_options"] = [{x.name: x.value} for x in field_type] 122 + # elif issubclass(field_type, datetime.datetime): 123 + # field_obj["type"] = "dateTime" 124 + # elif issubclass(field_type, str): 125 + # field_obj["type"] = "string" 126 + # elif issubclass(field_type, int): 127 + # field_obj["type"] = "integer" 128 + # elif issubclass(field_type, uuid.UUID): 129 + # field_obj["type"] = "string" 130 + # elif field_type is Coding: 131 + # field_obj["type"] = "coding" 132 + # field_obj["valueset"] = {"slug": field_class.json_schema_extra["slug"]} 133 + # elif issubclass(field_type, EMRResource): 134 + # field_obj["type"] = "group" 135 + # parent_classes = parent_classes[::] 136 + # parent_classes.append(cls) 137 + # field_obj["questions"] = field_type.as_questionnaire(parent_classes) 138 + # questionnire_obj.append(field_obj) 139 + # cls.__questionnaire_cache__ = questionnire_obj 140 + # return questionnire_obj 145 141 146 142 def to_json(self): 147 143 return self.model_dump(mode="json", exclude=["meta"]) ··· 172 168 173 169 @model_validator(mode="after") 174 170 def validate_period(self): 171 + if self.start and is_naive(self.start): 172 + raise ValueError("Start Date must be timezone aware") 173 + if self.end and is_naive(self.end): 174 + raise ValueError("End Date must be timezone aware") 175 175 if (self.start and self.end) and (self.start > self.end): 176 176 raise ValueError("Start Date cannot be greater than End Date") 177 177 return self
+32 -2
care/emr/resources/common/valueset.py
··· 1 - from pydantic import BaseModel, ConfigDict 1 + from typing import Self 2 + 3 + from pydantic import BaseModel, ConfigDict, field_validator, model_validator 2 4 3 5 4 6 class ValueSetConcept(BaseModel): ··· 6 8 extra="forbid", 7 9 ) 8 10 id: str | None = None 9 - 10 11 code: str | None = None 11 12 display: str | None = None 12 13 ··· 21 22 op: str | None = None 22 23 value: str | None = None 23 24 25 + @field_validator("op") 26 + @classmethod 27 + def validate_op(cls, op: str | None, info): 28 + allowed_op = [ 29 + "=", 30 + "is-a", 31 + "descendent-of", 32 + "is-not-a", 33 + "regex", 34 + "in", 35 + "not-in", 36 + "generalizes", 37 + "child-of", 38 + "descendent-leaf", 39 + "exists", 40 + ] 41 + if op is not None and op not in allowed_op: 42 + error = f"Invalid op value {op}. Allowed values are {allowed_op}" 43 + raise ValueError(error) 44 + return op 45 + 24 46 25 47 class ValueSetInclude(BaseModel): 26 48 model_config = ConfigDict( ··· 31 53 version: str | None = None 32 54 concept: list[ValueSetConcept] | None = None 33 55 filter: list[ValueSetFilter] | None = None 56 + 57 + @model_validator(mode="after") 58 + def check_concept_or_filter(self) -> Self: 59 + if self.concept and self.filter: 60 + raise ValueError( 61 + "Only one of 'concept' or 'filter' can be present, not both." 62 + ) 63 + return self 34 64 35 65 36 66 class ValueSetCompose(BaseModel):
+19 -25
care/emr/resources/condition/spec.py
··· 1 1 import datetime 2 2 from enum import Enum 3 3 4 - from pydantic import UUID4, Field, field_validator 4 + from django.utils.timezone import is_aware, make_aware 5 + from pydantic import UUID4, field_validator 5 6 from rest_framework.generics import get_object_or_404 6 7 7 8 from care.emr.models.condition import Condition 8 9 from care.emr.models.encounter import Encounter 9 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 10 10 from care.emr.resources.base import EMRResource 11 11 from care.emr.resources.common.coding import Coding 12 12 from care.emr.resources.condition.valueset import CARE_CODITION_CODE_VALUESET 13 13 from care.emr.resources.user.spec import UserSpec 14 + from care.emr.utils.valueset_coding_type import ( 15 + ValueSetBoundCoding, 16 + ) 17 + from care.utils.time_util import care_now 14 18 15 19 16 20 class ClinicalStatusChoices(str, Enum): ··· 49 53 onset_age: int | None = None 50 54 onset_string: str | None = None 51 55 note: str | None = None 52 - # 53 - # @field_validator("onset_datetime") 54 - # @classmethod 55 - # def validate_onset_datetime(cls, onset_datetime: datetime.datetime, info): 56 - # if onset_datetime and onset_datetime > care_now(): 57 - # raise ValueError("Onset date cannot be in the future") 58 - # return onset_datetime 56 + 57 + @field_validator("onset_datetime") 58 + @classmethod 59 + def validate_onset_datetime(cls, onset_datetime: datetime.datetime, info): 60 + if onset_datetime: 61 + if not is_aware(onset_datetime): 62 + onset_datetime = make_aware(onset_datetime) 63 + if onset_datetime > care_now(): 64 + raise ValueError("Onset date cannot be in the future") 65 + return onset_datetime 59 66 60 67 61 68 class ConditionAbatementSpec(EMRResource): ··· 75 82 clinical_status: ClinicalStatusChoices | None = None 76 83 verification_status: VerificationStatusChoices 77 84 severity: SeverityChoices | None = None 78 - code: Coding = Field(json_schema_extra={"slug": CARE_CODITION_CODE_VALUESET.slug}) 85 + code: ValueSetBoundCoding[CARE_CODITION_CODE_VALUESET.slug] 79 86 encounter: UUID4 80 87 onset: ConditionOnSetSpec = {} 81 88 abatement: ConditionAbatementSpec = {} 82 89 note: str | None = None 83 - 84 - @field_validator("code") 85 - @classmethod 86 - def validate_code(cls, code: int): 87 - return validate_valueset( 88 - "code", cls.model_fields["code"].json_schema_extra["slug"], code 89 - ) 90 + category: CategoryChoices 90 91 91 92 @field_validator("encounter") 92 93 @classmethod ··· 140 141 clinical_status: ClinicalStatusChoices | None = None 141 142 verification_status: VerificationStatusChoices 142 143 severity: SeverityChoices | None = None 143 - code: Coding = Field(json_schema_extra={"slug": CARE_CODITION_CODE_VALUESET.slug}) 144 + code: ValueSetBoundCoding[CARE_CODITION_CODE_VALUESET.slug] 144 145 onset: ConditionOnSetSpec = {} 145 146 abatement: ConditionAbatementSpec = {} 146 147 note: str | None = None 147 - 148 - @field_validator("code") 149 - @classmethod 150 - def validate_code(cls, code: int): 151 - return validate_valueset( 152 - "code", cls.model_fields["code"].json_schema_extra["slug"], code 153 - ) 154 148 155 149 156 150 class ChronicConditionUpdateSpec(ConditionUpdateSpec):
+9 -1
care/emr/resources/consent/spec.py
··· 2 2 from enum import Enum 3 3 4 4 from django.contrib.auth import get_user_model 5 - from pydantic import UUID4, BaseModel, Field 5 + from pydantic import UUID4, BaseModel, Field, model_validator 6 + from rest_framework.exceptions import ValidationError 6 7 7 8 from care.emr.models import Encounter, FileUpload 8 9 from care.emr.models.consent import Consent ··· 71 72 72 73 73 74 class ConsentCreateSpec(ConsentBaseSpec): 75 + @model_validator(mode="after") 76 + def validate_period_and_date(self): 77 + if self.period.end and self.period.end <= self.date: 78 + raise ValidationError( 79 + "Consent date cannot be greater than the end of the period" 80 + ) 81 + 74 82 def perform_extra_deserialization(self, is_update, obj): 75 83 if not is_update: 76 84 obj.encounter = Encounter.objects.get(external_id=self.encounter)
+32 -1
care/emr/resources/encounter/spec.py
··· 1 - # Not Being used 2 1 import datetime 3 2 3 + from django.contrib.auth import get_user_model 4 4 from django.utils import timezone 5 5 from pydantic import UUID4, BaseModel 6 6 ··· 20 20 EncounterPriorityChoices, 21 21 StatusChoices, 22 22 ) 23 + from care.emr.resources.encounter.valueset import PRACTITIONER_ROLE_VALUESET 23 24 from care.emr.resources.facility.spec import FacilityBareMinimumSpec 24 25 from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec 25 26 from care.emr.resources.location.spec import ( ··· 29 30 from care.emr.resources.patient.spec import PatientListSpec 30 31 from care.emr.resources.permissions import EncounterPermissionsMixin 31 32 from care.emr.resources.scheduling.slot.spec import TokenBookingReadSpec 33 + from care.emr.resources.user.spec import UserSpec 34 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 32 35 from care.facility.models import Facility 33 36 37 + User = get_user_model() 38 + 34 39 35 40 class HospitalizationSpec(BaseModel): 36 41 re_admission: bool | None = None ··· 47 52 "facility", 48 53 "appointment", 49 54 "current_location", 55 + "care_team", 50 56 ] 51 57 52 58 id: UUID4 = None ··· 56 62 hospitalization: HospitalizationSpec | None = {} 57 63 priority: EncounterPriorityChoices 58 64 external_identifier: str | None = None 65 + discharge_summary_advice: str | None = None 59 66 60 67 61 68 class EncounterCreateSpec(EncounterSpecBase): ··· 116 123 organizations: list[dict] = [] 117 124 current_location: dict | None = None 118 125 location_history: list[dict] = [] 126 + care_team: list[dict] = [] 119 127 120 128 @classmethod 121 129 def perform_extra_serialization(cls, mapping, obj): ··· 140 148 "-created_date" 141 149 ) 142 150 ] 151 + 152 + care_team = [] 153 + for member in obj.care_team: 154 + care_team.append( 155 + { 156 + "member": UserSpec.serialize( 157 + User.objects.get(id=member["user_id"]) 158 + ).to_json(), 159 + "role": member["role"], 160 + } 161 + ) 162 + 163 + mapping["care_team"] = care_team 164 + 143 165 cls.serialize_audit_users(mapping, obj) 166 + 167 + 168 + class EncounterCareTeamMemberSpec(BaseModel): 169 + user_id: UUID4 170 + role: ValueSetBoundCoding[PRACTITIONER_ROLE_VALUESET.slug] 171 + 172 + 173 + class EncounterCareTeamMemberWriteSpec(BaseModel): 174 + members: list[EncounterCareTeamMemberSpec]
+26
care/emr/resources/encounter/valueset.py
··· 1 + from care.emr.registries.care_valueset.care_valueset import CareValueset 2 + from care.emr.resources.common.valueset import ValueSetCompose, ValueSetInclude 3 + from care.emr.resources.valueset.spec import ValueSetStatusOptions 4 + 5 + PRACTITIONER_ROLE_VALUESET = CareValueset( 6 + "Practitioner Role", 7 + "system-practitioner-role-code", 8 + ValueSetStatusOptions.active.value, 9 + ) 10 + 11 + PRACTITIONER_ROLE_VALUESET.register_valueset( 12 + ValueSetCompose( 13 + include=[ 14 + ValueSetInclude( 15 + system="http://snomed.info/sct", 16 + filter=[{"property": "concept", "op": "is-a", "value": "223366009"}], 17 + ), 18 + ValueSetInclude( 19 + system="http://snomed.info/sct", 20 + filter=[{"property": "concept", "op": "is-a", "value": "224930009"}], 21 + ), 22 + ] 23 + ) 24 + ) 25 + 26 + PRACTITIONER_ROLE_VALUESET.register_as_system()
+11 -2
care/emr/resources/location/spec.py
··· 1 1 import datetime 2 2 from enum import Enum 3 3 4 - from pydantic import UUID4, model_validator 4 + from pydantic import UUID4, Field, model_validator 5 5 6 6 from care.emr.models import Encounter, FacilityLocationEncounter 7 7 from care.emr.models.location import FacilityLocation ··· 68 68 id: UUID4 | None = None 69 69 70 70 71 + MIN_SORT_INDEX = 0 72 + MAX_SORT_INDEX = 10000 73 + 74 + 71 75 class FacilityLocationSpec(FacilityLocationBaseSpec): 72 76 status: StatusChoices 73 77 operational_status: FacilityLocationOperationalStatusChoices ··· 75 79 description: str 76 80 location_type: Coding | None = None 77 81 form: FacilityLocationFormChoices 82 + sort_index: int | None = Field( 83 + default=0, 84 + ge=MIN_SORT_INDEX, 85 + le=MAX_SORT_INDEX, 86 + ) 78 87 79 88 80 89 class FacilityLocationUpdateSpec(FacilityLocationSpec): ··· 164 173 status: LocationEncounterAvailabilityStatusChoices 165 174 166 175 start_datetime: datetime.datetime 167 - end_datetime: datetime.datetime | None = None 176 + end_datetime: datetime.datetime | None 168 177 169 178 170 179 class FacilityLocationEncounterListSpec(FacilityLocationEncounterBaseSpec):
+7 -70
care/emr/resources/medication/administration/spec.py
··· 6 6 from care.emr.models.encounter import Encounter 7 7 from care.emr.models.medication_administration import MedicationAdministration 8 8 from care.emr.models.medication_request import MedicationRequest 9 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 10 9 from care.emr.resources.base import EMRResource 11 - from care.emr.resources.common import Coding, Quantity 10 + from care.emr.resources.common import Quantity 12 11 from care.emr.resources.medication.valueset.administration_method import ( 13 12 CARE_ADMINISTRATION_METHOD_VALUESET, 14 13 ) ··· 16 15 from care.emr.resources.medication.valueset.medication import CARE_MEDICATION_VALUESET 17 16 from care.emr.resources.medication.valueset.route import CARE_ROUTE_VALUESET 18 17 from care.emr.resources.user.spec import UserSpec 18 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 19 19 from care.users.models import User 20 20 21 21 ··· 65 65 None, 66 66 description="Free text dosage instructions", 67 67 ) 68 - site: Coding | None = Field( 69 - None, 70 - description="The site of the administration", 71 - json_schema_extra={"slug": CARE_BODY_SITE_VALUESET.slug}, 72 - ) 73 - route: Coding | None = Field( 74 - None, 75 - description="The route of the administration", 76 - json_schema_extra={"slug": CARE_ROUTE_VALUESET.slug}, 77 - ) 78 - method: Coding | None = Field( 79 - None, 80 - description="The method of the administration", 81 - json_schema_extra={"slug": CARE_ADMINISTRATION_METHOD_VALUESET.slug}, 82 - ) 68 + site: ValueSetBoundCoding[CARE_BODY_SITE_VALUESET.slug] | None = None 69 + route: ValueSetBoundCoding[CARE_ROUTE_VALUESET.slug] | None = None 70 + method: ValueSetBoundCoding[CARE_ADMINISTRATION_METHOD_VALUESET.slug] | None = None 83 71 dose: Quantity | None = Field( 84 72 None, 85 73 description="The amount of medication administered", ··· 89 77 description="The speed of administration", 90 78 ) 91 79 92 - @field_validator("site") 93 - @classmethod 94 - def validate_site(cls, code): 95 - return validate_valueset( 96 - "site", 97 - cls.model_fields["site"].json_schema_extra["slug"], 98 - code, 99 - ) 100 - 101 - @field_validator("route") 102 - @classmethod 103 - def validate_route(cls, code): 104 - return validate_valueset( 105 - "route", 106 - cls.model_fields["route"].json_schema_extra["slug"], 107 - code, 108 - ) 109 - 110 - @field_validator("method") 111 - @classmethod 112 - def validate_method(cls, code): 113 - return validate_valueset( 114 - "method", 115 - cls.model_fields["method"].json_schema_extra["slug"], 116 - code, 117 - ) 118 - 119 80 120 81 class BaseMedicationAdministrationSpec(EMRResource): 121 82 __model__ = MedicationAdministration ··· 124 85 125 86 status: MedicationAdministrationStatus 126 87 127 - status_reason: Coding | None = Field( 128 - None, 129 - json_schema_extra={"slug": CARE_MEDICATION_VALUESET.slug}, 130 - ) 88 + status_reason: ValueSetBoundCoding[CARE_MEDICATION_VALUESET.slug] | None = None 131 89 category: MedicationAdministrationCategory | None = None 132 90 133 - medication: Coding = Field( 134 - description="The medication that was taken", 135 - json_schema_extra={"slug": CARE_MEDICATION_VALUESET.slug}, 136 - ) 91 + medication: ValueSetBoundCoding[CARE_MEDICATION_VALUESET.slug] 137 92 138 93 authored_on: datetime | None = Field( 139 94 None, ··· 184 139 err = "Medication Request not found" 185 140 raise ValueError(err) 186 141 return request 187 - 188 - @field_validator("medication") 189 - @classmethod 190 - def validate_medication(cls, code): 191 - return validate_valueset( 192 - "medication", 193 - cls.model_fields["medication"].json_schema_extra["slug"], 194 - code, 195 - ) 196 - 197 - @field_validator("status_reason") 198 - @classmethod 199 - def validate_status_reason(cls, code): 200 - return validate_valueset( 201 - "status_reason", 202 - cls.model_fields["status_reason"].json_schema_extra["slug"], 203 - code, 204 - ) 205 142 206 143 def perform_extra_deserialization(self, is_update, obj): 207 144 if not is_update:
+10 -74
care/emr/resources/medication/request/spec.py
··· 6 6 7 7 from care.emr.models.encounter import Encounter 8 8 from care.emr.models.medication_request import MedicationRequest 9 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 10 9 from care.emr.resources.base import EMRResource 11 10 from care.emr.resources.common.coding import Coding 12 11 from care.emr.resources.medication.valueset.additional_instruction import ( ··· 22 21 from care.emr.resources.medication.valueset.medication import CARE_MEDICATION_VALUESET 23 22 from care.emr.resources.medication.valueset.route import CARE_ROUTE_VALUESET 24 23 from care.emr.resources.user.spec import UserSpec 24 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 25 25 from care.users.models import User 26 26 27 27 ··· 128 128 class DosageInstruction(BaseModel): 129 129 sequence: int | None = None 130 130 text: str | None = None 131 - additional_instruction: list[Coding] | None = Field( 132 - None, json_schema_extra={"slug": CARE_ADDITIONAL_INSTRUCTION_VALUESET.slug} 133 - ) 131 + additional_instruction: ( 132 + list[ValueSetBoundCoding[CARE_ADDITIONAL_INSTRUCTION_VALUESET.slug]] | None 133 + ) = None 134 134 patient_instruction: str | None = None 135 135 timing: Timing | None = None 136 136 as_needed_boolean: bool 137 - as_needed_for: Coding | None = Field( 138 - None, json_schema_extra={"slug": CARE_AS_NEEDED_REASON_VALUESET.slug} 139 - ) 140 - site: Coding | None = Field( 141 - None, json_schema_extra={"slug": CARE_BODY_SITE_VALUESET.slug} 142 - ) 143 - route: Coding | None = Field( 144 - None, json_schema_extra={"slug": CARE_ROUTE_VALUESET.slug} 145 - ) 146 - method: Coding | None = Field( 147 - None, json_schema_extra={"slug": CARE_ADMINISTRATION_METHOD_VALUESET.slug} 137 + as_needed_for: ValueSetBoundCoding[CARE_AS_NEEDED_REASON_VALUESET.slug] | None = ( 138 + None 148 139 ) 140 + site: ValueSetBoundCoding[CARE_BODY_SITE_VALUESET.slug] | None = None 141 + route: ValueSetBoundCoding[CARE_ROUTE_VALUESET.slug] | None = None 142 + method: ValueSetBoundCoding[CARE_ADMINISTRATION_METHOD_VALUESET.slug] | None = None 149 143 dose_and_rate: DoseAndRate | None = None 150 144 max_dose_per_period: DoseRange | None = None 151 145 152 - @field_validator("additional_instruction") 153 - @classmethod 154 - def validate_additional_instruction(cls, codes): 155 - if not codes: 156 - return codes 157 - return [ 158 - validate_valueset( 159 - "additional_instruction", 160 - cls.model_fields["additional_instruction"].json_schema_extra["slug"], 161 - code, 162 - ) 163 - for code in codes 164 - ] 165 - 166 - @field_validator("site") 167 - @classmethod 168 - def validate_site(cls, code): 169 - if not code: 170 - return code 171 - return validate_valueset( 172 - "site", 173 - cls.model_fields["site"].json_schema_extra["slug"], 174 - code, 175 - ) 176 - 177 - @field_validator("route") 178 - @classmethod 179 - def validate_route(cls, code): 180 - if not code: 181 - return code 182 - return validate_valueset( 183 - "route", 184 - cls.model_fields["route"].json_schema_extra["slug"], 185 - code, 186 - ) 187 - 188 - @field_validator("method") 189 - @classmethod 190 - def validate_method(cls, code): 191 - if not code: 192 - return code 193 - return validate_valueset( 194 - "method", 195 - cls.model_fields["method"].json_schema_extra["slug"], 196 - code, 197 - ) 198 - 199 146 200 147 class MedicationRequestResource(EMRResource): 201 148 __model__ = MedicationRequest ··· 216 163 217 164 do_not_perform: bool 218 165 219 - medication: Coding = Field( 220 - json_schema_extra={"slug": CARE_MEDICATION_VALUESET.slug}, 221 - ) 166 + medication: ValueSetBoundCoding[CARE_MEDICATION_VALUESET.slug] 222 167 223 168 encounter: UUID4 224 169 ··· 238 183 err = "Encounter not found" 239 184 raise ValueError(err) 240 185 return encounter 241 - 242 - @field_validator("medication") 243 - @classmethod 244 - def validate_medication(cls, code): 245 - return validate_valueset( 246 - "medication", 247 - cls.model_fields["medication"].json_schema_extra["slug"], 248 - code, 249 - ) 250 186 251 187 def perform_extra_deserialization(self, is_update, obj): 252 188 obj.encounter = Encounter.objects.get(external_id=self.encounter)
+2 -14
care/emr/resources/medication/statement/spec.py
··· 5 5 6 6 from care.emr.models.encounter import Encounter 7 7 from care.emr.models.medication_statement import MedicationStatement 8 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 9 8 from care.emr.resources.base import EMRResource 10 - from care.emr.resources.common.coding import Coding 11 9 from care.emr.resources.common.period import Period 12 10 from care.emr.resources.medication.valueset.medication import CARE_MEDICATION_VALUESET 13 11 from care.emr.resources.user.spec import UserSpec 12 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 14 13 15 14 16 15 class MedicationStatementStatus(str, Enum): ··· 38 37 status: MedicationStatementStatus 39 38 reason: str | None = None 40 39 41 - medication: Coding = Field( 42 - json_schema_extra={"slug": CARE_MEDICATION_VALUESET.slug}, 43 - ) 40 + medication: ValueSetBoundCoding[CARE_MEDICATION_VALUESET.slug] 44 41 dosage_text: str | None = Field( 45 42 None, 46 43 ) # consider using Dosage from MedicationRequest ··· 71 68 err = "Encounter not found" 72 69 raise ValueError(err) 73 70 return encounter 74 - 75 - @field_validator("medication") 76 - @classmethod 77 - def validate_medication(cls, medication): 78 - return validate_valueset( 79 - "medication", 80 - cls.model_fields["medication"].json_schema_extra["slug"], 81 - medication, 82 - ) 83 71 84 72 def perform_extra_deserialization(self, is_update, obj): 85 73 if not is_update:
+50 -17
care/emr/resources/observation/spec.py
··· 16 16 QuestionnaireSubmitResultValue, 17 17 ) 18 18 from care.emr.resources.user.spec import UserSpec 19 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 19 20 20 21 21 22 class ObservationStatus(str, Enum): ··· 51 52 class BaseObservationSpec(EMRResource): 52 53 __model__ = Observation 53 54 54 - id: str 55 - status: ObservationStatus 56 - category: Coding | None = None 57 - main_code: Coding | None = None 55 + id: str = Field("", description="Unique ID in the system") 56 + 57 + status: ObservationStatus = Field( 58 + description="Status of the observation (final or amended)" 59 + ) 60 + 61 + category: Coding | None = Field( 62 + None, description="List of codeable concepts derived from the questionnaire" 63 + ) 64 + 65 + main_code: Coding | None = Field( 66 + None, description="Code for the observation (LOINC binding)" 67 + ) 68 + 58 69 alternate_coding: CodeableConcept = dict 70 + 59 71 subject_type: SubjectType 72 + 60 73 encounter: UUID4 | None = None 61 - effective_datetime: datetime 62 - performer: Performer | None = None 63 - value_type: QuestionType 64 - value: QuestionnaireSubmitResultValue 65 - note: str | None = None 66 - body_site: Coding | None = Field( 74 + 75 + effective_datetime: datetime = Field( 76 + ..., 77 + description="Datetime when observation was recorded", 78 + ) 79 + 80 + performer: Performer | None = Field( 67 81 None, 68 - json_schema_extra={"slug": CARE_BODY_SITE_VALUESET.slug}, 82 + description="Who performed the observation (currently supports RelatedPerson)", 83 + ) # If none the observation is captured by the data entering person 84 + 85 + value_type: QuestionType = Field( 86 + description="Type of value", 87 + ) 88 + 89 + value: QuestionnaireSubmitResultValue = Field( 90 + description="Value of the observation if not code. For codes, contains display text", 91 + ) 92 + 93 + note: str | None = Field(None, description="Additional notes about the observation") 94 + 95 + body_site: ValueSetBoundCoding[CARE_BODY_SITE_VALUESET.slug] | None = None 96 + 97 + method: ValueSetBoundCoding[CARE_OBSERVATION_COLLECTION_METHOD.slug] | None = None 98 + 99 + reference_range: list[ReferenceRange] = Field( 100 + [], description="Reference ranges for interpretation" 69 101 ) 70 - method: Coding | None = Field( 71 - None, 72 - json_schema_extra={"slug": CARE_OBSERVATION_COLLECTION_METHOD.slug}, 102 + 103 + interpretation: str | None = Field( 104 + None, description="Interpretation based on the reference range" 73 105 ) 74 - reference_range: list[ReferenceRange] = [] 75 - interpretation: str | None = None 76 - parent: UUID4 | None = None 106 + 107 + parent: UUID4 | None = Field(None, description="ID reference to parent observation") 108 + 77 109 questionnaire_response: UUID4 | None = None 110 + 78 111 component: list[Component] = [] 79 112 80 113
+40 -8
care/emr/resources/patient/spec.py
··· 3 3 from enum import Enum 4 4 5 5 from django.utils import timezone 6 - from pydantic import UUID4, Field, field_validator, model_validator 6 + from pydantic import UUID4, Field, field_validator 7 7 8 8 from care.emr.models import Organization 9 9 from care.emr.models.patient import Patient ··· 43 43 address: str 44 44 permanent_address: str 45 45 pincode: int 46 - death_datetime: datetime.datetime | None = None 46 + deceased_datetime: datetime.datetime | None = None 47 47 blood_group: BloodGroupChoices | None = None 48 48 49 49 ··· 52 52 date_of_birth: datetime.date | None = None 53 53 54 54 age: int | None = None 55 - 56 - @model_validator(mode="after") 57 - def validate_age(self): 58 - if not (self.age or self.date_of_birth): 59 - raise ValueError("Either age or date of birth is required") 60 - return self 61 55 62 56 @field_validator("geo_organization") 63 57 @classmethod ··· 78 72 obj.year_of_birth = timezone.now().date().year - self.age 79 73 else: 80 74 obj.year_of_birth = self.date_of_birth.year 75 + 76 + 77 + class PatientUpdateSpec(PatientBaseSpec): 78 + name: str | None = Field(default=None, max_length=200) 79 + gender: GenderChoices | None = None 80 + phone_number: PhoneNumber | None = Field(default=None, max_length=14) 81 + emergency_phone_number: PhoneNumber | None = Field(default=None, max_length=14) 82 + address: str | None = None 83 + permanent_address: str | None = None 84 + pincode: int | None = None 85 + deceased_datetime: datetime.datetime | None = None 86 + blood_group: BloodGroupChoices | None = None 87 + date_of_birth: datetime.date | None = None 88 + age: int | None = None 89 + geo_organization: UUID4 | None = None 90 + 91 + @field_validator("geo_organization") 92 + @classmethod 93 + def validate_geo_organization(cls, geo_organization): 94 + if geo_organization is None: 95 + return None 96 + if not Organization.objects.filter( 97 + org_type="govt", external_id=geo_organization 98 + ).exists(): 99 + raise ValueError("Geo Organization does not exist") 100 + return geo_organization 101 + 102 + def perform_extra_deserialization(self, is_update, obj): 103 + if is_update: 104 + if self.geo_organization: 105 + obj.geo_organization = Organization.objects.get( 106 + external_id=self.geo_organization 107 + ) 108 + if self.age is not None: 109 + obj.date_of_birth = None 110 + obj.year_of_birth = timezone.now().year - self.age 111 + elif self.date_of_birth: 112 + obj.year_of_birth = self.date_of_birth.year 81 113 82 114 83 115 class PatientListSpec(PatientBaseSpec):
+25 -17
care/emr/resources/questionnaire/spec.py
··· 3 3 from typing import Any 4 4 5 5 from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator 6 + from rest_framework.generics import get_object_or_404 6 7 7 8 from care.emr.models import Questionnaire, QuestionnaireTag, ValueSet 8 - from care.emr.registries.care_valueset.care_valueset import validate_valueset 9 9 from care.emr.resources.base import EMRResource 10 - from care.emr.resources.common.coding import Coding 11 10 from care.emr.resources.observation.valueset import ( 12 11 CARE_OBSERVATION_VALUSET, 13 12 CARE_UCUM_UNITS, 14 13 ) 15 14 from care.emr.resources.user.spec import UserSpec 15 + from care.emr.utils.valueset_coding_type import ValueSetBoundCoding 16 16 17 17 18 18 class EnableOperator(str, Enum): ··· 96 96 description="Whether option is initially selected", 97 97 ) 98 98 99 + @field_validator("value") 100 + @classmethod 101 + def validate_value(cls, value: str, info): 102 + if not value.strip(): 103 + raise ValueError( 104 + "All the answer option values must be provided for custom choices" 105 + ) 106 + return value.strip() 107 + 99 108 100 109 class Question(QuestionnaireBaseSpec): 101 110 model_config = ConfigDict(populate_by_name=True) ··· 104 113 id: UUID4 = Field( 105 114 description="Unique machine provided UUID", default_factory=uuid.uuid4 106 115 ) 107 - code: Coding | None = Field( 108 - None, 109 - description="Coding for observation creation", 110 - json_schema_extra={"slug": CARE_OBSERVATION_VALUSET.slug}, 111 - ) 116 + code: ValueSetBoundCoding[CARE_OBSERVATION_VALUSET.slug] | None = None 112 117 collect_time: bool = Field( 113 118 default=False, description="Whether to collect timestamp" 114 119 ) ··· 135 140 answer_option: list[AnswerOption] | None = None 136 141 answer_value_set: str | None = None 137 142 is_observation: bool | None = None 138 - unit: Coding | None = Field(None, json_schema_extra={"slug": CARE_UCUM_UNITS.slug}) 143 + unit: ValueSetBoundCoding[CARE_UCUM_UNITS.slug] | None = None 139 144 questions: list["Question"] = [] 140 145 formula: str | None = None 141 146 styling_metadata: dict = {} 142 147 is_component: bool = False 143 148 144 - @field_validator("unit") 145 - @classmethod 146 - def validate_unit(cls, code): 147 - return validate_valueset( 148 - "unit", cls.model_fields["unit"].json_schema_extra["slug"], code 149 - ) 150 - 151 149 @field_validator("answer_value_set") 152 150 @classmethod 153 151 def validate_value_set(cls, slug): ··· 185 183 version: str = Field("1.0", frozen=True, description="Version of the questionnaire") 186 184 slug: str | None = Field(None, min_length=5, max_length=25, pattern=r"^[-\w]+$") 187 185 title: str 188 - description: str = "" 186 + description: str | None = None 189 187 type: str = "custom" 190 188 status: QuestionnaireStatus 191 189 subject_type: SubjectType ··· 245 243 246 244 class QuestionnaireSpec(QuestionnaireWriteSpec): 247 245 organizations: list[UUID4] = Field(min_length=1) 246 + tags: list[UUID4] = [] 247 + 248 + @field_validator("tags") 249 + @classmethod 250 + def validate_tags(cls, tags): 251 + tag_ids = [] 252 + for external_id in tags: 253 + tag = get_object_or_404(QuestionnaireTag, external_id=external_id) 254 + tag_ids.append(tag.id) 255 + return tag_ids 248 256 249 257 def perform_extra_deserialization(self, is_update, obj): 250 258 obj._organizations = self.organizations # noqa SLF001 ··· 259 267 slug: str | None = None 260 268 version: str 261 269 title: str 262 - description: str = "" 270 + description: str | None = None 263 271 status: QuestionnaireStatus 264 272 subject_type: SubjectType 265 273 styling_metadata: dict
+149 -1
care/emr/resources/questionnaire/utils.py
··· 3 3 from urllib.parse import urlparse 4 4 5 5 from dateutil.parser import isoparse 6 + from django.conf import settings 6 7 from django.utils import timezone 7 8 from rest_framework.exceptions import ValidationError 8 9 ··· 76 77 parsed = urlparse(value.value) 77 78 if not all([parsed.scheme, parsed.netloc]): 78 79 errors.append(f"Invalid {value_type}") 80 + elif ( 81 + value_type == QuestionType.text.value 82 + and len(value.value) > settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE 83 + ): 84 + error = f"Text too long. Max allowed size is {settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE}" 85 + errors.append(error) 79 86 except ValueError: 80 87 errors.append(f"Invalid {value_type}") 81 88 except Exception: ··· 84 91 return errors 85 92 86 93 94 + def is_question_enabled(question, responses, questionnaire_obj): # noqa PLR0912 95 + """ 96 + Check if a question should be enabled based on its enable_when conditions. 97 + Returns True if the question is enabled, False otherwise. 98 + """ 99 + question_link_id_to_id = get_link_id_map(questionnaire_obj.questions) 100 + 101 + if not question.get("enable_when"): 102 + return True 103 + 104 + conditions = question["enable_when"] 105 + behavior = question.get("enable_behavior", "all") 106 + results = [] 107 + 108 + for condition in conditions: 109 + link_id = condition["question"] 110 + if link_id not in question_link_id_to_id: 111 + results.append(False) 112 + continue 113 + 114 + condition_question_id = question_link_id_to_id[link_id] 115 + 116 + if ( 117 + condition_question_id not in responses 118 + or not responses[condition_question_id].values 119 + ): 120 + condition_value = None 121 + else: 122 + condition_value = responses[condition_question_id].values[0].value 123 + 124 + operator = condition["operator"] 125 + expected_answer = condition["answer"] 126 + 127 + # Evaluate the condition based on the operator. 128 + if operator == "exists": 129 + result = condition_value is not None 130 + elif operator == "equals": 131 + result = condition_value == expected_answer 132 + elif operator == "not_equals": 133 + result = condition_value != expected_answer 134 + elif operator == "greater": 135 + try: 136 + result = float(condition_value) > float(expected_answer) 137 + except (TypeError, ValueError): 138 + result = False 139 + elif operator == "less": 140 + try: 141 + result = float(condition_value) < float(expected_answer) 142 + except (TypeError, ValueError): 143 + result = False 144 + elif operator == "greater_or_equals": 145 + try: 146 + result = float(condition_value) >= float(expected_answer) 147 + except (TypeError, ValueError): 148 + result = False 149 + elif operator == "less_or_equals": 150 + try: 151 + result = float(condition_value) <= float(expected_answer) 152 + except (TypeError, ValueError): 153 + result = False 154 + else: 155 + # Unsupported operator; treat as condition not met. 156 + result = False 157 + 158 + results.append(result) 159 + 160 + # Combine condition results using the enable_behavior. 161 + return all(results) if behavior == "all" else any(results) 162 + 163 + 87 164 def validate_question_result( # noqa : PLR0912 88 165 questionnaire, responses, errors, parent, questionnaire_mapping 89 166 ): ··· 297 374 return constructed_observation_mapping 298 375 299 376 300 - def handle_response(questionnaire_obj: Questionnaire, results, user): 377 + def remove_nested_questions(group_question, responses, results): 378 + if "questions" not in group_question: 379 + return 380 + for child in group_question["questions"]: 381 + responses.pop(child["id"], None) 382 + results.results = [ 383 + r for r in results.results if str(r.question_id) != child["id"] 384 + ] 385 + # If it's another group inside, clean it recursively 386 + if child.get("type") == QuestionType.group.value: 387 + remove_nested_questions(child, responses, results) 388 + 389 + 390 + def prune_nested_disabled_questions(question, responses, results, questionnaire_obj): 391 + if question.get("questions"): 392 + enabled_children = [] 393 + for child in question["questions"]: 394 + if "enable_when" in child and not is_question_enabled( 395 + child, responses, questionnaire_obj 396 + ): 397 + responses.pop(child["id"], None) 398 + results.results = [ 399 + r for r in results.results if str(r.question_id) != child["id"] 400 + ] 401 + if child.get("type") == QuestionType.group.value: 402 + remove_nested_questions(child, responses, results) 403 + else: 404 + # Recursively check deeper levels 405 + if child.get("type") == QuestionType.group.value: 406 + prune_nested_disabled_questions( 407 + child, responses, results, questionnaire_obj 408 + ) 409 + enabled_children.append(child) 410 + question["questions"] = enabled_children 411 + 412 + 413 + def get_link_id_map(questions): 414 + """ 415 + Recursively extract a map of link_id to question_id from all questions, including nested ones. 416 + """ 417 + mapping = {} 418 + for q in questions: 419 + mapping[q["link_id"]] = q["id"] 420 + if q.get("questions"): 421 + mapping.update(get_link_id_map(q["questions"])) 422 + return mapping 423 + 424 + 425 + def handle_response(questionnaire_obj: Questionnaire, results, user): # noqa PLR0912 301 426 """ 302 427 Generate observations and questionnaire responses after validation 303 428 """ ··· 328 453 "msg": "Empty Questionnaire cannot be submitted", 329 454 } 330 455 ) 456 + valid_questions = [] 457 + for question in questionnaire_obj.questions: 458 + if "enable_when" in question and not is_question_enabled( 459 + question, responses, questionnaire_obj 460 + ): 461 + # Remove disabled question and any responses 462 + responses.pop(question["id"], None) 463 + results.results = [ 464 + r for r in results.results if str(r.question_id) != question["id"] 465 + ] 466 + # Also remove nested ones if it's a group 467 + if question["type"] == QuestionType.group.value: 468 + remove_nested_questions(question, responses, results) 469 + else: 470 + # Only keep enabled questions 471 + if question["type"] == QuestionType.group.value: 472 + prune_nested_disabled_questions( 473 + question, responses, results, questionnaire_obj 474 + ) 475 + valid_questions.append(question) 476 + 477 + questionnaire_obj.questions = valid_questions 478 + 331 479 for question in questionnaire_obj.questions: 332 480 validate_question_result( 333 481 question,
+8
care/emr/resources/scheduling/slot/spec.py
··· 54 54 BookingStatusChoices.rescheduled.value, 55 55 ] 56 56 57 + COMPLETED_STATUS_CHOICES = [ 58 + BookingStatusChoices.fulfilled.value, 59 + BookingStatusChoices.noshow.value, 60 + BookingStatusChoices.entered_in_error.value, 61 + BookingStatusChoices.cancelled.value, 62 + BookingStatusChoices.rescheduled.value, 63 + ] 64 + 57 65 58 66 class TokenBookingBaseSpec(EMRResource): 59 67 __model__ = TokenBooking
+54 -7
care/emr/resources/user/spec.py
··· 1 + import re 1 2 from enum import Enum 2 3 3 4 from django.contrib.auth.password_validation import validate_password ··· 9 10 from care.emr.models import Organization 10 11 from care.emr.resources.base import EMRResource 11 12 from care.emr.resources.patient.spec import GenderChoices 12 - from care.security.roles.role import DOCTOR_ROLE, NURSE_ROLE, STAFF_ROLE, VOLUNTEER_ROLE 13 + from care.security.roles.role import ( 14 + ADMINISTRATOR, 15 + DOCTOR_ROLE, 16 + NURSE_ROLE, 17 + STAFF_ROLE, 18 + VOLUNTEER_ROLE, 19 + ) 13 20 from care.users.models import User 14 21 15 22 23 + def is_valid_username(username): 24 + pattern = r"^[a-zA-Z0-9_-]{3,}$" 25 + return bool(re.fullmatch(pattern, username)) 26 + 27 + 16 28 class UserTypeOptions(str, Enum): 17 29 doctor = "doctor" 18 30 nurse = "nurse" 19 31 staff = "staff" 20 32 volunteer = "volunteer" 33 + administrator = "administrator" 21 34 22 35 23 36 class UserTypeRoleMapping(Enum): ··· 25 38 nurse = NURSE_ROLE 26 39 staff = STAFF_ROLE 27 40 volunteer = VOLUNTEER_ROLE 41 + administrator = ADMINISTRATOR 28 42 29 43 30 44 class UserBaseSpec(EMRResource): ··· 35 49 36 50 first_name: str 37 51 last_name: str 52 + phone_number: str = Field(max_length=14) 53 + 38 54 prefix: str | None = None 39 55 suffix: str | None = None 40 - phone_number: str = Field(max_length=14) 41 56 42 57 43 58 class UserUpdateSpec(UserBaseSpec): 44 59 user_type: UserTypeOptions 45 60 gender: GenderChoices 61 + phone_number: str = Field(max_length=14) 62 + geo_organization: UUID4 | None = None 63 + 64 + def perform_extra_deserialization(self, is_update, obj): 65 + if self.geo_organization is not None: 66 + obj.geo_organization = get_object_or_404( 67 + Organization, external_id=self.geo_organization, org_type="govt" 68 + ) 46 69 47 70 48 71 class UserCreateSpec(UserUpdateSpec): 49 - geo_organization: UUID4 50 - password: str 72 + password: str | None = None 51 73 username: str 52 74 email: str 53 75 54 76 @field_validator("username") 55 77 @classmethod 56 78 def validate_username(cls, username): 79 + if not is_valid_username(username): 80 + raise ValueError( 81 + "Username can only contain alpha numeric values, dashes ( - ) and underscores ( _ )" 82 + ) 57 83 if User.check_username_exists(username): 58 84 raise ValueError("Username already exists") 59 85 return username 60 86 87 + @field_validator("phone_number") 88 + @classmethod 89 + def validate_phone_number(cls, phone_number): 90 + if User.objects.filter(phone_number=phone_number).exists(): 91 + raise ValueError("Phone Number already exists") 92 + return phone_number 93 + 61 94 @field_validator("email") 62 95 @classmethod 63 96 def validate_user_email(cls, email): ··· 72 105 @field_validator("password") 73 106 @classmethod 74 107 def validate_password(cls, password): 108 + if password is None: 109 + return None 75 110 try: 76 111 validate_password(password) 77 112 except Exception as e: ··· 80 115 81 116 def perform_extra_deserialization(self, is_update, obj): 82 117 obj.set_password(self.password) 83 - obj.geo_organization = get_object_or_404( 84 - Organization, external_id=self.geo_organization, org_type="govt" 85 - ) 86 118 87 119 88 120 class UserSpec(UserBaseSpec): ··· 92 124 gender: str 93 125 username: str 94 126 mfa_enabled: bool = False 127 + phone_number: str = Field(max_length=14) 128 + deleted: bool = False 95 129 96 130 @classmethod 97 131 def perform_extra_serialization(cls, mapping, obj: User): ··· 118 152 obj.geo_organization 119 153 ).to_json() 120 154 mapping["flags"] = obj.get_all_flags() 155 + 156 + 157 + class PublicUserReadSpec(UserBaseSpec): 158 + last_login: str 159 + profile_picture_url: str 160 + user_type: str 161 + gender: str 162 + username: str 163 + 164 + @classmethod 165 + def perform_extra_serialization(cls, mapping, obj: User): 166 + mapping["id"] = str(obj.external_id) 167 + mapping["profile_picture_url"] = obj.read_profile_picture_url()
+13
care/emr/tasks/__init__.py
··· 1 + from celery import Celery, current_app 2 + from celery.schedules import crontab 3 + 4 + from care.emr.tasks.cleanup_expired_token_slots import cleanup_expired_token_slots 5 + 6 + 7 + @current_app.on_after_finalize.connect 8 + def setup_periodic_tasks(sender: Celery, **kwargs): 9 + sender.add_periodic_task( 10 + crontab(hour="0", minute="0"), 11 + cleanup_expired_token_slots.s(), 12 + name="cleanup_expired_token_slots", 13 + )
+21
care/emr/tasks/cleanup_expired_token_slots.py
··· 1 + from logging import Logger 2 + 3 + from celery import shared_task 4 + from celery.utils.log import get_task_logger 5 + from django.utils import timezone 6 + 7 + from care.emr.models import TokenSlot 8 + 9 + logger: Logger = get_task_logger(__name__) 10 + 11 + 12 + @shared_task 13 + def cleanup_expired_token_slots(): 14 + """ 15 + Hard-deletes TokenSlot objects that have expired if they have no bookings associated with them. 16 + """ 17 + logger.info("Cleaning up expired TokenSlot objects") 18 + queryset = TokenSlot.objects.filter( 19 + tokenbooking__isnull=True, end_datetime__lte=timezone.now() 20 + ) 21 + queryset.delete()
-10
care/emr/tests/test_allergy_intolerance_api.py
··· 1 1 import uuid 2 2 from secrets import choice 3 - from unittest.mock import patch 4 3 5 4 from django.forms import model_to_dict 6 5 from django.urls import reverse ··· 37 36 "system": "http://test_system.care/test", 38 37 "code": "123", 39 38 } 40 - # Mocking validate_valueset 41 - self.patcher = patch( 42 - "care.emr.resources.allergy_intolerance.spec.validate_valueset", 43 - return_value=self.valid_code, 44 - ) 45 - self.mock_validate_valueset = self.patcher.start() 46 - 47 - def tearDown(self): 48 - self.patcher.stop() 49 39 50 40 def _get_allergy_intolerance_url(self, allergy_intolerance_id): 51 41 """Helper to get the detail URL for a specific allergy_intolerance."""
+151 -39
care/emr/tests/test_booking_api.py
··· 29 29 self.facility = self.create_facility(user=self.user) 30 30 self.organization = self.create_facility_organization(facility=self.facility) 31 31 self.patient = self.create_patient() 32 - self.resource = SchedulableUserResource.objects.create( 33 - user=self.user, 34 - facility=self.facility, 32 + self.resource = self.create_resource(user=self.user, facility=self.facility) 33 + self.schedule = self.create_schedule(resource=self.resource) 34 + self.availability = self.create_availability(schedule=self.schedule) 35 + self.slot = self.create_slot( 36 + resource=self.resource, availability=self.availability 35 37 ) 36 - self.schedule = Schedule.objects.create( 37 - resource=self.resource, 38 - name="Test Schedule", 39 - valid_from=datetime.now(UTC) - timedelta(days=30), 40 - valid_to=datetime.now(UTC) + timedelta(days=30), 41 - ) 42 - self.availability = Availability.objects.create( 43 - schedule=self.schedule, 44 - name="Test Availability", 45 - slot_type=SlotTypeOptions.appointment.value, 46 - slot_size_in_minutes=120, 47 - tokens_per_slot=30, 48 - create_tokens=False, 49 - reason="", 50 - availability=[ 51 - {"day_of_week": 0, "start_time": "09:00:00", "end_time": "13:00:00"}, 52 - {"day_of_week": 1, "start_time": "09:00:00", "end_time": "13:00:00"}, 53 - {"day_of_week": 2, "start_time": "09:00:00", "end_time": "13:00:00"}, 54 - {"day_of_week": 3, "start_time": "09:00:00", "end_time": "13:00:00"}, 55 - {"day_of_week": 4, "start_time": "09:00:00", "end_time": "13:00:00"}, 56 - {"day_of_week": 5, "start_time": "09:00:00", "end_time": "13:00:00"}, 57 - {"day_of_week": 6, "start_time": "09:00:00", "end_time": "13:00:00"}, 58 - ], 59 - ) 60 - self.slot = self.create_slot() 61 38 self.client.force_authenticate(user=self.user) 62 39 63 40 self.base_url = reverse( ··· 99 76 } 100 77 data.update(kwargs) 101 78 return TokenSlot.objects.create(**data) 79 + 80 + def create_resource(self, **kwargs): 81 + data = { 82 + "user": self.user, 83 + "facility": self.facility, 84 + } 85 + data.update(kwargs) 86 + return SchedulableUserResource.objects.create(**data) 87 + 88 + def create_schedule(self, **kwargs): 89 + data = { 90 + "resource": self.resource, 91 + "name": "Test Schedule", 92 + "valid_from": datetime.now(UTC) - timedelta(days=30), 93 + "valid_to": datetime.now(UTC) + timedelta(days=30), 94 + } 95 + data.update(kwargs) 96 + return Schedule.objects.create(**data) 97 + 98 + def create_availability(self, **kwargs): 99 + data = { 100 + "schedule": self.schedule, 101 + "name": "Test Availability", 102 + "slot_type": SlotTypeOptions.appointment.value, 103 + "slot_size_in_minutes": 120, 104 + "tokens_per_slot": 30, 105 + "create_tokens": False, 106 + "reason": "", 107 + "availability": [ 108 + {"day_of_week": 0, "start_time": "09:00:00", "end_time": "13:00:00"}, 109 + {"day_of_week": 1, "start_time": "09:00:00", "end_time": "13:00:00"}, 110 + {"day_of_week": 2, "start_time": "09:00:00", "end_time": "13:00:00"}, 111 + {"day_of_week": 3, "start_time": "09:00:00", "end_time": "13:00:00"}, 112 + {"day_of_week": 4, "start_time": "09:00:00", "end_time": "13:00:00"}, 113 + {"day_of_week": 5, "start_time": "09:00:00", "end_time": "13:00:00"}, 114 + {"day_of_week": 6, "start_time": "09:00:00", "end_time": "13:00:00"}, 115 + ], 116 + } 117 + data.update(kwargs) 118 + return Availability.objects.create(**data) 102 119 103 120 def test_list_booking_with_permissions(self): 104 121 """Users with can_list_user_booking permission can list bookings.""" ··· 368 385 ) 369 386 370 387 def test_list_available_users(self): 371 - """Users can list available schedulable users.""" 388 + """Users can list available schedulable users and ensure deleted users are not listed""" 389 + deleted_user = self.create_user() 390 + self.create_resource(user=deleted_user) 391 + deleted_user.deleted = True 392 + deleted_user.save() 393 + 372 394 available_users_url = reverse( 373 395 "appointments-available-users", 374 396 kwargs={"facility_external_id": self.facility.external_id}, 375 397 ) 376 398 response = self.client.get(available_users_url) 399 + self.assertContains(response, self.user.external_id) 400 + self.assertNotContains(response, deleted_user.external_id) 401 + 402 + def test_list_booking_for_user_with_schedules_in_multiple_facilities(self): 403 + """Appointments for a user with schedules in multiple facilities are filtered correctly.""" 404 + permissions = [ 405 + UserSchedulePermissions.can_list_user_booking.name, 406 + ] 407 + role = self.create_role_with_permissions(permissions) 408 + self.attach_role_facility_organization_user(self.organization, self.user, role) 409 + 410 + # Create 2nd facility, organization, resource and role 411 + facility_2 = self.create_facility(user=self.user) 412 + organization_2 = self.create_facility_organization(facility=facility_2) 413 + resource_2 = self.create_resource(user=self.user, facility=facility_2) 414 + self.attach_role_facility_organization_user(organization_2, self.user, role) 415 + 416 + # Create the first schedule 417 + schedule_1 = self.create_schedule( 418 + resource=self.resource, 419 + name="Schedule in Facility 1", 420 + ) 421 + 422 + # Create the second schedule 423 + schedule_2 = self.create_schedule( 424 + resource=resource_2, 425 + name="Schedule in Facility 2", 426 + ) 427 + 428 + # Create availability for first schedule 429 + availability_1 = self.create_availability( 430 + schedule=schedule_1, 431 + name="Availability in Facility 1", 432 + ) 433 + 434 + # Create availability for 2nd schedule 435 + availability_2 = self.create_availability( 436 + schedule=schedule_2, 437 + name="Availability in Facility 2", 438 + ) 439 + 440 + # Create a booking for the first schedule 441 + self.create_booking( 442 + token_slot=self.create_slot( 443 + resource=self.resource, 444 + availability=availability_1, 445 + ), 446 + ) 447 + 448 + # Create a booking for the second schedule 449 + self.create_booking( 450 + token_slot=self.create_slot( 451 + resource=resource_2, 452 + availability=availability_2, 453 + ), 454 + ) 455 + 456 + response = self.client.get(self.base_url) 377 457 self.assertEqual(response.status_code, 200) 378 - self.assertGreaterEqual(len(response.data["users"]), 1) 458 + self.assertEqual(len(response.data["results"]), 1) 459 + url = reverse( 460 + "appointments-list", 461 + kwargs={"facility_external_id": facility_2.external_id}, 462 + ) 463 + response = self.client.get(url) 464 + self.assertEqual(response.status_code, 200) 465 + self.assertEqual(len(response.data["results"]), 1) 379 466 380 467 381 468 @ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") ··· 552 639 ) 553 640 self.assertContains(response, status_code=400, text="Slot is already past") 554 641 642 + def test_create_appointment_ongoing_slot(self): 643 + """Users can create appointments for a slot that's currently ongoing.""" 644 + permissions = [UserSchedulePermissions.can_create_appointment.name] 645 + role = self.create_role_with_permissions(permissions) 646 + self.attach_role_facility_organization_user(self.organization, self.user, role) 647 + 648 + slot = self.create_slot( 649 + start_datetime=datetime.now(UTC) - timedelta(minutes=5), 650 + end_datetime=datetime.now(UTC) + timedelta(minutes=5), 651 + ) 652 + data = self.get_appointment_data() 653 + response = self.client.post( 654 + self._get_create_appointment_url(slot.external_id), data, format="json" 655 + ) 656 + self.assertEqual(response.status_code, 200) 657 + 555 658 def test_create_multiple_appointments_on_same_slot(self): 556 659 """Users cannot create multiple appointments on the same slot for the same patient.""" 557 660 permissions = [UserSchedulePermissions.can_create_appointment.name] ··· 690 793 """Users can get available slots for a specific day.""" 691 794 data = { 692 795 "user": self.user.external_id, 693 - "day": datetime.now(UTC).strftime("%Y-%m-%d"), 796 + "day": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"), 694 797 } 695 798 response = self.client.post(self._get_slot_for_day_url(), data, format="json") 696 799 self.assertEqual(response.status_code, 200) 697 800 self.assertEqual(len(response.data["results"]), 8) 698 801 802 + def test_get_slots_for_day_on_past_day_does_not_create_objects(self): 803 + """If get_slots_for_day API is called on a past day, new TokenSlot objects should not be created.""" 804 + data = { 805 + "user": self.user.external_id, 806 + "day": (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%d"), 807 + } 808 + response = self.client.post(self._get_slot_for_day_url(), data, format="json") 809 + self.assertEqual(len(response.data["results"]), 0) 810 + 699 811 def test_hit_on_get_slots_for_day_does_not_cause_duplicate_slots(self): 700 812 """Multiple requests to get slots for a day should not create duplicate slots.""" 701 813 data = { 702 814 "user": self.user.external_id, 703 - "day": datetime.now(UTC).strftime("%Y-%m-%d"), 815 + "day": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"), 704 816 } 705 817 url = self._get_slot_for_day_url() 706 818 ··· 751 863 resource=self.resource, 752 864 name="Test Exception", 753 865 valid_from=datetime.now(UTC) - timedelta(days=1), 754 - valid_to=datetime.now(UTC) + timedelta(days=1), 866 + valid_to=datetime.now(UTC) + timedelta(days=2), 755 867 start_time="00:00:00", 756 868 end_time="12:00:00", 757 869 ) 758 870 data = { 759 871 "user": self.user.external_id, 760 - "day": datetime.now(UTC).strftime("%Y-%m-%d"), 872 + "day": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"), 761 873 } 762 874 response = self.client.post(self._get_slot_for_day_url(), data, format="json") 763 875 self.assertEqual(response.status_code, 200) ··· 769 881 resource=self.resource, 770 882 name="Test Exception", 771 883 valid_from=datetime.now(UTC) - timedelta(days=1), 772 - valid_to=datetime.now(UTC) + timedelta(days=1), 884 + valid_to=datetime.now(UTC) + timedelta(days=2), 773 885 start_time="10:00:00", 774 886 end_time="23:59:59", 775 887 ) 776 888 data = { 777 889 "user": self.user.external_id, 778 - "day": datetime.now(UTC).strftime("%Y-%m-%d"), 890 + "day": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"), 779 891 } 780 892 response = self.client.post(self._get_slot_for_day_url(), data, format="json") 781 893 self.assertEqual(response.status_code, 200) ··· 787 899 resource=self.resource, 788 900 name="Test Exception", 789 901 valid_from=datetime.now(UTC) - timedelta(days=1), 790 - valid_to=datetime.now(UTC) + timedelta(days=1), 902 + valid_to=datetime.now(UTC) + timedelta(days=2), 791 903 start_time="10:00:00", 792 904 end_time="12:00:00", 793 905 ) 794 906 data = { 795 907 "user": self.user.external_id, 796 - "day": datetime.now(UTC).strftime("%Y-%m-%d"), 908 + "day": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"), 797 909 } 798 910 response = self.client.post(self._get_slot_for_day_url(), data, format="json") 799 911 self.assertEqual(response.status_code, 200) ··· 882 994 self, 883 995 ): 884 996 """Availability heatmap slot counts should match individual day slot counts when there are no exceptions.""" 885 - from_date = datetime.now(UTC).date() 997 + from_date = datetime.now(UTC).date() + timedelta(days=1) 886 998 end_date = from_date + timedelta(days=7) 887 999 data = { 888 1000 "user": self.user.external_id,
-770
care/emr/tests/test_chronic_condition_api.py
··· 1 - import uuid 2 - from secrets import choice 3 - from unittest.mock import patch 4 - 5 - from django.forms import model_to_dict 6 - from django.urls import reverse 7 - from model_bakery import baker 8 - 9 - from care.emr.models import Condition 10 - from care.emr.resources.condition.spec import ( 11 - CategoryChoices, 12 - ClinicalStatusChoices, 13 - SeverityChoices, 14 - VerificationStatusChoices, 15 - ) 16 - from care.emr.resources.resource_request.spec import StatusChoices 17 - from care.security.permissions.encounter import EncounterPermissions 18 - from care.security.permissions.patient import PatientPermissions 19 - from care.utils.tests.base import CareAPITestBase 20 - 21 - 22 - class TestChronicConditionViewSet(CareAPITestBase): 23 - def setUp(self): 24 - super().setUp() 25 - self.user = self.create_user() 26 - self.facility = self.create_facility(user=self.user) 27 - self.organization = self.create_facility_organization(facility=self.facility) 28 - self.patient = self.create_patient() 29 - self.client.force_authenticate(user=self.user) 30 - 31 - self.base_url = reverse( 32 - "chronic-condition-list", 33 - kwargs={"patient_external_id": self.patient.external_id}, 34 - ) 35 - self.valid_code = { 36 - "display": "Test Value", 37 - "system": "http://test_system.care/test", 38 - "code": "123", 39 - } 40 - # Mocking validate_valueset 41 - self.patcher = patch( 42 - "care.emr.resources.condition.spec.validate_valueset", 43 - return_value=self.valid_code, 44 - ) 45 - self.mock_validate_valueset = self.patcher.start() 46 - 47 - def tearDown(self): 48 - self.patcher.stop() 49 - 50 - def _get_chronic_condition_url(self, chronic_condition_id): 51 - """Helper to get the detail URL for a specific chronic_condition.""" 52 - return reverse( 53 - "chronic-condition-detail", 54 - kwargs={ 55 - "patient_external_id": self.patient.external_id, 56 - "external_id": chronic_condition_id, 57 - }, 58 - ) 59 - 60 - def create_chronic_condition(self, encounter, patient, **kwargs): 61 - clinical_status = kwargs.pop( 62 - "clinical_status", choice(list(ClinicalStatusChoices)).value 63 - ) 64 - verification_status = kwargs.pop( 65 - "verification_status", choice(list(VerificationStatusChoices)).value 66 - ) 67 - severity = kwargs.pop("severity", choice(list(SeverityChoices)).value) 68 - 69 - return baker.make( 70 - Condition, 71 - encounter=encounter, 72 - patient=patient, 73 - category=CategoryChoices.chronic_condition.value, 74 - clinical_status=clinical_status, 75 - verification_status=verification_status, 76 - severity=severity, 77 - **kwargs, 78 - ) 79 - 80 - def generate_data_for_chronic_condition(self, encounter, **kwargs): 81 - clinical_status = kwargs.pop( 82 - "clinical_status", choice(list(ClinicalStatusChoices)).value 83 - ) 84 - verification_status = kwargs.pop( 85 - "verification_status", choice(list(VerificationStatusChoices)).value 86 - ) 87 - severity = kwargs.pop("severity", choice(list(SeverityChoices)).value) 88 - code = self.valid_code 89 - return { 90 - "encounter": encounter.external_id, 91 - "category": CategoryChoices.chronic_condition.value, 92 - "clinical_status": clinical_status, 93 - "verification_status": verification_status, 94 - "severity": severity, 95 - "code": code, 96 - **kwargs, 97 - } 98 - 99 - # LIST TESTS 100 - def test_list_chronic_condition_with_permissions(self): 101 - """ 102 - Users with `can_view_clinical_data` on a non-completed encounter 103 - can list chronic_condition (HTTP 200). 104 - """ 105 - # Attach the needed role/permission 106 - permissions = [PatientPermissions.can_view_clinical_data.name] 107 - role = self.create_role_with_permissions(permissions) 108 - self.attach_role_facility_organization_user(self.organization, self.user, role) 109 - 110 - # Create an active encounter 111 - self.create_encounter( 112 - patient=self.patient, 113 - facility=self.facility, 114 - organization=self.organization, 115 - status=None, 116 - ) 117 - 118 - response = self.client.get(self.base_url) 119 - self.assertEqual(response.status_code, 200) 120 - 121 - def test_list_chronic_condition_with_permissions_and_encounter_status_as_completed( 122 - self, 123 - ): 124 - """ 125 - Users with `can_view_clinical_data` but a completed encounter => (HTTP 403). 126 - """ 127 - permissions = [PatientPermissions.can_view_clinical_data.name] 128 - role = self.create_role_with_permissions(permissions) 129 - self.attach_role_facility_organization_user(self.organization, self.user, role) 130 - 131 - self.create_encounter( 132 - patient=self.patient, 133 - facility=self.facility, 134 - organization=self.organization, 135 - status=StatusChoices.completed.value, 136 - ) 137 - response = self.client.get(self.base_url) 138 - self.assertEqual(response.status_code, 403) 139 - 140 - def test_list_chronic_condition_without_permissions(self): 141 - """ 142 - Users without `can_view_clinical_data` => (HTTP 403). 143 - """ 144 - # No permission attached 145 - self.create_encounter( 146 - patient=self.patient, 147 - facility=self.facility, 148 - organization=self.organization, 149 - status=None, 150 - ) 151 - response = self.client.get(self.base_url) 152 - self.assertEqual(response.status_code, 403) 153 - 154 - def test_list_chronic_condition_for_single_encounter_with_permissions(self): 155 - """ 156 - Users with `can_view_clinical_data` can list chronic_condition for that encounter (HTTP 200). 157 - """ 158 - permissions = [PatientPermissions.can_view_clinical_data.name] 159 - role = self.create_role_with_permissions(permissions) 160 - self.attach_role_facility_organization_user(self.organization, self.user, role) 161 - 162 - encounter = self.create_encounter( 163 - patient=self.patient, 164 - facility=self.facility, 165 - organization=self.organization, 166 - status=None, 167 - ) 168 - 169 - url = f"{self.base_url}?encounter={encounter.external_id}" 170 - response = self.client.get(url) 171 - self.assertEqual(response.status_code, 200) 172 - 173 - def test_list_chronic_condition_for_single_encounter_with_permissions_and_encounter_status_completed( 174 - self, 175 - ): 176 - """ 177 - Users with `can_view_clinical_data` on a completed encounter cannot list chronic_condition (HTTP 200). 178 - """ 179 - permissions = [PatientPermissions.can_view_clinical_data.name] 180 - role = self.create_role_with_permissions(permissions) 181 - self.attach_role_facility_organization_user(self.organization, self.user, role) 182 - 183 - encounter = self.create_encounter( 184 - patient=self.patient, 185 - facility=self.facility, 186 - organization=self.organization, 187 - status=StatusChoices.completed.value, 188 - ) 189 - url = f"{self.base_url}?encounter={encounter.external_id}" 190 - response = self.client.get(url) 191 - self.assertEqual(response.status_code, 403) 192 - 193 - def test_list_chronic_condition_for_single_encounter_without_permissions(self): 194 - """ 195 - Users without `can_view_clinical_data` or `can_view_clinical_data` => (HTTP 403). 196 - """ 197 - # No relevant permission 198 - encounter = self.create_encounter( 199 - patient=self.patient, 200 - facility=self.facility, 201 - organization=self.organization, 202 - status=None, 203 - ) 204 - url = f"{self.base_url}?encounter={encounter.external_id}" 205 - response = self.client.get(url) 206 - self.assertEqual(response.status_code, 403) 207 - 208 - # CREATE TESTS 209 - def test_create_chronic_condition_without_permissions(self): 210 - """ 211 - Users who lack `can_write_patient` get (HTTP 403) when creating. 212 - """ 213 - # No permission attached 214 - encounter = self.create_encounter( 215 - patient=self.patient, 216 - facility=self.facility, 217 - organization=self.organization, 218 - status=None, 219 - ) 220 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 221 - encounter 222 - ) 223 - 224 - response = self.client.post( 225 - self.base_url, chronic_condition_data_dict, format="json" 226 - ) 227 - self.assertEqual(response.status_code, 403) 228 - 229 - def test_create_chronic_condition_without_permissions_on_facility(self): 230 - """ 231 - Tests that a user with `can_write_patient` permissions but belonging to a different 232 - organization receives (HTTP 403) when attempting to create a chronic_condition. 233 - """ 234 - permissions = [ 235 - PatientPermissions.can_view_clinical_data.name, 236 - PatientPermissions.can_write_patient.name, 237 - ] 238 - role = self.create_role_with_permissions(permissions) 239 - external_user = self.create_user() 240 - external_facility = self.create_facility(user=external_user) 241 - external_organization = self.create_facility_organization( 242 - facility=external_facility 243 - ) 244 - self.attach_role_facility_organization_user( 245 - external_organization, self.user, role 246 - ) 247 - 248 - encounter = self.create_encounter( 249 - patient=self.patient, 250 - facility=self.facility, 251 - organization=self.organization, 252 - status=None, 253 - ) 254 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 255 - encounter 256 - ) 257 - 258 - response = self.client.post( 259 - self.base_url, chronic_condition_data_dict, format="json" 260 - ) 261 - self.assertEqual(response.status_code, 403) 262 - 263 - def test_create_chronic_condition_with_organization_user_with_permissions(self): 264 - """ 265 - Ensures that a user from a certain organization, who has both 266 - `can_write_patient` and `can_view_clinical_data`, can successfully 267 - view chronic_condition data (HTTP 200) and is able to edit chronic_condition 268 - and chronic_condition can change across encounters. 269 - """ 270 - organization = self.create_organization(org_type="govt") 271 - patient = self.create_patient(geo_organization=organization) 272 - 273 - permissions = [ 274 - PatientPermissions.can_write_patient.name, 275 - PatientPermissions.can_view_clinical_data.name, 276 - ] 277 - role = self.create_role_with_permissions(permissions) 278 - self.attach_role_organization_user(organization, self.user, role) 279 - 280 - # Verify the user can view chronic_condition data (HTTP 200) 281 - test_url = reverse( 282 - "chronic-condition-list", 283 - kwargs={"patient_external_id": patient.external_id}, 284 - ) 285 - response = self.client.get(test_url) 286 - self.assertEqual(response.status_code, 200) 287 - 288 - encounter = self.create_encounter( 289 - patient=patient, 290 - facility=self.facility, 291 - organization=self.organization, 292 - status=None, 293 - ) 294 - 295 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 296 - encounter 297 - ) 298 - response = self.client.post( 299 - test_url, chronic_condition_data_dict, format="json" 300 - ) 301 - 302 - self.assertEqual(response.status_code, 200) 303 - 304 - def test_create_chronic_condition_with_permissions(self): 305 - """ 306 - Users with `can_write_patient` on a non-completed encounter => (HTTP 200). 307 - """ 308 - permissions = [PatientPermissions.can_write_patient.name] 309 - role = self.create_role_with_permissions(permissions) 310 - self.attach_role_facility_organization_user(self.organization, self.user, role) 311 - 312 - encounter = self.create_encounter( 313 - patient=self.patient, 314 - facility=self.facility, 315 - organization=self.organization, 316 - status=None, 317 - ) 318 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 319 - encounter 320 - ) 321 - 322 - response = self.client.post( 323 - self.base_url, chronic_condition_data_dict, format="json" 324 - ) 325 - self.assertEqual(response.status_code, 200) 326 - self.assertEqual( 327 - response.json()["severity"], chronic_condition_data_dict["severity"] 328 - ) 329 - self.assertEqual(response.json()["code"], chronic_condition_data_dict["code"]) 330 - 331 - def test_create_chronic_condition_with_permissions_and_encounter_status_completed( 332 - self, 333 - ): 334 - """ 335 - Users with `can_write_patient` on a completed encounter => (HTTP 403). 336 - """ 337 - permissions = [PatientPermissions.can_write_patient.name] 338 - role = self.create_role_with_permissions(permissions) 339 - self.attach_role_facility_organization_user(self.organization, self.user, role) 340 - 341 - encounter = self.create_encounter( 342 - patient=self.patient, 343 - facility=self.facility, 344 - organization=self.organization, 345 - status=StatusChoices.completed.value, 346 - ) 347 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 348 - encounter 349 - ) 350 - 351 - response = self.client.post( 352 - self.base_url, chronic_condition_data_dict, format="json" 353 - ) 354 - self.assertEqual(response.status_code, 403) 355 - 356 - def test_create_chronic_condition_with_permissions_and_no_association_with_facility( 357 - self, 358 - ): 359 - """ 360 - Test that users with `can_write_patient` permission, but who are not 361 - associated with the facility, receive an HTTP 403 (Forbidden) response 362 - when attempting to create a chronic_condition. 363 - """ 364 - permissions = [PatientPermissions.can_write_patient.name] 365 - role = self.create_role_with_permissions(permissions) 366 - organization = self.create_organization(org_type="govt") 367 - self.attach_role_organization_user(organization, self.user, role) 368 - 369 - encounter = self.create_encounter( 370 - patient=self.patient, 371 - facility=self.facility, 372 - organization=self.organization, 373 - status=None, 374 - ) 375 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 376 - encounter 377 - ) 378 - 379 - response = self.client.post( 380 - self.base_url, chronic_condition_data_dict, format="json" 381 - ) 382 - self.assertEqual(response.status_code, 403) 383 - 384 - def test_create_chronic_condition_with_permissions_with_mismatched_patient_id(self): 385 - """ 386 - Users with `can_write_patient` on a encounter with different patient => (HTTP 403). 387 - """ 388 - permissions = [ 389 - PatientPermissions.can_view_clinical_data.name, 390 - PatientPermissions.can_write_patient.name, 391 - ] 392 - role = self.create_role_with_permissions(permissions) 393 - self.attach_role_facility_organization_user(self.organization, self.user, role) 394 - 395 - encounter = self.create_encounter( 396 - patient=self.create_patient(), 397 - facility=self.facility, 398 - organization=self.organization, 399 - status=None, 400 - ) 401 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 402 - encounter 403 - ) 404 - 405 - response = self.client.post( 406 - self.base_url, chronic_condition_data_dict, format="json" 407 - ) 408 - self.assertEqual(response.status_code, 403) 409 - 410 - def test_create_chronic_condition_with_permissions_with_invalid_encounter_id(self): 411 - """ 412 - Users with `can_write_patient` on a incomplete encounter => (HTTP 400). 413 - """ 414 - permissions = [PatientPermissions.can_write_patient.name] 415 - role = self.create_role_with_permissions(permissions) 416 - self.attach_role_facility_organization_user(self.organization, self.user, role) 417 - 418 - encounter = self.create_encounter( 419 - patient=self.create_patient(), 420 - facility=self.facility, 421 - organization=self.organization, 422 - status=None, 423 - ) 424 - chronic_condition_data_dict = self.generate_data_for_chronic_condition( 425 - encounter 426 - ) 427 - chronic_condition_data_dict["encounter"] = uuid.uuid4() 428 - 429 - response = self.client.post( 430 - self.base_url, chronic_condition_data_dict, format="json" 431 - ) 432 - response_data = response.json() 433 - self.assertEqual(response.status_code, 400) 434 - self.assertIn("errors", response_data) 435 - error = response_data["errors"][0] 436 - self.assertEqual(error["type"], "value_error") 437 - self.assertIn("Encounter not found", error["msg"]) 438 - 439 - # RETRIEVE TESTS 440 - def test_retrieve_chronic_condition_with_permissions(self): 441 - """ 442 - Users with `can_view_clinical_data` => (HTTP 200). 443 - """ 444 - permissions = [PatientPermissions.can_view_clinical_data.name] 445 - role = self.create_role_with_permissions(permissions) 446 - self.attach_role_facility_organization_user(self.organization, self.user, role) 447 - 448 - encounter = self.create_encounter( 449 - patient=self.patient, 450 - facility=self.facility, 451 - organization=self.organization, 452 - ) 453 - chronic_condition = self.create_chronic_condition( 454 - encounter=encounter, patient=self.patient 455 - ) 456 - 457 - url = self._get_chronic_condition_url(chronic_condition.external_id) 458 - retrieve_response = self.client.get(url) 459 - self.assertEqual(retrieve_response.status_code, 200) 460 - self.assertEqual( 461 - retrieve_response.data["id"], str(chronic_condition.external_id) 462 - ) 463 - 464 - def test_retrieve_chronic_condition_for_single_encounter_with_permissions(self): 465 - """ 466 - Users with `can_view_clinical_data` => (HTTP 200). 467 - """ 468 - permissions = [ 469 - PatientPermissions.can_view_clinical_data.name, 470 - ] 471 - role = self.create_role_with_permissions(permissions) 472 - self.attach_role_facility_organization_user(self.organization, self.user, role) 473 - 474 - encounter = self.create_encounter( 475 - patient=self.patient, 476 - facility=self.facility, 477 - organization=self.organization, 478 - ) 479 - chronic_condition = self.create_chronic_condition( 480 - encounter=encounter, patient=self.patient 481 - ) 482 - 483 - url = self._get_chronic_condition_url(chronic_condition.external_id) 484 - retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") 485 - self.assertEqual(retrieve_response.status_code, 200) 486 - self.assertEqual( 487 - retrieve_response.data["id"], str(chronic_condition.external_id) 488 - ) 489 - 490 - def test_retrieve_chronic_condition_for_single_encounter_without_permissions(self): 491 - """ 492 - Lacking `can_view_clinical_data` => (HTTP 403). 493 - """ 494 - # No relevant permission 495 - encounter = self.create_encounter( 496 - patient=self.patient, 497 - facility=self.facility, 498 - organization=self.organization, 499 - ) 500 - chronic_condition = self.create_chronic_condition( 501 - encounter=encounter, patient=self.patient 502 - ) 503 - 504 - url = self._get_chronic_condition_url(chronic_condition.external_id) 505 - retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") 506 - self.assertEqual(retrieve_response.status_code, 403) 507 - 508 - def test_retrieve_chronic_condition_without_permissions(self): 509 - """ 510 - Users who have only `can_write_patient` => (HTTP 403). 511 - """ 512 - # No relevant permission 513 - encounter = self.create_encounter( 514 - patient=self.patient, 515 - facility=self.facility, 516 - organization=self.organization, 517 - ) 518 - chronic_condition = self.create_chronic_condition( 519 - encounter=encounter, patient=self.patient 520 - ) 521 - 522 - url = self._get_chronic_condition_url(chronic_condition.external_id) 523 - retrieve_response = self.client.get(url) 524 - self.assertEqual(retrieve_response.status_code, 403) 525 - 526 - # UPDATE TESTS 527 - def test_update_chronic_condition_with_permissions(self): 528 - """ 529 - Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` 530 - => (HTTP 200) when updating. 531 - """ 532 - permissions = [ 533 - PatientPermissions.can_view_clinical_data.name, 534 - PatientPermissions.can_write_patient.name, 535 - EncounterPermissions.can_write_encounter.name, 536 - ] 537 - role = self.create_role_with_permissions(permissions) 538 - self.attach_role_facility_organization_user(self.organization, self.user, role) 539 - 540 - encounter = self.create_encounter( 541 - patient=self.patient, 542 - facility=self.facility, 543 - organization=self.organization, 544 - ) 545 - chronic_condition = self.create_chronic_condition( 546 - encounter=encounter, patient=self.patient 547 - ) 548 - 549 - url = self._get_chronic_condition_url(chronic_condition.external_id) 550 - chronic_condition_data_updated = model_to_dict(chronic_condition) 551 - chronic_condition_data_updated["encounter"] = encounter.external_id 552 - chronic_condition_data_updated["severity"] = "mild" 553 - chronic_condition_data_updated["code"] = self.valid_code 554 - 555 - response = self.client.put(url, chronic_condition_data_updated, format="json") 556 - self.assertEqual(response.status_code, 200) 557 - self.assertEqual(response.json()["severity"], "mild") 558 - 559 - def test_update_chronic_condition_for_single_encounter_with_permissions(self): 560 - """ 561 - Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` 562 - => (HTTP 200). 563 - """ 564 - permissions = [ 565 - PatientPermissions.can_view_clinical_data.name, 566 - PatientPermissions.can_write_patient.name, 567 - EncounterPermissions.can_write_encounter.name, 568 - ] 569 - role = self.create_role_with_permissions(permissions) 570 - self.attach_role_facility_organization_user(self.organization, self.user, role) 571 - 572 - encounter = self.create_encounter( 573 - patient=self.patient, 574 - facility=self.facility, 575 - organization=self.organization, 576 - ) 577 - chronic_condition = self.create_chronic_condition( 578 - encounter=encounter, patient=self.patient 579 - ) 580 - 581 - url = self._get_chronic_condition_url(chronic_condition.external_id) 582 - chronic_condition_data_updated = model_to_dict(chronic_condition) 583 - chronic_condition_data_updated["encounter"] = encounter.external_id 584 - chronic_condition_data_updated["severity"] = "mild" 585 - chronic_condition_data_updated["code"] = self.valid_code 586 - 587 - update_response = self.client.put( 588 - f"{url}?encounter={encounter.external_id}", 589 - chronic_condition_data_updated, 590 - format="json", 591 - ) 592 - self.assertEqual(update_response.status_code, 200) 593 - self.assertEqual(update_response.json()["severity"], "mild") 594 - 595 - def test_update_chronic_condition_for_single_encounter_without_permissions(self): 596 - """ 597 - Lacking `can_view_clinical_data` => (HTTP 403). 598 - """ 599 - # Only write permission 600 - permissions = [PatientPermissions.can_write_patient.name] 601 - role = self.create_role_with_permissions(permissions) 602 - self.attach_role_facility_organization_user(self.organization, self.user, role) 603 - 604 - encounter = self.create_encounter( 605 - patient=self.patient, 606 - facility=self.facility, 607 - organization=self.organization, 608 - ) 609 - chronic_condition = self.create_chronic_condition( 610 - encounter=encounter, patient=self.patient 611 - ) 612 - 613 - url = self._get_chronic_condition_url(chronic_condition.external_id) 614 - chronic_condition_data_updated = model_to_dict(chronic_condition) 615 - chronic_condition_data_updated["severity"] = "mild" 616 - 617 - update_response = self.client.put( 618 - f"{url}?encounter={encounter.external_id}", 619 - chronic_condition_data_updated, 620 - format="json", 621 - ) 622 - self.assertEqual(update_response.status_code, 403) 623 - 624 - def test_update_chronic_condition_without_permissions(self): 625 - """ 626 - Users with only `can_write_patient` but not `can_view_clinical_data` 627 - => (HTTP 403). 628 - """ 629 - # Only write permission (same scenario as above but no read or view clinical) 630 - 631 - permissions = [PatientPermissions.can_write_patient.name] 632 - role = self.create_role_with_permissions(permissions) 633 - self.attach_role_facility_organization_user(self.organization, self.user, role) 634 - 635 - encounter = self.create_encounter( 636 - patient=self.patient, 637 - facility=self.facility, 638 - organization=self.organization, 639 - ) 640 - chronic_condition = self.create_chronic_condition( 641 - encounter=encounter, patient=self.patient 642 - ) 643 - 644 - url = self._get_chronic_condition_url(chronic_condition.external_id) 645 - chronic_condition_data_updated = model_to_dict(chronic_condition) 646 - chronic_condition_data_updated["severity"] = "mild" 647 - 648 - update_response = self.client.put( 649 - url, chronic_condition_data_updated, format="json" 650 - ) 651 - self.assertEqual(update_response.status_code, 403) 652 - 653 - def test_update_chronic_condition_for_closed_encounter_with_permissions(self): 654 - """ 655 - Encounter completed => (HTTP 403) on update, 656 - even if user has `can_write_patient` + `can_view_clinical_data`. 657 - """ 658 - permissions = [ 659 - PatientPermissions.can_write_patient.name, 660 - PatientPermissions.can_view_clinical_data.name, 661 - ] 662 - role = self.create_role_with_permissions(permissions) 663 - self.attach_role_facility_organization_user(self.organization, self.user, role) 664 - 665 - encounter = self.create_encounter( 666 - patient=self.patient, 667 - facility=self.facility, 668 - organization=self.organization, 669 - status=StatusChoices.completed.value, 670 - ) 671 - chronic_condition = self.create_chronic_condition( 672 - encounter=encounter, patient=self.patient 673 - ) 674 - 675 - url = self._get_chronic_condition_url(chronic_condition.external_id) 676 - chronic_condition_data_updated = model_to_dict(chronic_condition) 677 - chronic_condition_data_updated["severity"] = "mild" 678 - 679 - update_response = self.client.put( 680 - url, chronic_condition_data_updated, format="json" 681 - ) 682 - self.assertEqual(update_response.status_code, 403) 683 - 684 - def test_update_chronic_condition_changes_encounter_id(self): 685 - """ 686 - When a user with access to a new encounter 687 - updates a chronic_condition added by a different encounter, 688 - the encounter_id should be updated to the new encounter. 689 - """ 690 - permissions = [ 691 - PatientPermissions.can_write_patient.name, 692 - PatientPermissions.can_view_clinical_data.name, 693 - EncounterPermissions.can_write_encounter.name, 694 - ] 695 - role = self.create_role_with_permissions(permissions) 696 - self.attach_role_facility_organization_user(self.organization, self.user, role) 697 - 698 - temp_facility = self.create_facility(user=self.create_user()) 699 - encounter = self.create_encounter( 700 - patient=self.patient, 701 - facility=temp_facility, 702 - organization=self.create_facility_organization(facility=temp_facility), 703 - ) 704 - 705 - chronic_condition = self.create_chronic_condition( 706 - encounter=encounter, 707 - patient=self.patient, 708 - code=self.valid_code, 709 - ) 710 - 711 - new_encounter = self.create_encounter( 712 - patient=self.patient, 713 - facility=self.facility, 714 - organization=self.organization, 715 - ) 716 - 717 - url = self._get_chronic_condition_url(chronic_condition.external_id) 718 - chronic_condition_data_updated = model_to_dict(chronic_condition) 719 - chronic_condition_data_updated["encounter"] = new_encounter.external_id 720 - chronic_condition_data_updated["clinical_status"] = "remission" 721 - 722 - update_response = self.client.put( 723 - url, chronic_condition_data_updated, format="json" 724 - ) 725 - self.assertEqual(update_response.status_code, 200) 726 - self.assertEqual( 727 - update_response.json()["encounter"], str(new_encounter.external_id) 728 - ) 729 - 730 - def test_update_chronic_condition_changes_encounter_id_without_permission(self): 731 - """ 732 - When a user without access to a new encounter 733 - updates a chronic_condition added by a different encounter, 734 - the encounter_id should not be updated to the new encounter. 735 - """ 736 - permissions = [ 737 - PatientPermissions.can_write_patient.name, 738 - PatientPermissions.can_view_clinical_data.name, 739 - ] 740 - role = self.create_role_with_permissions(permissions) 741 - self.attach_role_facility_organization_user(self.organization, self.user, role) 742 - 743 - temp_facility = self.create_facility(user=self.create_user()) 744 - encounter = self.create_encounter( 745 - patient=self.patient, 746 - facility=temp_facility, 747 - organization=self.create_facility_organization(facility=temp_facility), 748 - ) 749 - 750 - chronic_condition = self.create_chronic_condition( 751 - encounter=encounter, 752 - patient=self.patient, 753 - code=self.valid_code, 754 - ) 755 - 756 - new_encounter = self.create_encounter( 757 - patient=self.patient, 758 - facility=self.facility, 759 - organization=self.organization, 760 - ) 761 - 762 - url = self._get_chronic_condition_url(chronic_condition.external_id) 763 - chronic_condition_data_updated = model_to_dict(chronic_condition) 764 - chronic_condition_data_updated["encounter"] = new_encounter.external_id 765 - chronic_condition_data_updated["clinical_status"] = "remission" 766 - 767 - update_response = self.client.put( 768 - url, chronic_condition_data_updated, format="json" 769 - ) 770 - self.assertEqual(update_response.status_code, 403)
+12
care/emr/tests/test_device_api.py
··· 469 469 Device.objects.get(external_id=self.device["id"]).managing_organization, 470 470 self.managing_org, 471 471 ) 472 + self.assertIn( 473 + self.managing_org.id, 474 + Device.objects.get( 475 + external_id=self.device["id"] 476 + ).facility_organization_cache, 477 + ) 472 478 473 479 response = self.client.post( 474 480 self.add_url, ··· 509 515 self.assertEqual(response.status_code, 200) 510 516 self.assertIsNone( 511 517 Device.objects.get(external_id=self.device["id"]).managing_organization 518 + ) 519 + self.assertNotIn( 520 + self.managing_org.id, 521 + Device.objects.get( 522 + external_id=self.device["id"] 523 + ).facility_organization_cache, 512 524 ) 513 525 514 526 def test_remove_managing_organization_without_permissions(self):
+1 -13
care/emr/tests/test_diagnosis_api.py
··· 1 1 import datetime 2 2 import uuid 3 3 from secrets import choice 4 - from unittest.mock import patch 5 4 6 5 from django.forms import model_to_dict 7 6 from django.urls import reverse ··· 38 37 "system": "http://test_system.care/test", 39 38 "code": "123", 40 39 } 41 - # Mocking validate_valueset 42 - self.patcher = patch( 43 - "care.emr.resources.condition.spec.validate_valueset", 44 - return_value=self.valid_code, 45 - ) 46 - self.mock_validate_valueset = self.patcher.start() 47 - 48 - def tearDown(self): 49 - self.patcher.stop() 50 40 51 41 def _get_diagnosis_url(self, diagnosis_id): 52 42 """Helper to get the detail URL for a specific diagnosis.""" ··· 319 309 ) 320 310 diagnosis_data_dict = self.generate_data_for_diagnosis( 321 311 encounter, 322 - onset={ 323 - "onset_datetime": care_now() + datetime.timedelta(seconds=20), 324 - }, 312 + onset={"onset_datetime": care_now() + datetime.timedelta(days=1)}, 325 313 ) 326 314 response = self.client.post(self.base_url, diagnosis_data_dict, format="json") 327 315 self.assertEqual(response.status_code, 400)
+106 -1
care/emr/tests/test_location_api.py
··· 2 2 import uuid 3 3 from secrets import choice 4 4 5 - from django.test import ignore_warnings 5 + from django.test import ignore_warnings, override_settings 6 6 from django.urls import reverse 7 7 from django.utils import timezone 8 8 ··· 260 260 response = self.client.post(self.base_url, data=data, format="json") 261 261 self.assertEqual(response.status_code, 403) 262 262 self.assertEqual(response.json()["detail"], "Parent Incompatible with Location") 263 + 264 + @override_settings(LOCATION_MAX_DEPTH=2) 265 + def test_for_maximum_depth_for_location(self): 266 + self.client.force_authenticate(self.super_user) 267 + 268 + # Create root (depth 0) 269 + data = self.generate_data_for_facility_location(organizations=[], mode="kind") 270 + response = self.client.post(self.base_url, data=data, format="json") 271 + self.assertEqual(response.status_code, 200) 272 + parent_id = response.data["id"] 273 + 274 + # Create child (depth 1) 275 + data = self.generate_data_for_facility_location( 276 + parent=parent_id, organizations=[], mode="kind" 277 + ) 278 + response = self.client.post(self.base_url, data=data, format="json") 279 + self.assertEqual(response.status_code, 200) 280 + parent_id = response.data["id"] 281 + 282 + # Create child (depth 2) 283 + data = self.generate_data_for_facility_location( 284 + parent=parent_id, organizations=[], mode="kind" 285 + ) 286 + response = self.client.post(self.base_url, data=data, format="json") 287 + self.assertEqual(response.status_code, 200) 288 + parent_id = response.data["id"] 289 + 290 + # Create child at depth 3 → should fail 291 + data = self.generate_data_for_facility_location( 292 + parent=parent_id, organizations=[], mode="kind" 293 + ) 294 + response = self.client.post(self.base_url, data=data, format="json") 295 + self.assertEqual(response.status_code, 400) 296 + self.assertEqual(response.json()["errors"][0]["msg"], "Max depth reached (2)") 297 + 298 + @override_settings(MAX_LOCATION_IN_FACILITY=2) 299 + def test_for_maximum_location_in_facility(self): 300 + self.client.force_authenticate(self.super_user) 301 + 302 + data = self.generate_data_for_facility_location(organizations=[], mode="kind") 303 + response = self.client.post(self.base_url, data=data, format="json") 304 + self.assertEqual(response.status_code, 200) 305 + parent_id = response.data["id"] 306 + 307 + data = self.generate_data_for_facility_location( 308 + parent=parent_id, organizations=[], mode="kind" 309 + ) 310 + response = self.client.post(self.base_url, data=data, format="json") 311 + self.assertEqual(response.status_code, 200) 312 + parent_id = response.data["id"] 313 + 314 + data = self.generate_data_for_facility_location( 315 + parent=parent_id, organizations=[], mode="kind" 316 + ) 317 + response = self.client.post(self.base_url, data=data, format="json") 318 + self.assertEqual(response.status_code, 400) 319 + self.assertEqual( 320 + response.json()["errors"][0]["msg"], "Max location reached for facility (2)" 321 + ) 322 + 323 + def test_create_facility_location_with_invalid_sort_index(self): 324 + self.authenticate_with_permissions( 325 + [ 326 + FacilityOrganizationPermissions.can_create_facility_organization.name, 327 + FacilityOrganizationPermissions.can_manage_facility_organization.name, 328 + ] 329 + ) 330 + data = self.generate_data_for_facility_location(sort_index=-1) 331 + response = self.client.post(self.base_url, data=data, format="json") 332 + self.assertEqual(response.status_code, 400) 333 + data = self.generate_data_for_facility_location(sort_index=10001) 334 + response = self.client.post(self.base_url, data=data, format="json") 335 + self.assertEqual(response.status_code, 400) 336 + 337 + def test_sort_index_value_setting(self): 338 + self.client.force_authenticate(self.super_user) 339 + 340 + # Step 1: Create a parent location 341 + data = self.generate_data_for_facility_location(mode="kind") 342 + response = self.client.post(self.base_url, data=data, format="json") 343 + self.assertEqual(response.status_code, 200) 344 + parent_id = response.data["id"] 345 + 346 + # Step 2: Create child 1 (sort_index = 1) 347 + data1 = self.generate_data_for_facility_location(parent=parent_id) 348 + response1 = self.client.post(self.base_url, data=data1, format="json") 349 + self.assertEqual(response1.status_code, 200) 350 + child1_id = response1.data["id"] 351 + self.assertEqual(response1.data["sort_index"], 1) 352 + 353 + # Step 3: Create child 2 (sort_index = 2) 354 + data2 = self.generate_data_for_facility_location(parent=parent_id) 355 + response2 = self.client.post(self.base_url, data=data2, format="json") 356 + self.assertEqual(response2.status_code, 200) 357 + self.assertEqual(response2.data["sort_index"], 2) 358 + 359 + # Step 4: Delete child 1 360 + delete_response = self.client.delete(f"{self.base_url}{child1_id}/") 361 + self.assertEqual(delete_response.status_code, 204) 362 + 363 + # Step 5: Create new child (should get sort_index = 3) 364 + data3 = self.generate_data_for_facility_location(parent=parent_id) 365 + response3 = self.client.post(self.base_url, data=data3, format="json") 366 + self.assertEqual(response3.status_code, 200) 367 + self.assertEqual(response3.data["sort_index"], 3) 263 368 264 369 # RETRIEVE TESTS 265 370 def test_retrieve_facility_location_without_permissions(self):
-10
care/emr/tests/test_medication_request.py
··· 1 1 from datetime import UTC, datetime 2 - from unittest.mock import patch 3 2 4 3 from django.urls import reverse 5 4 from model_bakery import baker ··· 32 31 "system": "http://test_system.care/test", 33 32 "code": "123", 34 33 } 35 - # Mocking validate_valueset 36 - self.patcher = patch( 37 - "care.emr.resources.medication.request.spec.validate_valueset", 38 - return_value=self.valid_code, 39 - ) 40 - self.mock_validate_valueset = self.patcher.start() 41 - 42 - def tearDown(self): 43 - self.patcher.stop() 44 34 45 35 def _get_medication_request_url(self, medication_request_id): 46 36 """Helper to get the detail URL for a specific medication request."""
+53
care/emr/tests/test_patient_api.py
··· 9 9 from care.emr.resources.patient.spec import BloodGroupChoices, GenderChoices 10 10 from care.security.permissions.patient import PatientPermissions 11 11 from care.utils.tests.base import CareAPITestBase 12 + from care.utils.time_util import care_now 12 13 13 14 14 15 def generate_random_valid_phone_number() -> str: ··· 175 176 self.assertEqual(response.status_code, status.HTTP_200_OK) 176 177 self.assertEqual(response.data["date_of_birth"], "1992-01-10") 177 178 self.assertEqual(response.data["year_of_birth"], 1992) 179 + 180 + def test_invalid_date_of_birth_and_death_date(self): 181 + user = self.create_user() 182 + geo_organization = self.create_organization(org_type="govt") 183 + role = self.create_role_with_permissions( 184 + permissions=[ 185 + PatientPermissions.can_create_patient.name, 186 + PatientPermissions.can_write_patient.name, 187 + PatientPermissions.can_list_patients.name, 188 + ] 189 + ) 190 + self.attach_role_organization_user(geo_organization, user, role) 191 + self.client.force_authenticate(user=user) 192 + patient_data = self.generate_patient_data( 193 + geo_organization=geo_organization.external_id, 194 + date_of_birth=datetime.date(1993, 1, 10), 195 + deceased_datetime=datetime.datetime(1992, 5, 15, 14, 30, 0), 196 + ) 197 + response = self.client.post(self.base_url, patient_data, format="json") 198 + data = response.json() 199 + status_code = response.status_code 200 + self.assertEqual(status_code, 400) 201 + self.assertIn("errors", data) 202 + error = data["errors"][0] 203 + self.assertEqual(error["type"], "validation_error") 204 + self.assertIn("Date of birth cannot be after the date of death", error["msg"]) 205 + 206 + def test_invalid_age_and_death_date(self): 207 + user = self.create_user() 208 + geo_organization = self.create_organization(org_type="govt") 209 + role = self.create_role_with_permissions( 210 + permissions=[ 211 + PatientPermissions.can_create_patient.name, 212 + PatientPermissions.can_write_patient.name, 213 + PatientPermissions.can_list_patients.name, 214 + ] 215 + ) 216 + self.attach_role_organization_user(geo_organization, user, role) 217 + self.client.force_authenticate(user=user) 218 + patient_data = self.generate_patient_data( 219 + geo_organization=geo_organization.external_id, 220 + deceased_datetime=care_now() - datetime.timedelta(days=2), 221 + date_of_birth=(care_now() + datetime.timedelta(days=5)).date().isoformat(), 222 + ) 223 + response = self.client.post(self.base_url, patient_data, format="json") 224 + data = response.json() 225 + status_code = response.status_code 226 + self.assertEqual(status_code, 400) 227 + self.assertIn("errors", data) 228 + error = data["errors"][0] 229 + self.assertEqual(error["type"], "validation_error") 230 + self.assertIn("Date of birth cannot be after the date of death", error["msg"])
+1317 -1
care/emr/tests/test_questionnaire_api.py
··· 1 1 import uuid 2 2 3 + from django.conf import settings 3 4 from django.urls import reverse 4 5 from model_bakery import baker 5 6 7 + from care.emr.resources.questionnaire.spec import QuestionType 6 8 from care.security.permissions.questionnaire import QuestionnairePermissions 7 9 from care.utils.tests.base import CareAPITestBase 8 10 ··· 143 145 "subject_type": "patient", 144 146 "organizations": [str(self.organization.external_id)], 145 147 "questions": questions, 148 + "tags": [self.create_questionnaire_tag().external_id], 146 149 } 147 150 148 151 response = self.client.post( ··· 199 202 "time": "25:61:00", 200 203 "choice": "INVALID_CHOICE", 201 204 "url": "not_a_url", 205 + "text": "a" * settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE + "extra", 202 206 } 203 207 return invalid_values.get(question_type) 204 208 ··· 238 242 "time", 239 243 "choice", 240 244 "url", 245 + "text", 241 246 ] 242 247 243 248 for question_type in test_types: ··· 253 258 error = response_data["errors"][0] 254 259 self.assertEqual(error["type"], "type_error") 255 260 self.assertEqual(error["question_id"], question["id"]) 256 - self.assertIn(f"Invalid {question_type}", error["msg"]) 261 + if question_type == QuestionType.text.value: 262 + self.assertIn( 263 + f"Text too long. Max allowed size is {settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE}", 264 + error["msg"], 265 + ) 266 + else: 267 + self.assertIn(f"Invalid {question_type}", error["msg"]) 268 + 269 + def test_false_choice_values_validations(self): 270 + questionnaire_definition = { 271 + "title": "Comprehensive Health Assessment", 272 + "slug": "ques-choices-type", 273 + "description": "Complete health assessment questionnaire with various response types", 274 + "status": "active", 275 + "subject_type": "patient", 276 + "organizations": [str(self.organization.external_id)], 277 + "questions": [ 278 + { 279 + "link_id": "1", 280 + "type": "choice", 281 + "text": "Overall health assessment", 282 + "answer_option": [ 283 + {"value": " ", "display": "Excellent"}, 284 + {"value": "GOOD", "display": "Good"}, 285 + {"value": "FAIR", "display": "Fair"}, 286 + {"value": "POOR", "display": "Poor"}, 287 + ], 288 + }, 289 + ], 290 + } 291 + response = self.client.post( 292 + self.base_url, questionnaire_definition, format="json" 293 + ) 294 + data = response.json() 295 + status_code = response.status_code 296 + self.assertEqual(status_code, 400) 297 + self.assertIn("errors", data) 298 + error = data["errors"][0] 299 + self.assertEqual(error["type"], "value_error") 300 + self.assertIn( 301 + "All the answer option values must be provided for custom choices", 302 + error["msg"], 303 + ) 304 + 305 + 306 + class QuestionnaireEnableWhenSubmissionTests(QuestionnaireTestBase): 307 + def setUp(self): 308 + # Override setUp so that we don't create a default questionnaire. 309 + self.user = self.create_super_user() 310 + self.organization = self.create_organization(org_type="govt") 311 + self.patient = self.create_patient() 312 + self.client.force_authenticate(user=self.user) 313 + self.base_url = reverse("questionnaire-list") 314 + 315 + def _create_questionnaire(self, questions): 316 + """ 317 + Creates a questionnaire with the given list of questions. 318 + A base code template is added to each question and a unique slug is generated. 319 + """ 320 + question_templates = { 321 + "base": { 322 + "code": { 323 + "display": "Test Value", 324 + "system": "http://test_system.care/test", 325 + "code": "123", 326 + } 327 + }, 328 + } 329 + for question in questions: 330 + question.update(question_templates["base"]) 331 + questionnaire_definition = { 332 + "title": "Test Questionnaire", 333 + "slug": f"test-ques-{uuid.uuid4()!s}"[:20], 334 + "description": "Questionnaire for testing enable_when operators", 335 + "status": "active", 336 + "subject_type": "patient", 337 + "organizations": [str(self.organization.external_id)], 338 + "questions": questions, 339 + } 340 + response = self.client.post( 341 + self.base_url, questionnaire_definition, format="json" 342 + ) 343 + self.assertEqual( 344 + response.status_code, 345 + 200, 346 + f"Questionnaire creation failed: {response.json()}", 347 + ) 348 + return response.json() 349 + 350 + def _submit(self, responses): 351 + """ 352 + Utility method to submit responses. 353 + Returns a tuple: (status_code, response_data) 354 + """ 355 + payload = { 356 + "resource_id": str(self.patient.external_id), 357 + "patient": str(self.patient.external_id), 358 + "results": responses, 359 + } 360 + return self._submit_questionnaire(payload) 361 + 362 + # --- Equals operator --- 363 + def test_equals_operator_valid(self): 364 + # Q2 is enabled if Q1 equals "true" 365 + questions = [ 366 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 367 + { 368 + "link_id": "2", 369 + "type": "integer", 370 + "text": "Q2", 371 + "enable_when": [ 372 + {"question": "1", "operator": "equals", "answer": "true"} 373 + ], 374 + }, 375 + ] 376 + questionnaire = self._create_questionnaire(questions) 377 + self.questionnaire_data = questionnaire 378 + self.questions = questionnaire["questions"] 379 + 380 + responses = [ 381 + {"question_id": self.questions[0]["id"], "values": [{"value": "true"}]}, 382 + {"question_id": self.questions[1]["id"], "values": [{"value": "10"}]}, 383 + ] 384 + status_code, response_data = self._submit(responses) 385 + self.assertEqual( 386 + status_code, 387 + 200, 388 + f"Valid equals operator submission should succeed: {response_data}", 389 + ) 390 + saved_qids = { 391 + resp["question_id"] for resp in response_data.get("responses", []) 392 + } 393 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 394 + self.assertIn( 395 + self.questions[1]["id"], saved_qids, "Q2 should be present (condition met)" 396 + ) 397 + 398 + def test_equals_operator_invalid(self): 399 + # Q2 should be disabled because Q1 does not equal "true" 400 + questions = [ 401 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 402 + { 403 + "link_id": "2", 404 + "type": "integer", 405 + "text": "Q2", 406 + "enable_when": [ 407 + {"question": "1", "operator": "equals", "answer": "true"} 408 + ], 409 + }, 410 + ] 411 + questionnaire = self._create_questionnaire(questions) 412 + self.questionnaire_data = questionnaire 413 + self.questions = questionnaire["questions"] 414 + 415 + responses = [ 416 + {"question_id": self.questions[0]["id"], "values": [{"value": "false"}]}, 417 + {"question_id": self.questions[1]["id"], "values": [{"value": "10"}]}, 418 + ] 419 + status_code, response_data = self._submit(responses) 420 + self.assertEqual( 421 + status_code, 422 + 200, 423 + f"Submission should succeed in ignore mode: {response_data}", 424 + ) 425 + saved_qids = { 426 + resp["question_id"] for resp in response_data.get("responses", []) 427 + } 428 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 429 + self.assertNotIn( 430 + self.questions[1]["id"], 431 + saved_qids, 432 + "Q2 should be removed since condition failed", 433 + ) 434 + 435 + # --- Not Equals operator --- 436 + def test_not_equals_operator_valid(self): 437 + # Q2 is enabled if Q1 not_equals "true" 438 + questions = [ 439 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 440 + { 441 + "link_id": "2", 442 + "type": "integer", 443 + "text": "Q2", 444 + "enable_when": [ 445 + {"question": "1", "operator": "not_equals", "answer": "true"} 446 + ], 447 + }, 448 + ] 449 + questionnaire = self._create_questionnaire(questions) 450 + self.questionnaire_data = questionnaire 451 + self.questions = questionnaire["questions"] 452 + 453 + responses = [ 454 + {"question_id": self.questions[0]["id"], "values": [{"value": "false"}]}, 455 + {"question_id": self.questions[1]["id"], "values": [{"value": "20"}]}, 456 + ] 457 + status_code, response_data = self._submit(responses) 458 + self.assertEqual( 459 + status_code, 460 + 200, 461 + f"Valid not_equals operator submission should succeed: {response_data}", 462 + ) 463 + saved_qids = { 464 + resp["question_id"] for resp in response_data.get("responses", []) 465 + } 466 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 467 + self.assertIn( 468 + self.questions[1]["id"], 469 + saved_qids, 470 + "Q2 should be present since condition met", 471 + ) 472 + 473 + def test_not_equals_operator_invalid(self): 474 + # Q2 should be disabled if Q1 equals "true" 475 + questions = [ 476 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 477 + { 478 + "link_id": "2", 479 + "type": "integer", 480 + "text": "Q2", 481 + "enable_when": [ 482 + {"question": "1", "operator": "not_equals", "answer": "true"} 483 + ], 484 + }, 485 + ] 486 + questionnaire = self._create_questionnaire(questions) 487 + self.questionnaire_data = questionnaire 488 + self.questions = questionnaire["questions"] 489 + 490 + responses = [ 491 + {"question_id": self.questions[0]["id"], "values": [{"value": "true"}]}, 492 + {"question_id": self.questions[1]["id"], "values": [{"value": "20"}]}, 493 + ] 494 + status_code, response_data = self._submit(responses) 495 + self.assertEqual( 496 + status_code, 497 + 200, 498 + f"Submission should succeed in ignore mode: {response_data}", 499 + ) 500 + saved_qids = { 501 + resp["question_id"] for resp in response_data.get("responses", []) 502 + } 503 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 504 + self.assertNotIn( 505 + self.questions[1]["id"], 506 + saved_qids, 507 + "Q2 should be removed since condition failed", 508 + ) 509 + 510 + # --- Greater operator --- 511 + def test_greater_operator_valid(self): 512 + # Q2 enabled if Q1 (integer) is greater than 8 513 + questions = [ 514 + {"link_id": "1", "type": "integer", "text": "Q1"}, 515 + { 516 + "link_id": "2", 517 + "type": "decimal", 518 + "text": "Q2", 519 + "enable_when": [ 520 + {"question": "1", "operator": "greater", "answer": "8"} 521 + ], 522 + }, 523 + ] 524 + questionnaire = self._create_questionnaire(questions) 525 + self.questionnaire_data = questionnaire 526 + self.questions = questionnaire["questions"] 527 + 528 + responses = [ 529 + {"question_id": self.questions[0]["id"], "values": [{"value": "9"}]}, 530 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 531 + ] 532 + status_code, response_data = self._submit(responses) 533 + self.assertEqual( 534 + status_code, 535 + 200, 536 + f"Valid greater operator submission should succeed: {response_data}", 537 + ) 538 + saved_qids = { 539 + resp["question_id"] for resp in response_data.get("responses", []) 540 + } 541 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 542 + self.assertIn( 543 + self.questions[1]["id"], 544 + saved_qids, 545 + "Q2 should be present since condition met", 546 + ) 547 + 548 + def test_greater_operator_invalid(self): 549 + # Q2 should be disabled because Q1 is not greater than 8. 550 + questions = [ 551 + {"link_id": "1", "type": "integer", "text": "Q1"}, 552 + { 553 + "link_id": "2", 554 + "type": "decimal", 555 + "text": "Q2", 556 + "enable_when": [ 557 + {"question": "1", "operator": "greater", "answer": "8"} 558 + ], 559 + }, 560 + ] 561 + questionnaire = self._create_questionnaire(questions) 562 + self.questionnaire_data = questionnaire 563 + self.questions = questionnaire["questions"] 564 + 565 + responses = [ 566 + {"question_id": self.questions[0]["id"], "values": [{"value": "7"}]}, 567 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 568 + ] 569 + status_code, response_data = self._submit(responses) 570 + self.assertEqual( 571 + status_code, 572 + 200, 573 + f"Submission should succeed in ignore mode: {response_data}", 574 + ) 575 + saved_qids = { 576 + resp["question_id"] for resp in response_data.get("responses", []) 577 + } 578 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 579 + self.assertNotIn( 580 + self.questions[1]["id"], 581 + saved_qids, 582 + "Q2 should be removed since condition failed", 583 + ) 584 + 585 + # --- Less operator --- 586 + def test_less_operator_valid(self): 587 + # Q2 enabled if Q1 is less than 10 588 + questions = [ 589 + {"link_id": "1", "type": "integer", "text": "Q1"}, 590 + { 591 + "link_id": "2", 592 + "type": "decimal", 593 + "text": "Q2", 594 + "enable_when": [{"question": "1", "operator": "less", "answer": "10"}], 595 + }, 596 + ] 597 + questionnaire = self._create_questionnaire(questions) 598 + self.questionnaire_data = questionnaire 599 + self.questions = questionnaire["questions"] 600 + 601 + responses = [ 602 + {"question_id": self.questions[0]["id"], "values": [{"value": "5"}]}, 603 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 604 + ] 605 + status_code, response_data = self._submit(responses) 606 + self.assertEqual( 607 + status_code, 608 + 200, 609 + f"Valid less operator submission should succeed: {response_data}", 610 + ) 611 + saved_qids = { 612 + resp["question_id"] for resp in response_data.get("responses", []) 613 + } 614 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 615 + self.assertIn( 616 + self.questions[1]["id"], 617 + saved_qids, 618 + "Q2 should be present since condition met", 619 + ) 620 + 621 + def test_less_operator_invalid(self): 622 + # Q2 should be disabled because Q1 is not less than 10. 623 + questions = [ 624 + {"link_id": "1", "type": "integer", "text": "Q1"}, 625 + { 626 + "link_id": "2", 627 + "type": "decimal", 628 + "text": "Q2", 629 + "enable_when": [{"question": "1", "operator": "less", "answer": "10"}], 630 + }, 631 + ] 632 + questionnaire = self._create_questionnaire(questions) 633 + self.questionnaire_data = questionnaire 634 + self.questions = questionnaire["questions"] 635 + 636 + responses = [ 637 + {"question_id": self.questions[0]["id"], "values": [{"value": "15"}]}, 638 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 639 + ] 640 + status_code, response_data = self._submit(responses) 641 + self.assertEqual( 642 + status_code, 643 + 200, 644 + f"Submission should succeed in ignore mode: {response_data}", 645 + ) 646 + saved_qids = { 647 + resp["question_id"] for resp in response_data.get("responses", []) 648 + } 649 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 650 + self.assertNotIn( 651 + self.questions[1]["id"], 652 + saved_qids, 653 + "Q2 should be removed since condition failed", 654 + ) 655 + 656 + # --- Greater or Equals operator --- 657 + def test_greater_or_equals_operator_valid(self): 658 + # Q2 is enabled if Q1 is greater or equal to 10 659 + questions = [ 660 + {"link_id": "1", "type": "integer", "text": "Q1"}, 661 + { 662 + "link_id": "2", 663 + "type": "decimal", 664 + "text": "Q2", 665 + "enable_when": [ 666 + {"question": "1", "operator": "greater_or_equals", "answer": "10"} 667 + ], 668 + }, 669 + ] 670 + questionnaire = self._create_questionnaire(questions) 671 + self.questionnaire_data = questionnaire 672 + self.questions = questionnaire["questions"] 673 + 674 + responses = [ 675 + {"question_id": self.questions[0]["id"], "values": [{"value": "10"}]}, 676 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 677 + ] 678 + status_code, response_data = self._submit(responses) 679 + self.assertEqual( 680 + status_code, 681 + 200, 682 + f"Valid greater_or_equals operator submission should succeed: {response_data}", 683 + ) 684 + saved_qids = { 685 + resp["question_id"] for resp in response_data.get("responses", []) 686 + } 687 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 688 + self.assertIn( 689 + self.questions[1]["id"], 690 + saved_qids, 691 + "Q2 should be present since condition met", 692 + ) 693 + 694 + def test_greater_or_equals_operator_invalid(self): 695 + # Q2 should be disabled because Q1 is less than 10. 696 + questions = [ 697 + {"link_id": "1", "type": "integer", "text": "Q1"}, 698 + { 699 + "link_id": "2", 700 + "type": "decimal", 701 + "text": "Q2", 702 + "enable_when": [ 703 + {"question": "1", "operator": "greater_or_equals", "answer": "10"} 704 + ], 705 + }, 706 + ] 707 + questionnaire = self._create_questionnaire(questions) 708 + self.questionnaire_data = questionnaire 709 + self.questions = questionnaire["questions"] 710 + 711 + responses = [ 712 + {"question_id": self.questions[0]["id"], "values": [{"value": "9"}]}, 713 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 714 + ] 715 + status_code, response_data = self._submit(responses) 716 + self.assertEqual( 717 + status_code, 718 + 200, 719 + f"Submission should succeed in ignore mode: {response_data}", 720 + ) 721 + saved_qids = { 722 + resp["question_id"] for resp in response_data.get("responses", []) 723 + } 724 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 725 + self.assertNotIn( 726 + self.questions[1]["id"], 727 + saved_qids, 728 + "Q2 should be removed since condition failed", 729 + ) 730 + 731 + # --- Less or Equals operator --- 732 + def test_less_or_equals_operator_valid(self): 733 + # Q2 is enabled if Q1 is less or equal to 10 734 + questions = [ 735 + {"link_id": "1", "type": "integer", "text": "Q1"}, 736 + { 737 + "link_id": "2", 738 + "type": "decimal", 739 + "text": "Q2", 740 + "enable_when": [ 741 + {"question": "1", "operator": "less_or_equals", "answer": "10"} 742 + ], 743 + }, 744 + ] 745 + questionnaire = self._create_questionnaire(questions) 746 + self.questionnaire_data = questionnaire 747 + self.questions = questionnaire["questions"] 748 + 749 + responses = [ 750 + {"question_id": self.questions[0]["id"], "values": [{"value": "10"}]}, 751 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 752 + ] 753 + status_code, response_data = self._submit(responses) 754 + self.assertEqual( 755 + status_code, 756 + 200, 757 + f"Valid less_or_equals operator submission should succeed: {response_data}", 758 + ) 759 + saved_qids = { 760 + resp["question_id"] for resp in response_data.get("responses", []) 761 + } 762 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 763 + self.assertIn( 764 + self.questions[1]["id"], 765 + saved_qids, 766 + "Q2 should be present since condition met", 767 + ) 768 + 769 + def test_less_or_equals_operator_invalid(self): 770 + # Q2 should be disabled because Q1 is greater than 10. 771 + questions = [ 772 + {"link_id": "1", "type": "integer", "text": "Q1"}, 773 + { 774 + "link_id": "2", 775 + "type": "decimal", 776 + "text": "Q2", 777 + "enable_when": [ 778 + {"question": "1", "operator": "less_or_equals", "answer": "10"} 779 + ], 780 + }, 781 + ] 782 + questionnaire = self._create_questionnaire(questions) 783 + self.questionnaire_data = questionnaire 784 + self.questions = questionnaire["questions"] 785 + 786 + responses = [ 787 + {"question_id": self.questions[0]["id"], "values": [{"value": "15"}]}, 788 + {"question_id": self.questions[1]["id"], "values": [{"value": "34.5"}]}, 789 + ] 790 + status_code, response_data = self._submit(responses) 791 + self.assertEqual( 792 + status_code, 793 + 200, 794 + f"Submission should succeed in ignore mode: {response_data}", 795 + ) 796 + saved_qids = { 797 + resp["question_id"] for resp in response_data.get("responses", []) 798 + } 799 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 800 + self.assertNotIn( 801 + self.questions[1]["id"], 802 + saved_qids, 803 + "Q2 should be removed since condition failed", 804 + ) 805 + 806 + # --- Exists operator --- 807 + def test_exists_operator_valid(self): 808 + # Q2 is enabled if Q1 has a non-empty value (exists condition) 809 + questions = [ 810 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 811 + { 812 + "link_id": "2", 813 + "type": "string", 814 + "text": "Q2", 815 + "enable_when": [ 816 + {"question": "1", "operator": "exists", "answer": "true"} 817 + ], 818 + }, 819 + ] 820 + questionnaire = self._create_questionnaire(questions) 821 + self.questionnaire_data = questionnaire 822 + self.questions = questionnaire["questions"] 823 + 824 + responses = [ 825 + {"question_id": self.questions[0]["id"], "values": [{"value": "true"}]}, 826 + { 827 + "question_id": self.questions[1]["id"], 828 + "values": [{"value": "A valid answer"}], 829 + }, 830 + ] 831 + status_code, response_data = self._submit(responses) 832 + self.assertEqual( 833 + status_code, 834 + 200, 835 + f"Valid exists operator submission should succeed: {response_data}", 836 + ) 837 + saved_qids = { 838 + resp["question_id"] for resp in response_data.get("responses", []) 839 + } 840 + self.assertIn(self.questions[0]["id"], saved_qids, "Q1 should be present") 841 + self.assertIn( 842 + self.questions[1]["id"], 843 + saved_qids, 844 + "Q2 should be present since condition met", 845 + ) 846 + 847 + def test_exists_operator_invalid(self): 848 + # Q2 should be disabled because Q1's answer is empty 849 + questions = [ 850 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 851 + { 852 + "link_id": "2", 853 + "type": "string", 854 + "text": "Q2", 855 + "enable_when": [ 856 + {"question": "1", "operator": "exists", "answer": "true"} 857 + ], 858 + }, 859 + ] 860 + questionnaire = self._create_questionnaire(questions) 861 + self.questionnaire_data = questionnaire 862 + self.questions = questionnaire["questions"] 863 + 864 + responses = [ 865 + { 866 + "question_id": self.questions[1]["id"], 867 + "values": [{"value": "A valid answer"}], 868 + }, 869 + ] 870 + status_code, response_data = self._submit(responses) 871 + self.assertEqual( 872 + status_code, 873 + 200, 874 + f"Submission should succeed in ignore mode: {response_data}", 875 + ) 876 + saved_qids = { 877 + resp["question_id"] for resp in response_data.get("responses", []) 878 + } 879 + self.assertNotIn( 880 + self.questions[1]["id"], 881 + saved_qids, 882 + "Q2 should be removed since condition failed", 883 + ) 884 + 885 + # --- Nested dependency chain tests --- 886 + def test_nested_dependency_chain_valid(self): 887 + """ 888 + Test a valid nested dependency chain: 889 + - Q2 is enabled if Q3 equals "true" 890 + - Q1 is enabled if Q2 is greater than "5" 891 + Valid responses: Q3 = "true", Q2 = "10", Q1 = "34.5" 892 + """ 893 + questions = [ 894 + {"link_id": "3", "type": "boolean", "text": "Q3"}, 895 + { 896 + "link_id": "2", 897 + "type": "integer", 898 + "text": "Q2", 899 + "enable_when": [ 900 + {"question": "3", "operator": "equals", "answer": "true"} 901 + ], 902 + }, 903 + { 904 + "link_id": "1", 905 + "type": "decimal", 906 + "text": "Q1", 907 + "enable_when": [ 908 + {"question": "2", "operator": "greater", "answer": "5"} 909 + ], 910 + }, 911 + ] 912 + questionnaire = self._create_questionnaire(questions) 913 + self.questionnaire_data = questionnaire 914 + self.questions = questionnaire["questions"] 915 + 916 + responses = [] 917 + for q in questionnaire["questions"]: 918 + if q["link_id"] == "3": 919 + responses.append( 920 + {"question_id": q["id"], "values": [{"value": "true"}]} 921 + ) 922 + elif q["link_id"] == "2": 923 + responses.append({"question_id": q["id"], "values": [{"value": "10"}]}) 924 + elif q["link_id"] == "1": 925 + responses.append( 926 + {"question_id": q["id"], "values": [{"value": "34.5"}]} 927 + ) 928 + status_code, response_data = self._submit(responses) 929 + self.assertEqual( 930 + status_code, 931 + 200, 932 + f"Valid nested dependency chain should succeed: {response_data}", 933 + ) 934 + saved_qids = { 935 + resp["question_id"] for resp in response_data.get("responses", []) 936 + } 937 + # Expect all responses to be present. 938 + expected_qids = {q["id"] for q in questionnaire["questions"]} 939 + self.assertSetEqual( 940 + saved_qids, expected_qids, "All valid responses should be present" 941 + ) 942 + 943 + def test_nested_dependency_chain_invalid(self): 944 + """ 945 + Test an invalid nested dependency chain: 946 + - Q2 is enabled if Q3 equals "true" 947 + - Q1 is enabled if Q2 is greater than "5" 948 + In this case, Q3 is answered as "false", so Q2 (and thus Q1) should be disabled. 949 + Even if responses for Q2 and Q1 are provided, they should be ignored. 950 + """ 951 + questions = [ 952 + {"link_id": "3", "type": "boolean", "text": "Q3"}, 953 + { 954 + "link_id": "2", 955 + "type": "integer", 956 + "text": "Q2", 957 + "enable_when": [ 958 + {"question": "3", "operator": "equals", "answer": "true"} 959 + ], 960 + }, 961 + { 962 + "link_id": "1", 963 + "type": "decimal", 964 + "text": "Q1", 965 + "enable_when": [ 966 + {"question": "2", "operator": "greater", "answer": "5"} 967 + ], 968 + }, 969 + ] 970 + questionnaire = self._create_questionnaire(questions) 971 + self.questionnaire_data = questionnaire 972 + self.questions = questionnaire["questions"] 973 + 974 + responses = [] 975 + for q in questionnaire["questions"]: 976 + if q["link_id"] == "3": 977 + # Q3 is "false", so condition fails. 978 + responses.append( 979 + {"question_id": q["id"], "values": [{"value": "false"}]} 980 + ) 981 + elif q["link_id"] == "2": 982 + responses.append({"question_id": q["id"], "values": [{"value": "10"}]}) 983 + elif q["link_id"] == "1": 984 + responses.append( 985 + {"question_id": q["id"], "values": [{"value": "34.5"}]} 986 + ) 987 + status_code, response_data = self._submit(responses) 988 + self.assertEqual( 989 + status_code, 990 + 200, 991 + f"Submission should succeed in ignore mode: {response_data}", 992 + ) 993 + saved_qids = { 994 + resp["question_id"] for resp in response_data.get("responses", []) 995 + } 996 + # Expect only Q3 to be present because Q3's false value disables Q2 and Q1. 997 + q3_id = next(q["id"] for q in questionnaire["questions"] if q["link_id"] == "3") 998 + self.assertSetEqual( 999 + saved_qids, {q3_id}, "Only the response for Q3 should be present" 1000 + ) 1001 + 1002 + # --- Enable Behavior tests --- 1003 + def test_enable_behavior_all_valid(self): 1004 + # With default "all" behavior, Q3 is enabled if both conditions are met. 1005 + # Q3 enabled if Q1 equals "true" AND Q2 is greater than "8" 1006 + questions = [ 1007 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1008 + {"link_id": "2", "type": "integer", "text": "Q2"}, 1009 + { 1010 + "link_id": "3", 1011 + "type": "decimal", 1012 + "text": "Q3", 1013 + "enable_when": [ 1014 + {"question": "1", "operator": "equals", "answer": "true"}, 1015 + {"question": "2", "operator": "greater", "answer": "8"}, 1016 + ], 1017 + }, # Default enable_behavior is "all" 1018 + ] 1019 + questionnaire = self._create_questionnaire(questions) 1020 + self.questionnaire_data = questionnaire 1021 + self.questions = questionnaire["questions"] 1022 + 1023 + # Valid: Q1 true, Q2 = 9 (9 > 8) 1024 + responses = [ 1025 + {"question_id": self.questions[0]["id"], "values": [{"value": "true"}]}, 1026 + {"question_id": self.questions[1]["id"], "values": [{"value": "9"}]}, 1027 + {"question_id": self.questions[2]["id"], "values": [{"value": "34.5"}]}, 1028 + ] 1029 + status_code, response_data = self._submit(responses) 1030 + self.assertEqual( 1031 + status_code, 1032 + 200, 1033 + f"Valid enable_behavior (all) submission should succeed: {response_data}", 1034 + ) 1035 + saved_qids = { 1036 + resp["question_id"] for resp in response_data.get("responses", []) 1037 + } 1038 + expected_qids = {q["id"] for q in questionnaire["questions"]} 1039 + self.assertSetEqual( 1040 + saved_qids, expected_qids, "All responses should be present" 1041 + ) 1042 + 1043 + def test_enable_behavior_all_invalid(self): 1044 + # With "all" behavior, if one condition fails then Q3 is disabled. 1045 + questions = [ 1046 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1047 + {"link_id": "2", "type": "integer", "text": "Q2"}, 1048 + { 1049 + "link_id": "3", 1050 + "type": "decimal", 1051 + "text": "Q3", 1052 + "enable_when": [ 1053 + {"question": "1", "operator": "equals", "answer": "true"}, 1054 + {"question": "2", "operator": "greater", "answer": "8"}, 1055 + ], 1056 + }, 1057 + ] 1058 + questionnaire = self._create_questionnaire(questions) 1059 + self.questionnaire_data = questionnaire 1060 + self.questions = questionnaire["questions"] 1061 + 1062 + # Invalid: Q1 true but Q2 = 7 (fails condition) → Q3 disabled. 1063 + responses = [ 1064 + {"question_id": self.questions[0]["id"], "values": [{"value": "true"}]}, 1065 + {"question_id": self.questions[1]["id"], "values": [{"value": "7"}]}, 1066 + {"question_id": self.questions[2]["id"], "values": [{"value": "34.5"}]}, 1067 + ] 1068 + status_code, response_data = self._submit(responses) 1069 + self.assertEqual( 1070 + status_code, 1071 + 200, 1072 + f"Submission should succeed in ignore mode: {response_data}", 1073 + ) 1074 + saved_qids = { 1075 + resp["question_id"] for resp in response_data.get("responses", []) 1076 + } 1077 + # Expect only Q1 and Q2 are saved; Q3 is filtered out. 1078 + expected_qids = {self.questions[0]["id"], self.questions[1]["id"]} 1079 + self.assertSetEqual( 1080 + saved_qids, expected_qids, "Only Q1 and Q2 responses should be present" 1081 + ) 1082 + 1083 + def test_enable_behavior_any_valid(self): 1084 + # With "any" behavior, Q3 is enabled if at least one condition is met. 1085 + questions = [ 1086 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1087 + {"link_id": "2", "type": "integer", "text": "Q2"}, 1088 + { 1089 + "link_id": "3", 1090 + "type": "decimal", 1091 + "text": "Q3", 1092 + "enable_when": [ 1093 + {"question": "1", "operator": "equals", "answer": "true"}, 1094 + {"question": "2", "operator": "greater", "answer": "8"}, 1095 + ], 1096 + "enable_behavior": "any", 1097 + }, 1098 + ] 1099 + questionnaire = self._create_questionnaire(questions) 1100 + self.questionnaire_data = questionnaire 1101 + self.questions = questionnaire["questions"] 1102 + 1103 + # Valid: Q1 false but Q2 = 9 (one condition met) enables Q3. 1104 + responses = [ 1105 + {"question_id": self.questions[0]["id"], "values": [{"value": "false"}]}, 1106 + {"question_id": self.questions[1]["id"], "values": [{"value": "9"}]}, 1107 + {"question_id": self.questions[2]["id"], "values": [{"value": "34.5"}]}, 1108 + ] 1109 + status_code, response_data = self._submit(responses) 1110 + self.assertEqual( 1111 + status_code, 1112 + 200, 1113 + f"Valid enable_behavior (any) submission should succeed: {response_data}", 1114 + ) 1115 + saved_qids = { 1116 + resp["question_id"] for resp in response_data.get("responses", []) 1117 + } 1118 + expected_qids = {q["id"] for q in questionnaire["questions"]} 1119 + self.assertSetEqual( 1120 + saved_qids, expected_qids, "All responses should be present" 1121 + ) 1122 + 1123 + def test_enable_behavior_any_invalid(self): 1124 + # With "any" behavior, Q3 is disabled only if neither condition is met. 1125 + questions = [ 1126 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1127 + {"link_id": "2", "type": "integer", "text": "Q2"}, 1128 + { 1129 + "link_id": "3", 1130 + "type": "decimal", 1131 + "text": "Q3", 1132 + "enable_when": [ 1133 + {"question": "1", "operator": "equals", "answer": "true"}, 1134 + {"question": "2", "operator": "greater", "answer": "8"}, 1135 + ], 1136 + "enable_behavior": "any", 1137 + }, 1138 + ] 1139 + questionnaire = self._create_questionnaire(questions) 1140 + self.questionnaire_data = questionnaire 1141 + self.questions = questionnaire["questions"] 1142 + 1143 + # Invalid: Q1 false AND Q2 = 7 (neither condition met) → Q3 disabled. 1144 + responses = [ 1145 + {"question_id": self.questions[0]["id"], "values": [{"value": "false"}]}, 1146 + {"question_id": self.questions[1]["id"], "values": [{"value": "7"}]}, 1147 + {"question_id": self.questions[2]["id"], "values": [{"value": "34.5"}]}, 1148 + ] 1149 + status_code, response_data = self._submit(responses) 1150 + self.assertEqual( 1151 + status_code, 1152 + 200, 1153 + f"Submission should succeed in ignore mode: {response_data}", 1154 + ) 1155 + saved_qids = { 1156 + resp["question_id"] for resp in response_data.get("responses", []) 1157 + } 1158 + # Expect only Q1 and Q2 are saved; Q3 should be filtered out. 1159 + expected_qids = {self.questions[0]["id"], self.questions[1]["id"]} 1160 + self.assertSetEqual( 1161 + saved_qids, expected_qids, "Only Q1 and Q2 responses should be present" 1162 + ) 1163 + 1164 + def test_nested_group_enable_when_valid(self): 1165 + """ 1166 + Valid case: 1167 + - Q1 = true → enables Group G1 1168 + - Q2 (in G1) = true → enables Q3 1169 + - Q3 is submitted → all questions saved 1170 + """ 1171 + questions = [ 1172 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1173 + { 1174 + "link_id": "grp-1", 1175 + "type": "group", 1176 + "text": "Group G1", 1177 + "enable_when": [ 1178 + {"question": "1", "operator": "equals", "answer": "true"} 1179 + ], 1180 + "questions": [ 1181 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1182 + { 1183 + "link_id": "3", 1184 + "type": "decimal", 1185 + "text": "Q3", 1186 + "enable_when": [ 1187 + {"question": "2", "operator": "equals", "answer": "true"} 1188 + ], 1189 + }, 1190 + ], 1191 + }, 1192 + ] 1193 + 1194 + questionnaire = self._create_questionnaire(questions) 1195 + self.questionnaire_data = questionnaire 1196 + self.questions = [] 1197 + 1198 + for q in questionnaire["questions"]: 1199 + if q["type"] == "group": 1200 + self.questions.extend(q["questions"]) 1201 + else: 1202 + self.questions.append(q) 1203 + 1204 + q1 = next(q for q in self.questions if q["link_id"] == "1") 1205 + q2 = next(q for q in self.questions if q["link_id"] == "2") 1206 + q3 = next(q for q in self.questions if q["link_id"] == "3") 1207 + 1208 + responses = [ 1209 + {"question_id": q1["id"], "values": [{"value": "true"}]}, 1210 + {"question_id": q2["id"], "values": [{"value": "true"}]}, 1211 + {"question_id": q3["id"], "values": [{"value": "42.0"}]}, 1212 + ] 1213 + 1214 + status_code, response_data = self._submit(responses) 1215 + self.assertEqual(status_code, 200) 1216 + 1217 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1218 + self.assertSetEqual(saved_qids, {q1["id"], q2["id"], q3["id"]}) 1219 + 1220 + def test_nested_group_enable_when_invalid(self): 1221 + """ 1222 + Invalid case: 1223 + - Q1 = false → disables Group G1 1224 + - Q2 and Q3 are ignored even if submitted 1225 + """ 1226 + questions = [ 1227 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1228 + { 1229 + "link_id": "grp-1", 1230 + "type": "group", 1231 + "text": "Group G1", 1232 + "enable_when": [ 1233 + {"question": "1", "operator": "equals", "answer": "true"} 1234 + ], 1235 + "questions": [ 1236 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1237 + { 1238 + "link_id": "3", 1239 + "type": "decimal", 1240 + "text": "Q3", 1241 + "enable_when": [ 1242 + {"question": "2", "operator": "equals", "answer": "true"} 1243 + ], 1244 + }, 1245 + ], 1246 + }, 1247 + ] 1248 + 1249 + questionnaire = self._create_questionnaire(questions) 1250 + self.questionnaire_data = questionnaire 1251 + self.questions = [] 1252 + 1253 + for q in questionnaire["questions"]: 1254 + if q["type"] == "group": 1255 + self.questions.extend(q["questions"]) 1256 + else: 1257 + self.questions.append(q) 1258 + 1259 + q1 = next(q for q in self.questions if q["link_id"] == "1") 1260 + q2 = next(q for q in self.questions if q["link_id"] == "2") 1261 + q3 = next(q for q in self.questions if q["link_id"] == "3") 1262 + 1263 + responses = [ 1264 + {"question_id": q1["id"], "values": [{"value": "false"}]}, # disables group 1265 + {"question_id": q2["id"], "values": [{"value": "true"}]}, 1266 + {"question_id": q3["id"], "values": [{"value": "42.0"}]}, 1267 + ] 1268 + 1269 + status_code, response_data = self._submit(responses) 1270 + self.assertEqual(status_code, 200) 1271 + 1272 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1273 + self.assertSetEqual(saved_qids, {q1["id"]}, "Only Q1 should be saved") 1274 + 1275 + def test_nested_group_partial_enable_inner_question_invalid(self): 1276 + """ 1277 + Case: 1278 + - Q1 = true → enables Group G1 1279 + - Q2 = false → disables Q3 1280 + Expected: Q1 and Q2 are saved, Q3 is ignored 1281 + """ 1282 + questions = [ 1283 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1284 + { 1285 + "link_id": "grp-1", 1286 + "type": "group", 1287 + "text": "Group G1", 1288 + "enable_when": [ 1289 + {"question": "1", "operator": "equals", "answer": "true"} 1290 + ], 1291 + "questions": [ 1292 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1293 + { 1294 + "link_id": "3", 1295 + "type": "decimal", 1296 + "text": "Q3", 1297 + "enable_when": [ 1298 + {"question": "2", "operator": "equals", "answer": "true"} 1299 + ], 1300 + }, 1301 + ], 1302 + }, 1303 + ] 1304 + 1305 + questionnaire = self._create_questionnaire(questions) 1306 + self.questionnaire_data = questionnaire 1307 + self.questions = [] 1308 + 1309 + for q in questionnaire["questions"]: 1310 + if q["type"] == "group": 1311 + self.questions.extend(q["questions"]) 1312 + else: 1313 + self.questions.append(q) 1314 + 1315 + q1 = next(q for q in self.questions if q["link_id"] == "1") 1316 + q2 = next(q for q in self.questions if q["link_id"] == "2") 1317 + q3 = next(q for q in self.questions if q["link_id"] == "3") 1318 + 1319 + responses = [ 1320 + {"question_id": q1["id"], "values": [{"value": "true"}]}, # Enables group 1321 + { 1322 + "question_id": q2["id"], 1323 + "values": [{"value": "false"}], 1324 + }, # Q2 answered false 1325 + { 1326 + "question_id": q3["id"], 1327 + "values": [{"value": "42.0"}], 1328 + }, # Q3 condition fails 1329 + ] 1330 + 1331 + status_code, response_data = self._submit(responses) 1332 + self.assertEqual(status_code, 200) 1333 + 1334 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1335 + self.assertSetEqual( 1336 + saved_qids, 1337 + {q1["id"], q2["id"]}, 1338 + "Q3 should not be saved because Q2 was false", 1339 + ) 1340 + 1341 + def test_deep_nested_group_enable_when_valid(self): 1342 + """ 1343 + Valid case: 1344 + - Q1 = true → enables G1 1345 + - Q2 = true → enables G2 1346 + - Q3 = "yes" → enables Q4 1347 + """ 1348 + questions = [ 1349 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1350 + { 1351 + "link_id": "grp-1", 1352 + "type": "group", 1353 + "text": "Group G1", 1354 + "enable_when": [ 1355 + {"question": "1", "operator": "equals", "answer": "true"} 1356 + ], 1357 + "questions": [ 1358 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1359 + { 1360 + "link_id": "grp-2", 1361 + "type": "group", 1362 + "text": "Group G2", 1363 + "enable_when": [ 1364 + {"question": "2", "operator": "equals", "answer": "true"} 1365 + ], 1366 + "questions": [ 1367 + {"link_id": "3", "type": "string", "text": "Q3"}, 1368 + { 1369 + "link_id": "4", 1370 + "type": "decimal", 1371 + "text": "Q4", 1372 + "enable_when": [ 1373 + { 1374 + "question": "3", 1375 + "operator": "equals", 1376 + "answer": "yes", 1377 + } 1378 + ], 1379 + }, 1380 + ], 1381 + }, 1382 + ], 1383 + }, 1384 + ] 1385 + 1386 + questionnaire = self._create_questionnaire(questions) 1387 + self.questionnaire_data = questionnaire 1388 + flat_questions = [] 1389 + 1390 + for q in questionnaire["questions"]: 1391 + if q["type"] == "group": 1392 + for sub in q["questions"]: 1393 + if sub["type"] == "group": 1394 + flat_questions.extend(sub["questions"]) 1395 + else: 1396 + flat_questions.append(sub) 1397 + else: 1398 + flat_questions.append(q) 1399 + 1400 + flat_questions += [ 1401 + q for q in questionnaire["questions"] if q["type"] != "group" 1402 + ] 1403 + 1404 + q1 = next(q for q in flat_questions if q["link_id"] == "1") 1405 + q2 = next(q for q in flat_questions if q["link_id"] == "2") 1406 + q3 = next(q for q in flat_questions if q["link_id"] == "3") 1407 + q4 = next(q for q in flat_questions if q["link_id"] == "4") 1408 + 1409 + responses = [ 1410 + {"question_id": q1["id"], "values": [{"value": "true"}]}, 1411 + {"question_id": q2["id"], "values": [{"value": "true"}]}, 1412 + {"question_id": q3["id"], "values": [{"value": "yes"}]}, 1413 + {"question_id": q4["id"], "values": [{"value": "42.0"}]}, 1414 + ] 1415 + 1416 + status_code, response_data = self._submit(responses) 1417 + self.assertEqual(status_code, 200) 1418 + 1419 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1420 + self.assertSetEqual(saved_qids, {q1["id"], q2["id"], q3["id"], q4["id"]}) 1421 + 1422 + def test_deep_nested_group_enable_when_invalid(self): 1423 + """ 1424 + Invalid case: 1425 + - Q1 = true → enables G1 1426 + - Q2 = false → disables G2 and everything inside 1427 + - Q3 and Q4 should be ignored even if submitted 1428 + """ 1429 + questions = [ 1430 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1431 + { 1432 + "link_id": "grp-1", 1433 + "type": "group", 1434 + "text": "Group G1", 1435 + "enable_when": [ 1436 + {"question": "1", "operator": "equals", "answer": "true"} 1437 + ], 1438 + "questions": [ 1439 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1440 + { 1441 + "link_id": "grp-2", 1442 + "type": "group", 1443 + "text": "Group G2", 1444 + "enable_when": [ 1445 + {"question": "2", "operator": "equals", "answer": "true"} 1446 + ], 1447 + "questions": [ 1448 + {"link_id": "3", "type": "string", "text": "Q3"}, 1449 + { 1450 + "link_id": "4", 1451 + "type": "decimal", 1452 + "text": "Q4", 1453 + "enable_when": [ 1454 + { 1455 + "question": "3", 1456 + "operator": "equals", 1457 + "answer": "yes", 1458 + } 1459 + ], 1460 + }, 1461 + ], 1462 + }, 1463 + ], 1464 + }, 1465 + ] 1466 + 1467 + questionnaire = self._create_questionnaire(questions) 1468 + self.questionnaire_data = questionnaire 1469 + flat_questions = [] 1470 + 1471 + for q in questionnaire["questions"]: 1472 + if q["type"] == "group": 1473 + for sub in q["questions"]: 1474 + if sub["type"] == "group": 1475 + flat_questions.extend(sub["questions"]) 1476 + else: 1477 + flat_questions.append(sub) 1478 + else: 1479 + flat_questions.append(q) 1480 + 1481 + flat_questions += [ 1482 + q for q in questionnaire["questions"] if q["type"] != "group" 1483 + ] 1484 + 1485 + q1 = next(q for q in flat_questions if q["link_id"] == "1") 1486 + q2 = next(q for q in flat_questions if q["link_id"] == "2") 1487 + q3 = next(q for q in flat_questions if q["link_id"] == "3") 1488 + q4 = next(q for q in flat_questions if q["link_id"] == "4") 1489 + 1490 + responses = [ 1491 + {"question_id": q1["id"], "values": [{"value": "true"}]}, 1492 + {"question_id": q2["id"], "values": [{"value": "false"}]}, # disables G2 1493 + {"question_id": q3["id"], "values": [{"value": "yes"}]}, 1494 + {"question_id": q4["id"], "values": [{"value": "42.0"}]}, 1495 + ] 1496 + 1497 + status_code, response_data = self._submit(responses) 1498 + self.assertEqual(status_code, 200) 1499 + 1500 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1501 + self.assertSetEqual( 1502 + saved_qids, {q1["id"], q2["id"]}, "Q3 and Q4 should not be saved" 1503 + ) 1504 + 1505 + def test_remove_nested_groups_recursively_when_parent_group_disabled(self): 1506 + """ 1507 + Q1 = false → disables G1 1508 + G1 contains nested group G2 1509 + G2 contains Q2 and Q3 1510 + We submit answers for Q2 and Q3 → all must be removed 1511 + """ 1512 + questions = [ 1513 + {"link_id": "1", "type": "boolean", "text": "Q1"}, 1514 + { 1515 + "link_id": "grp-1", 1516 + "type": "group", 1517 + "text": "Group G1", 1518 + "enable_when": [ 1519 + {"question": "1", "operator": "equals", "answer": "true"} 1520 + ], 1521 + "questions": [ 1522 + { 1523 + "link_id": "grp-2", 1524 + "type": "group", 1525 + "text": "Group G2", 1526 + "questions": [ 1527 + {"link_id": "2", "type": "boolean", "text": "Q2"}, 1528 + {"link_id": "3", "type": "decimal", "text": "Q3"}, 1529 + ], 1530 + } 1531 + ], 1532 + }, 1533 + ] 1534 + 1535 + questionnaire = self._create_questionnaire(questions) 1536 + self.questionnaire_data = questionnaire 1537 + flat = [] 1538 + 1539 + for q in questionnaire["questions"]: 1540 + if q["type"] == "group": 1541 + for sub in q["questions"]: 1542 + if sub["type"] == "group": 1543 + flat.extend(sub["questions"]) 1544 + else: 1545 + flat.append(sub) 1546 + else: 1547 + flat.append(q) 1548 + 1549 + flat += [q for q in questionnaire["questions"] if q["type"] != "group"] 1550 + 1551 + q1 = next(q for q in flat if q["link_id"] == "1") 1552 + q2 = next(q for q in flat if q["link_id"] == "2") 1553 + q3 = next(q for q in flat if q["link_id"] == "3") 1554 + 1555 + responses = [ 1556 + {"question_id": q1["id"], "values": [{"value": "false"}]}, # disables G1 1557 + {"question_id": q2["id"], "values": [{"value": "true"}]}, 1558 + {"question_id": q3["id"], "values": [{"value": "45.6"}]}, 1559 + ] 1560 + 1561 + status_code, response_data = self._submit(responses) 1562 + self.assertEqual(status_code, 200) 1563 + 1564 + saved_qids = {resp["question_id"] for resp in response_data["responses"]} 1565 + self.assertSetEqual( 1566 + saved_qids, {q1["id"]}, "Q2 and Q3 inside nested group should be removed" 1567 + ) 257 1568 258 1569 259 1570 class RequiredFieldValidationTests(QuestionnaireTestBase): ··· 278 1589 "status": "active", 279 1590 "subject_type": "patient", 280 1591 "organizations": [str(self.organization.external_id)], 1592 + "tags": [self.create_questionnaire_tag().external_id], 281 1593 "questions": [ 282 1594 { 283 1595 "link_id": "1", ··· 344 1656 "status": "active", 345 1657 "subject_type": "patient", 346 1658 "organizations": [str(self.organization.external_id)], 1659 + "tags": [self.create_questionnaire_tag().external_id], 347 1660 "questions": [ 348 1661 { 349 1662 "styling_metadata": {"layout": "vertical"}, ··· 431 1744 "status": "active", 432 1745 "subject_type": "patient", 433 1746 "organizations": [str(self.organization.external_id)], 1747 + "tags": [self.create_questionnaire_tag().external_id], 434 1748 "questions": [ 435 1749 { 436 1750 "link_id": "1", ··· 610 1924 self.client.force_authenticate(user=self.super_user) 611 1925 612 1926 updated_data = self._create_questionnaire() 1927 + updated_data["description"] = "" 613 1928 updated_data["questions"] = [ 614 1929 { 615 1930 "link_id": "1", ··· 628 1943 self.assertEqual( 629 1944 response.json()["questions"][0]["text"], "Modified question text" 630 1945 ) 1946 + self.assertEqual(response.json()["description"], "") 631 1947 632 1948 # def test_active_questionnaire_modification_prevented(self): 633 1949 # """
+2 -13
care/emr/tests/test_symptom_api.py
··· 1 1 import datetime 2 2 import uuid 3 3 from secrets import choice 4 - from unittest.mock import patch 5 4 6 5 from django.forms import model_to_dict 7 6 from django.urls import reverse ··· 24 23 class TestSymptomViewSet(CareAPITestBase): 25 24 def setUp(self): 26 25 super().setUp() 26 + self.super_user = self.create_super_user() 27 27 self.user = self.create_user() 28 28 self.facility = self.create_facility(user=self.user) 29 29 self.organization = self.create_facility_organization(facility=self.facility) ··· 38 38 "system": "http://test_system.care/test", 39 39 "code": "123", 40 40 } 41 - # Mocking validate_valueset 42 - self.patcher = patch( 43 - "care.emr.resources.condition.spec.validate_valueset", 44 - return_value=self.valid_code, 45 - ) 46 - self.mock_validate_valueset = self.patcher.start() 47 - 48 - def tearDown(self): 49 - self.patcher.stop() 50 41 51 42 def _get_symptom_url(self, symptom_id): 52 43 """Helper to get the detail URL for a specific symptom.""" ··· 319 310 ) 320 311 symptom_data_dict = self.generate_data_for_symptom( 321 312 encounter, 322 - onset={ 323 - "onset_datetime": care_now() + datetime.timedelta(seconds=20), 324 - }, 313 + onset={"onset_datetime": care_now() + datetime.timedelta(days=1)}, 325 314 ) 326 315 327 316 response = self.client.post(self.base_url, symptom_data_dict, format="json")
+2 -2
care/emr/utils/file_manager.py
··· 3 3 from care.utils.csp.config import get_client_config 4 4 5 5 6 - class FileManger: 6 + class FileManager: 7 7 """ 8 8 A utility class to manage all file management related operations 9 9 """ 10 10 11 11 12 - class S3FilesManager(FileManger): 12 + class S3FilesManager(FileManager): 13 13 bucket_type = None 14 14 15 15 def __init__(self, bucket_type):
+2 -2
care/emr/utils/mfa.py
··· 34 34 35 35 def check_mfa_ip_rate_limit(request): 36 36 """Check IP-based rate limit""" 37 - if ratelimit(request, "mfa-login", ["ip"], "10/5m"): 37 + if ratelimit(request, "mfa-login", ["ip"]): 38 38 raise Throttled(detail="Too Many Requests. Please try again later.") 39 39 40 40 41 41 def check_mfa_user_rate_limit(request, user_id: str): 42 42 """Check user-based rate limit""" 43 - if ratelimit(request, "mfa-login", [user_id], "3/5m"): 43 + if ratelimit(request, "mfa-login", [user_id]): 44 44 raise Throttled(detail="Too Many Requests. Please try again later.") 45 45 46 46
+35
care/emr/utils/send_password_reset_mail.py
··· 1 + from django.conf import settings 2 + from django.contrib.auth import get_user_model 3 + from django.core.mail import EmailMessage 4 + from django.template.loader import render_to_string 5 + from django_rest_passwordreset.models import ResetPasswordToken 6 + 7 + User = get_user_model() 8 + 9 + 10 + def send_password_creation_email(instance: User, fail_silently=False): 11 + if instance.password_reset_tokens.all().count() > 0: 12 + # yes, already has a token, re-use this token 13 + reset_password_token = instance.password_reset_tokens.all()[0] 14 + else: 15 + # no token exists, generate a new token 16 + reset_password_token = ResetPasswordToken.objects.create( 17 + user=instance, 18 + ) 19 + context = { 20 + "current_user": reset_password_token.user, 21 + "username": reset_password_token.user.username, 22 + "email": reset_password_token.user.email, 23 + "create_password_url": f"{settings.CURRENT_DOMAIN}/password_reset/{reset_password_token.key}", 24 + } 25 + email_html_message = render_to_string( 26 + "email/user_password_creation_email.html", context 27 + ) 28 + msg = EmailMessage( 29 + "Set Up Your Password for Care", 30 + email_html_message, 31 + settings.DEFAULT_FROM_EMAIL, 32 + (reset_password_token.user.email,), 33 + ) 34 + msg.content_subtype = "html" 35 + msg.send(fail_silently=fail_silently)
+23
care/emr/utils/valueset_coding_type.py
··· 1 + from pydantic_core import CoreSchema, core_schema 2 + 3 + from care.emr.registries.care_valueset.care_valueset import validate_valueset 4 + from care.emr.resources.common import Coding 5 + 6 + 7 + class ValueSetBoundCoding: 8 + @classmethod 9 + def __class_getitem__(cls, slug: str) -> type: 10 + class BoundCoding(Coding): 11 + @classmethod 12 + def __get_pydantic_core_schema__(cls, source_type, handler) -> CoreSchema: 13 + return core_schema.no_info_after_validator_function( 14 + function=cls.validate_input, schema=handler(source_type), ref=slug 15 + ) 16 + 17 + @classmethod 18 + def validate_input(cls, v): 19 + if isinstance(v, dict): 20 + v = Coding.model_validate(v) 21 + return validate_valueset("code", slug, v) 22 + 23 + return BoundCoding
+19
care/security/migrations/0003_migrate_default_role_change.py
··· 1 + 2 + from django.db import migrations, models 3 + 4 + def update_default_role(apps, schema_editor): 5 + RoleModel = apps.get_model("security", "RoleModel") 6 + RoleModel.objects.filter(name="Geo Admin").update(name="Administrator") 7 + 8 + class Migration(migrations.Migration): 9 + 10 + dependencies = [ 11 + ('security', '0002_remove_rolemodel_unique_order_and_more'), 12 + ] 13 + 14 + operations = [ 15 + migrations.RunPython( 16 + code=update_default_role, 17 + reverse_code=migrations.RunPython.noop, 18 + ), 19 + ]
+2
care/security/permissions/base.py
··· 1 + from care.security.permissions.device import DevicePermissions 1 2 from care.security.permissions.encounter import EncounterPermissions 2 3 from care.security.permissions.facility import FacilityPermissions 3 4 from care.security.permissions.facility_organization import ( ··· 38 39 UserSchedulePermissions, 39 40 FacilityLocationPermissions, 40 41 ObservationDefinitionPermissions, 42 + DevicePermissions 41 43 ] 42 44 43 45 cache = {}
+2 -2
care/security/permissions/encounter.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 15 15 ADMIN_ROLE, 16 16 DOCTOR_ROLE, 17 17 NURSE_ROLE, 18 - GEO_ADMIN, 18 + ADMINISTRATOR, 19 19 STAFF_ROLE, 20 20 FACILITY_ADMIN_ROLE, 21 21 VOLUNTEER_ROLE,
+4 -4
care/security/permissions/facility.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 17 17 "Can Create on Facility", 18 18 "Something Here", 19 19 PermissionContext.FACILITY, 20 - [GEO_ADMIN, ADMIN_ROLE], 20 + [ADMINISTRATOR, ADMIN_ROLE, FACILITY_ADMIN_ROLE], 21 21 ) 22 22 can_read_facility = Permission( 23 23 "Can Read on Facility", ··· 25 25 PermissionContext.FACILITY, 26 26 [ 27 27 FACILITY_ADMIN_ROLE, 28 - GEO_ADMIN, 28 + ADMINISTRATOR, 29 29 ADMIN_ROLE, 30 30 STAFF_ROLE, 31 31 DOCTOR_ROLE, ··· 37 37 "Can Update on Facility", 38 38 "Something Here", 39 39 PermissionContext.FACILITY, 40 - [FACILITY_ADMIN_ROLE, GEO_ADMIN, ADMIN_ROLE, STAFF_ROLE], 40 + [FACILITY_ADMIN_ROLE, ADMINISTRATOR, ADMIN_ROLE, STAFF_ROLE], 41 41 )
+5 -5
care/security/permissions/facility_organization.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 34 34 ADMIN_ROLE, 35 35 STAFF_ROLE, 36 36 DOCTOR_ROLE, 37 - GEO_ADMIN, 37 + ADMINISTRATOR, 38 38 NURSE_ROLE, 39 39 VOLUNTEER_ROLE, 40 40 ], ··· 49 49 "Can Manage Facility Organizations", 50 50 "This includes changing names, descriptions, metadata, etc..", 51 51 PermissionContext.FACILITY_ORGANIZATION, 52 - [FACILITY_ADMIN_ROLE], 52 + [FACILITY_ADMIN_ROLE, ADMINISTRATOR], 53 53 ) 54 54 can_list_facility_organization_users = Permission( 55 55 "Can List Users in a Facility Organizations", ··· 60 60 ADMIN_ROLE, 61 61 STAFF_ROLE, 62 62 DOCTOR_ROLE, 63 - GEO_ADMIN, 63 + ADMINISTRATOR, 64 64 NURSE_ROLE, 65 65 ], 66 66 ) ··· 68 68 "Can Manage Users in an Organizations", 69 69 "", 70 70 PermissionContext.FACILITY_ORGANIZATION, 71 - [FACILITY_ADMIN_ROLE], 71 + [FACILITY_ADMIN_ROLE, ADMINISTRATOR], 72 72 )
+2 -2
care/security/permissions/location.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 ) ··· 20 20 ADMIN_ROLE, 21 21 DOCTOR_ROLE, 22 22 FACILITY_ADMIN_ROLE, 23 - GEO_ADMIN, 23 + ADMINISTRATOR, 24 24 NURSE_ROLE, 25 25 STAFF_ROLE, 26 26 ],
+4 -4
care/security/permissions/organization.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 22 22 ADMIN_ROLE, 23 23 STAFF_ROLE, 24 24 DOCTOR_ROLE, 25 - GEO_ADMIN, 25 + ADMINISTRATOR, 26 26 NURSE_ROLE, 27 27 VOLUNTEER_ROLE, 28 28 ], ··· 49 49 "Can Manage Users in an Organizations", 50 50 "", 51 51 PermissionContext.ORGANIZATION, 52 - [ADMIN_ROLE, GEO_ADMIN], 52 + [ADMIN_ROLE, ADMINISTRATOR, FACILITY_ADMIN_ROLE], 53 53 ) 54 54 can_list_organization_users = Permission( 55 55 "Can List Users in an Organizations", ··· 60 60 ADMIN_ROLE, 61 61 STAFF_ROLE, 62 62 DOCTOR_ROLE, 63 - GEO_ADMIN, 63 + ADMINISTRATOR, 64 64 NURSE_ROLE, 65 65 VOLUNTEER_ROLE, 66 66 ],
+7 -5
care/security/permissions/patient.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 21 21 STAFF_ROLE, 22 22 DOCTOR_ROLE, 23 23 NURSE_ROLE, 24 - GEO_ADMIN, 24 + ADMINISTRATOR, 25 25 ADMIN_ROLE, 26 26 FACILITY_ADMIN_ROLE, 27 27 ], ··· 34 34 STAFF_ROLE, 35 35 DOCTOR_ROLE, 36 36 NURSE_ROLE, 37 - GEO_ADMIN, 37 + ADMINISTRATOR, 38 38 ADMIN_ROLE, 39 39 FACILITY_ADMIN_ROLE, 40 40 ], ··· 47 47 STAFF_ROLE, 48 48 DOCTOR_ROLE, 49 49 NURSE_ROLE, 50 - GEO_ADMIN, 50 + ADMINISTRATOR, 51 51 ADMIN_ROLE, 52 52 FACILITY_ADMIN_ROLE, 53 53 VOLUNTEER_ROLE, ··· 60 60 [STAFF_ROLE, DOCTOR_ROLE, NURSE_ROLE, ADMIN_ROLE, FACILITY_ADMIN_ROLE], 61 61 ) # To be split into finer grain permissions 62 62 can_view_questionnaire_responses = Permission( 63 - "Can view clinical data about patients", 63 + "Can view questionnaire responses on patient", 64 64 "", 65 65 PermissionContext.PATIENT, 66 66 [ ··· 70 70 NURSE_ROLE, 71 71 ADMIN_ROLE, 72 72 FACILITY_ADMIN_ROLE, 73 + ADMINISTRATOR, 73 74 ], 74 75 ) 75 76 can_submit_patient_questionnaire = Permission( ··· 83 84 NURSE_ROLE, 84 85 ADMIN_ROLE, 85 86 FACILITY_ADMIN_ROLE, 87 + ADMINISTRATOR, 86 88 ], 87 89 )
+3 -3
care/security/permissions/questionnaire.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 33 33 ADMIN_ROLE, 34 34 DOCTOR_ROLE, 35 35 NURSE_ROLE, 36 - GEO_ADMIN, 36 + ADMINISTRATOR, 37 37 STAFF_ROLE, 38 38 FACILITY_ADMIN_ROLE, 39 39 VOLUNTEER_ROLE, ··· 47 47 ADMIN_ROLE, 48 48 DOCTOR_ROLE, 49 49 NURSE_ROLE, 50 - GEO_ADMIN, 50 + ADMINISTRATOR, 51 51 STAFF_ROLE, 52 52 FACILITY_ADMIN_ROLE, 53 53 VOLUNTEER_ROLE,
+3 -3
care/security/permissions/user.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 - GEO_ADMIN, 9 9 NURSE_ROLE, 10 10 STAFF_ROLE, 11 11 VOLUNTEER_ROLE, ··· 17 17 "Can create User in care", 18 18 "", 19 19 PermissionContext.FACILITY, 20 - [ADMIN_ROLE, FACILITY_ADMIN_ROLE, GEO_ADMIN], 20 + [ADMIN_ROLE, FACILITY_ADMIN_ROLE, ADMINISTRATOR], 21 21 ) 22 22 can_list_user = Permission( 23 23 "Can list Users in Care", ··· 27 27 ADMIN_ROLE, 28 28 DOCTOR_ROLE, 29 29 NURSE_ROLE, 30 - GEO_ADMIN, 30 + ADMINISTRATOR, 31 31 STAFF_ROLE, 32 32 FACILITY_ADMIN_ROLE, 33 33 VOLUNTEER_ROLE,
+32 -4
care/security/permissions/user_schedule.py
··· 3 3 from care.security.permissions.constants import Permission, PermissionContext 4 4 from care.security.roles.role import ( 5 5 ADMIN_ROLE, 6 + ADMINISTRATOR, 6 7 DOCTOR_ROLE, 7 8 FACILITY_ADMIN_ROLE, 8 9 NURSE_ROLE, ··· 21 22 "Can list user schedule on Facility", 22 23 "", 23 24 PermissionContext.FACILITY, 24 - [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], 25 + [ 26 + ADMIN_ROLE, 27 + STAFF_ROLE, 28 + FACILITY_ADMIN_ROLE, 29 + DOCTOR_ROLE, 30 + NURSE_ROLE, 31 + ADMINISTRATOR, 32 + ], 25 33 ) 26 34 can_list_user_booking = Permission( 27 35 "Can list bookings on Facility", 28 36 "", 29 37 PermissionContext.FACILITY, 30 - [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], 38 + [ 39 + ADMIN_ROLE, 40 + STAFF_ROLE, 41 + FACILITY_ADMIN_ROLE, 42 + DOCTOR_ROLE, 43 + NURSE_ROLE, 44 + ADMINISTRATOR, 45 + ], 31 46 ) 32 47 can_write_user_booking = Permission( 33 48 "Can update bookings on user", 34 49 "", 35 50 PermissionContext.FACILITY, 36 - [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], 51 + [ 52 + ADMIN_ROLE, 53 + STAFF_ROLE, 54 + FACILITY_ADMIN_ROLE, 55 + DOCTOR_ROLE, 56 + NURSE_ROLE, 57 + ], 37 58 ) 38 59 can_create_appointment = Permission( 39 60 "Can create appointment on facility", 40 61 "", 41 62 PermissionContext.FACILITY, 42 - [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], 63 + [ 64 + ADMIN_ROLE, 65 + STAFF_ROLE, 66 + FACILITY_ADMIN_ROLE, 67 + DOCTOR_ROLE, 68 + NURSE_ROLE, 69 + ADMINISTRATOR, 70 + ], 43 71 )
+12 -12
care/security/roles/role.py
··· 27 27 name="Staff", 28 28 description="Staff at some facility", 29 29 ) 30 - GEO_ADMIN = Role( 31 - name="Geo Admin", 32 - description="Administrator restricted with geographical boundaries", 30 + ADMINISTRATOR = Role( 31 + name="Administrator", 32 + description="Administrator at a given boundary", 33 33 ) 34 34 FACILITY_ADMIN_ROLE = Role( 35 35 name="Facility Admin", ··· 48 48 DOCTOR_ROLE, 49 49 STAFF_ROLE, 50 50 NURSE_ROLE, 51 - GEO_ADMIN, 51 + ADMINISTRATOR, 52 52 FACILITY_ADMIN_ROLE, 53 53 ADMIN_ROLE, 54 54 VOLUNTEER_ROLE, ··· 70 70 "Nurse": NURSE_ROLE, 71 71 "Doctor": DOCTOR_ROLE, 72 72 "Reserved": DOCTOR_ROLE, 73 - "WardAdmin": GEO_ADMIN, 74 - "LocalBodyAdmin": GEO_ADMIN, 75 - "DistrictLabAdmin": GEO_ADMIN, 76 - "DistrictReadOnlyAdmin": GEO_ADMIN, 77 - "DistrictAdmin": GEO_ADMIN, 78 - "StateLabAdmin": GEO_ADMIN, 79 - "StateReadOnlyAdmin": GEO_ADMIN, 80 - "StateAdmin": GEO_ADMIN, 73 + "WardAdmin": ADMINISTRATOR, 74 + "LocalBodyAdmin": ADMINISTRATOR, 75 + "DistrictLabAdmin": ADMINISTRATOR, 76 + "DistrictReadOnlyAdmin": ADMINISTRATOR, 77 + "DistrictAdmin": ADMINISTRATOR, 78 + "StateLabAdmin": ADMINISTRATOR, 79 + "StateReadOnlyAdmin": ADMINISTRATOR, 80 + "StateAdmin": ADMINISTRATOR, 81 81 } 82 82 return mapping[old_role] 83 83
+30
care/templates/email/user_password_creation_email.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> 6 + <title>Set Up Your Password</title> 7 + </head> 8 + <body> 9 + <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"> 10 + <tr> 11 + <td>&nbsp;</td> 12 + <td class="container"> 13 + <div class="content"> 14 + <p>Hi <strong>{{username}}</strong>,</p> 15 + <p>Welcome to Open Healthcare Network! Your username is: <strong>{{username}}</strong></p> 16 + <p>To secure your account, please create a password by clicking the link below:</p> 17 + <p> 18 + <a href="{{create_password_url}}" style="background-color: #007bff; color: #ffffff; padding: 10px 15px; text-decoration: none; border-radius: 5px;">Create Password</a> 19 + </p> 20 + <p>Or copy and paste the following link into your browser:</p> 21 + <p>{{create_password_url}}</p> 22 + <p><strong>Note:</strong> This link will expire in 24 hours.</p> 23 + <p>Thank you,<br>Open Healthcare Network Team</p> 24 + </div> 25 + </td> 26 + <td>&nbsp;</td> 27 + </tr> 28 + </table> 29 + </body> 30 + </html>
+1 -1
care/templates/email/user_reset_password.html
··· 17 17 <a href="{{reset_password_url}}" > Click Here</a> 18 18 or copy paste the following link on your address bar 19 19 {{reset_password_url}} 20 - ** This link will expire in 1 Hour 20 + ** This link will expire in 24 Hours 21 21 </div> 22 22 </td> 23 23 <td>&nbsp;</td>
+18 -1
care/templates/reports/patient_discharge_summary_pdf_template.typ
··· 190 190 {% endif %} 191 191 192 192 193 + #align(right)[#text(12pt,fill: mygray)[*Care Team* :] #text(10pt,weight: "bold")[{% if care_team %} 194 + {% for member in care_team %} 195 + {{ member }} 196 + {% endfor %} 197 + {% else %} 198 + - 199 + {% endif %}]] 200 + 201 + 193 202 {% if files %} 194 203 #align(left, text(18pt,)[== Annexes]) 195 204 #align(left, text(14pt,weight: "bold",)[=== Uploaded Files:]) ··· 206 215 {% endfor %} 207 216 ) 208 217 {% endif %} 209 - // #line(length: 100%, stroke: mygray) 218 + 219 + #text("") 220 + #line(length: 100%, stroke: mygray) 221 + #text("") 222 + {% if discharge_summary_advice %} 223 + 224 + = Discharge Summary Advice 225 + #text()[```{{ discharge_summary_advice }}```] 226 + {% endif %}
+3 -3
care/users/api/serializers/user.py
··· 322 322 def get_facilities(self, user): 323 323 unique_ids = [] 324 324 data = [] 325 - for obj in FacilityOrganizationUser.objects.filter(user=user).select_related( 326 - "organization__facility" 327 - ): 325 + for obj in FacilityOrganizationUser.objects.filter( 326 + user=user, organization__facility__deleted=False 327 + ).select_related("organization__facility"): 328 328 if obj.organization.facility.id not in unique_ids: 329 329 unique_ids.append(obj.organization.facility.id) 330 330 data.append(
+312 -336
care/users/api/viewsets/users.py
··· 1 1 from datetime import timedelta 2 2 3 3 from django.core.cache import cache 4 - from django.db.models import F, Q, Subquery 5 - from django.http import Http404 6 4 from django.utils import timezone 7 - from django.utils.decorators import method_decorator 8 5 from django_filters import rest_framework as filters 9 - from drf_spectacular.utils import extend_schema 10 - from dry_rest_permissions.generics import DRYPermissions 11 - from rest_framework import filters as drf_filters 12 - from rest_framework import filters as rest_framework_filters 13 - from rest_framework import mixins, status 14 - from rest_framework.decorators import action, parser_classes 15 - from rest_framework.generics import get_object_or_404 16 - from rest_framework.parsers import MultiPartParser 17 - from rest_framework.permissions import IsAuthenticated 18 - from rest_framework.response import Response 19 - from rest_framework.serializers import ValidationError 20 - from rest_framework.viewsets import GenericViewSet 21 6 22 - from care.facility.api.serializers.facility import FacilityBasicInfoSerializer 23 - from care.facility.models.facility import Facility, FacilityUser 24 - from care.users.api.serializers.user import ( 25 - UserCreateSerializer, 26 - UserImageUploadSerializer, 27 - UserListSerializer, 28 - UserSerializer, 29 - ) 30 7 from care.users.models import User 31 - from care.utils.cache.cache_allowed_facilities import get_accessible_facilities 32 - from care.utils.file_uploads.cover_image import delete_cover_image 33 8 34 9 35 10 def remove_facility_user_cache(user_id): ··· 91 66 return queryset.filter(last_login__gte=date) 92 67 93 68 94 - class UserViewSet( 95 - mixins.RetrieveModelMixin, 96 - mixins.UpdateModelMixin, 97 - mixins.ListModelMixin, 98 - mixins.DestroyModelMixin, 99 - GenericViewSet, 100 - ): 101 - """ 102 - A viewset for viewing and manipulating user instances. 103 - """ 104 - 105 - queryset = ( 106 - User.objects.filter(is_active=True, is_superuser=False) 107 - .select_related("local_body", "district", "state", "home_facility") 108 - .order_by(F("last_login").desc(nulls_last=True)) 109 - .annotate( 110 - created_by_user=F("created_by__username"), 111 - ) 112 - ) 113 - queryset = queryset.filter(Q(asset__isnull=True)) 114 - lookup_field = "username" 115 - lookup_value_regex = "[^/]+" 116 - permission_classes = (IsAuthenticated, DRYPermissions) 117 - filter_backends = ( 118 - filters.DjangoFilterBackend, 119 - rest_framework_filters.OrderingFilter, 120 - drf_filters.SearchFilter, 121 - ) 122 - filterset_class = UserFilterSet 123 - ordering_fields = ["id", "date_joined", "last_login"] 124 - search_fields = ["first_name", "last_name", "username"] 125 - 126 - def get_queryset(self): 127 - if self.request.user.is_superuser: 128 - return super().get_queryset() 129 - query = Q(id=self.request.user.id) 130 - if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateReadOnlyAdmin"]: 131 - query |= Q( 132 - state=self.request.user.state, 133 - user_type__lte=User.TYPE_VALUE_MAP["StateAdmin"], 134 - is_superuser=False, 135 - ) 136 - elif ( 137 - self.request.user.user_type >= User.TYPE_VALUE_MAP["DistrictReadOnlyAdmin"] 138 - ): 139 - query |= Q( 140 - district=self.request.user.district, 141 - user_type__lte=User.TYPE_VALUE_MAP["DistrictAdmin"], 142 - is_superuser=False, 143 - ) 144 - else: 145 - query |= Q( 146 - id__in=Subquery( 147 - FacilityUser.objects.filter( 148 - facility_id__in=get_accessible_facilities(self.request.user) 149 - ).values("user_id") 150 - ), 151 - user_type__lt=User.TYPE_VALUE_MAP["DistrictAdmin"], 152 - is_superuser=False, 153 - ) 154 - return self.queryset.filter(query) 155 - 156 - def get_object(self) -> User: 157 - try: 158 - if self.action == "retrieve": 159 - username = self.kwargs.get("username") 160 - return get_object_or_404(User, username=username) 161 - return super().get_object() 162 - except Http404 as e: 163 - error = "User not found" 164 - raise Http404(error) from e 165 - 166 - def get_serializer_class(self): 167 - if self.action == "list": 168 - return UserListSerializer 169 - if self.action == "add_user": 170 - return UserCreateSerializer 171 - if self.action == "profile_picture": 172 - return UserImageUploadSerializer 173 - return UserSerializer 174 - 175 - @extend_schema(tags=["users"]) 176 - @action(detail=False, methods=["GET"]) 177 - def getcurrentuser(self, request): 178 - return Response( 179 - status=status.HTTP_200_OK, 180 - data=UserSerializer(request.user, context={"request": request}).data, 181 - ) 182 - 183 - def destroy(self, request, *args, **kwargs): 184 - queryset = self.get_queryset() 185 - username = kwargs["username"] 186 - if request.user.is_superuser: 187 - pass 188 - elif request.user.user_type >= User.TYPE_VALUE_MAP["StateAdmin"]: 189 - queryset = queryset.filter( 190 - state=request.user.state, 191 - user_type__lt=User.TYPE_VALUE_MAP["StateAdmin"], 192 - is_superuser=False, 193 - ) 194 - elif request.user.user_type == User.TYPE_VALUE_MAP["DistrictAdmin"]: 195 - queryset = queryset.filter( 196 - district=request.user.district, 197 - user_type__lt=User.TYPE_VALUE_MAP["DistrictAdmin"], 198 - is_superuser=False, 199 - ) 200 - else: 201 - return Response( 202 - status=status.HTTP_403_FORBIDDEN, data={"permission": "Denied"} 203 - ) 204 - user = get_object_or_404(queryset.filter(username=username)) 205 - user.is_active = False 206 - user.save(update_fields=["is_active"]) 207 - return Response(status=status.HTTP_204_NO_CONTENT) 208 - 209 - @extend_schema(tags=["users"]) 210 - @action(detail=False, methods=["POST"]) 211 - def add_user(self, request, *args, **kwargs): 212 - password = request.data.pop( 213 - "password", User.objects.make_random_password(length=8) 214 - ) 215 - serializer = UserCreateSerializer( 216 - data={**request.data, "password": password}, 217 - context={"created_by": request.user}, 218 - ) 219 - serializer.is_valid(raise_exception=True) 220 - serializer.save() 221 - return Response(status=status.HTTP_201_CREATED) 222 - 223 - def has_facility_permission(self, user, facility): 224 - return ( 225 - user.is_superuser 226 - or (facility and user in facility.users.all()) 227 - or ( 228 - user.user_type >= User.TYPE_VALUE_MAP["LocalBodyAdmin"] 229 - and (facility and user.local_body == facility.local_body) 230 - ) 231 - or ( 232 - user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] 233 - and (facility and user.district == facility.district) 234 - ) 235 - or ( 236 - user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] 237 - and (facility and user.state == facility.state) 238 - ) 239 - ) 240 - 241 - def has_user_type_permission_elevation(self, init_user, dest_user): 242 - return init_user.user_type >= dest_user.user_type 243 - 244 - def check_facility_user_exists(self, user, facility): 245 - return FacilityUser.objects.filter(facility=facility, user=user).exists() 246 - 247 - @extend_schema(tags=["users"]) 248 - @action(detail=True, methods=["GET"], permission_classes=[IsAuthenticated]) 249 - def get_facilities(self, request, *args, **kwargs): 250 - user = self.get_object() 251 - queryset = Facility.objects.filter(users=user).select_related( 252 - "local_body", "district", "state", "ward" 253 - ) 254 - facilities = self.paginate_queryset(queryset) 255 - facilities = FacilityBasicInfoSerializer(facilities, many=True) 256 - return self.get_paginated_response(facilities.data) 257 - 258 - @extend_schema(tags=["users"]) 259 - @action(detail=True, methods=["PUT"], permission_classes=[IsAuthenticated]) 260 - def add_facility(self, request, *args, **kwargs): 261 - # Remove User Facility Cache 262 - user = self.get_object() 263 - remove_facility_user_cache(user.id) 264 - # Cache Deleted 265 - requesting_user = request.user 266 - if "facility" not in request.data: 267 - raise ValidationError({"facility": "required"}) 268 - facility = Facility.objects.filter(external_id=request.data["facility"]).first() 269 - if not facility: 270 - raise ValidationError({"facility": "Does not Exist"}) 271 - if not self.has_user_type_permission_elevation(requesting_user, user): 272 - raise ValidationError({"facility": "cannot Access Higher Level User"}) 273 - if not self.has_facility_permission(requesting_user, facility): 274 - raise ValidationError({"facility": "Facility Access not Present"}) 275 - if self.check_facility_user_exists(user, facility): 276 - raise ValidationError( 277 - {"facility": "User Already has permission to this facility"} 278 - ) 279 - FacilityUser(facility=facility, user=user, created_by=requesting_user).save() 280 - return Response(status=status.HTTP_201_CREATED) 281 - 282 - @extend_schema(tags=["users"]) 283 - @extend_schema( 284 - request=None, 285 - responses={204: "Deleted Successfully"}, 286 - ) 287 - @action(detail=True, methods=["DELETE"], permission_classes=[IsAuthenticated]) 288 - def clear_home_facility(self, request, *args, **kwargs): 289 - user = self.get_object() 290 - requesting_user = request.user 291 - 292 - if not user.home_facility: 293 - raise ValidationError({"home_facility": "No Home Facility Present"}) 294 - if ( 295 - requesting_user.id == user.id 296 - and requesting_user.user_type == User.TYPE_VALUE_MAP["Nurse"] 297 - ): 298 - pass 299 - elif ( 300 - requesting_user.user_type < User.TYPE_VALUE_MAP["DistrictAdmin"] 301 - or requesting_user.user_type in User.READ_ONLY_TYPES 302 - ): 303 - raise ValidationError({"home_facility": "Insufficient Permissions"}) 304 - 305 - if not self.has_user_type_permission_elevation(requesting_user, user): 306 - raise ValidationError({"home_facility": "Cannot Access Higher Level User"}) 307 - 308 - # ensure that district admin only able to delete in the same district 309 - if ( 310 - requesting_user.user_type <= User.TYPE_VALUE_MAP["DistrictAdmin"] 311 - and user.district_id != requesting_user.district_id 312 - ): 313 - raise ValidationError( 314 - {"facility": "Cannot unlink User's Home Facility from other district"} 315 - ) 316 - 317 - user.home_facility = None 318 - user.save(update_fields=["home_facility"]) 319 - return Response(status=status.HTTP_204_NO_CONTENT) 320 - 321 - @extend_schema(tags=["users"]) 322 - @action(detail=True, methods=["DELETE"], permission_classes=[IsAuthenticated]) 323 - def delete_facility(self, request, *args, **kwargs): 324 - # Remove User Facility Cache 325 - user = self.get_object() 326 - remove_facility_user_cache(user.id) 327 - # Cache Deleted 328 - requesting_user = request.user 329 - if "facility" not in request.data: 330 - raise ValidationError({"facility": "required"}) 331 - facility = Facility.objects.filter(external_id=request.data["facility"]).first() 332 - if not facility: 333 - raise ValidationError({"facility": "Does not Exist"}) 334 - if not self.has_user_type_permission_elevation(requesting_user, user): 335 - raise ValidationError({"facility": "cannot Access Higher Level User"}) 336 - if not self.has_facility_permission(requesting_user, facility): 337 - raise ValidationError({"facility": "Facility Access not Present"}) 338 - if not self.has_facility_permission(user, facility): 339 - raise ValidationError( 340 - {"facility": "Intended User Does not have permission to this facility"} 341 - ) 342 - if user.home_facility == facility: 343 - raise ValidationError({"facility": "Cannot Delete User's Home Facility"}) 344 - FacilityUser.objects.filter(facility=facility, user=user).delete() 345 - return Response(status=status.HTTP_204_NO_CONTENT) 346 - 347 - @extend_schema(tags=["users"]) 348 - @action( 349 - detail=True, 350 - methods=["PATCH", "GET"], 351 - permission_classes=[IsAuthenticated], 352 - ) 353 - def pnconfig(self, request, *args, **kwargs): 354 - user = request.user 355 - if request.method == "GET": 356 - return Response( 357 - { 358 - "pf_endpoint": user.pf_endpoint, 359 - "pf_p256dh": user.pf_p256dh, 360 - "pf_auth": user.pf_auth, 361 - } 362 - ) 363 - acceptable_fields = ["pf_endpoint", "pf_p256dh", "pf_auth"] 364 - for field in acceptable_fields: 365 - if field in request.data: 366 - setattr(user, field, request.data[field]) 367 - user.save() 368 - return Response(status=status.HTTP_200_OK) 369 - 370 - @extend_schema(tags=["users"]) 371 - @action(methods=["GET"], detail=True) 372 - def check_availability(self, request, username): 373 - """ 374 - Checks availability of username by getting as query, returns 200 if available, and 409 otherwise. 375 - """ 376 - if User.check_username_exists(username): 377 - return Response(status=status.HTTP_409_CONFLICT) 378 - return Response(status=status.HTTP_200_OK) 379 - 380 - def has_profile_image_write_permission(self, request, user): 381 - return request.user.is_superuser or (user.id == request.user.id) 382 - 383 - @extend_schema(tags=["users"]) 384 - @method_decorator(parser_classes([MultiPartParser])) 385 - @action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated]) 386 - def profile_picture(self, request, *args, **kwargs): 387 - user = self.get_object() 388 - if not self.has_profile_image_write_permission(request, user): 389 - return Response(status=status.HTTP_403_FORBIDDEN) 390 - serializer = self.get_serializer(user, data=request.data) 391 - serializer.is_valid(raise_exception=True) 392 - serializer.save() 393 - return Response(status=status.HTTP_200_OK) 394 - 395 - @extend_schema(tags=["users"]) 396 - @profile_picture.mapping.delete 397 - def profile_picture_delete(self, request, *args, **kwargs): 398 - user = self.get_object() 399 - if not self.has_profile_image_write_permission(request, user): 400 - return Response(status=status.HTTP_403_FORBIDDEN) 401 - delete_cover_image(user.profile_picture_url, "avatars") 402 - user.profile_picture_url = None 403 - user.save() 404 - return Response(status=status.HTTP_204_NO_CONTENT) 69 + # 70 + # class UserViewSet( 71 + # mixins.RetrieveModelMixin, 72 + # mixins.UpdateModelMixin, 73 + # mixins.ListModelMixin, 74 + # mixins.DestroyModelMixin, 75 + # GenericViewSet, 76 + # ): 77 + # """ 78 + # A viewset for viewing and manipulating user instances. 79 + # """ 80 + # 81 + # queryset = ( 82 + # User.objects.filter(is_active=True, is_superuser=False) 83 + # .select_related("local_body", "district", "state", "home_facility") 84 + # .order_by(F("last_login").desc(nulls_last=True)) 85 + # .annotate( 86 + # created_by_user=F("created_by__username"), 87 + # ) 88 + # ) 89 + # queryset = queryset.filter(Q(asset__isnull=True)) 90 + # lookup_field = "username" 91 + # lookup_value_regex = "[^/]+" 92 + # permission_classes = (IsAuthenticated, DRYPermissions) 93 + # filter_backends = ( 94 + # filters.DjangoFilterBackend, 95 + # rest_framework_filters.OrderingFilter, 96 + # drf_filters.SearchFilter, 97 + # ) 98 + # filterset_class = UserFilterSet 99 + # ordering_fields = ["id", "date_joined", "last_login"] 100 + # search_fields = ["first_name", "last_name", "username"] 101 + # 102 + # def get_queryset(self): 103 + # if self.request.user.is_superuser: 104 + # return super().get_queryset() 105 + # query = Q(id=self.request.user.id) 106 + # if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateReadOnlyAdmin"]: 107 + # query |= Q( 108 + # state=self.request.user.state, 109 + # user_type__lte=User.TYPE_VALUE_MAP["StateAdmin"], 110 + # is_superuser=False, 111 + # ) 112 + # elif ( 113 + # self.request.user.user_type >= User.TYPE_VALUE_MAP["DistrictReadOnlyAdmin"] 114 + # ): 115 + # query |= Q( 116 + # district=self.request.user.district, 117 + # user_type__lte=User.TYPE_VALUE_MAP["DistrictAdmin"], 118 + # is_superuser=False, 119 + # ) 120 + # else: 121 + # query |= Q( 122 + # id__in=Subquery( 123 + # FacilityUser.objects.filter( 124 + # facility_id__in=get_accessible_facilities(self.request.user) 125 + # ).values("user_id") 126 + # ), 127 + # user_type__lt=User.TYPE_VALUE_MAP["DistrictAdmin"], 128 + # is_superuser=False, 129 + # ) 130 + # return self.queryset.filter(query) 131 + # 132 + # def get_object(self) -> User: 133 + # try: 134 + # if self.action == "retrieve": 135 + # username = self.kwargs.get("username") 136 + # return get_object_or_404(User, username=username) 137 + # return super().get_object() 138 + # except Http404 as e: 139 + # error = "User not found" 140 + # raise Http404(error) from e 141 + # 142 + # def get_serializer_class(self): 143 + # if self.action == "list": 144 + # return UserListSerializer 145 + # if self.action == "add_user": 146 + # return UserCreateSerializer 147 + # if self.action == "profile_picture": 148 + # return UserImageUploadSerializer 149 + # return UserSerializer 150 + # 151 + # @extend_schema(tags=["users"]) 152 + # @action(detail=False, methods=["GET"]) 153 + # def getcurrentuser(self, request): 154 + # return Response( 155 + # status=status.HTTP_200_OK, 156 + # data=UserSerializer(request.user, context={"request": request}).data, 157 + # ) 158 + # 159 + # def destroy(self, request, *args, **kwargs): 160 + # queryset = self.get_queryset() 161 + # username = kwargs["username"] 162 + # if request.user.is_superuser: 163 + # pass 164 + # elif request.user.user_type >= User.TYPE_VALUE_MAP["StateAdmin"]: 165 + # queryset = queryset.filter( 166 + # state=request.user.state, 167 + # user_type__lt=User.TYPE_VALUE_MAP["StateAdmin"], 168 + # is_superuser=False, 169 + # ) 170 + # elif request.user.user_type == User.TYPE_VALUE_MAP["DistrictAdmin"]: 171 + # queryset = queryset.filter( 172 + # district=request.user.district, 173 + # user_type__lt=User.TYPE_VALUE_MAP["DistrictAdmin"], 174 + # is_superuser=False, 175 + # ) 176 + # else: 177 + # return Response( 178 + # status=status.HTTP_403_FORBIDDEN, data={"permission": "Denied"} 179 + # ) 180 + # user = get_object_or_404(queryset.filter(username=username)) 181 + # user.is_active = False 182 + # user.save(update_fields=["is_active"]) 183 + # return Response(status=status.HTTP_204_NO_CONTENT) 184 + # 185 + # @extend_schema(tags=["users"]) 186 + # @action(detail=False, methods=["POST"]) 187 + # def add_user(self, request, *args, **kwargs): 188 + # password = request.data.pop( 189 + # "password", User.objects.make_random_password(length=8) 190 + # ) 191 + # serializer = UserCreateSerializer( 192 + # data={**request.data, "password": password}, 193 + # context={"created_by": request.user}, 194 + # ) 195 + # serializer.is_valid(raise_exception=True) 196 + # serializer.save() 197 + # return Response(status=status.HTTP_201_CREATED) 198 + # 199 + # def has_facility_permission(self, user, facility): 200 + # return ( 201 + # user.is_superuser 202 + # or (facility and user in facility.users.all()) 203 + # or ( 204 + # user.user_type >= User.TYPE_VALUE_MAP["LocalBodyAdmin"] 205 + # and (facility and user.local_body == facility.local_body) 206 + # ) 207 + # or ( 208 + # user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] 209 + # and (facility and user.district == facility.district) 210 + # ) 211 + # or ( 212 + # user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] 213 + # and (facility and user.state == facility.state) 214 + # ) 215 + # ) 216 + # 217 + # def has_user_type_permission_elevation(self, init_user, dest_user): 218 + # return init_user.user_type >= dest_user.user_type 219 + # 220 + # def check_facility_user_exists(self, user, facility): 221 + # return FacilityUser.objects.filter(facility=facility, user=user).exists() 222 + # 223 + # @extend_schema(tags=["users"]) 224 + # @action(detail=True, methods=["GET"], permission_classes=[IsAuthenticated]) 225 + # def get_facilities(self, request, *args, **kwargs): 226 + # user = self.get_object() 227 + # queryset = Facility.objects.filter(users=user).select_related( 228 + # "local_body", "district", "state", "ward" 229 + # ) 230 + # facilities = self.paginate_queryset(queryset) 231 + # facilities = FacilityBasicInfoSerializer(facilities, many=True) 232 + # return self.get_paginated_response(facilities.data) 233 + # 234 + # @extend_schema(tags=["users"]) 235 + # @action(detail=True, methods=["PUT"], permission_classes=[IsAuthenticated]) 236 + # def add_facility(self, request, *args, **kwargs): 237 + # # Remove User Facility Cache 238 + # user = self.get_object() 239 + # remove_facility_user_cache(user.id) 240 + # # Cache Deleted 241 + # requesting_user = request.user 242 + # if "facility" not in request.data: 243 + # raise ValidationError({"facility": "required"}) 244 + # facility = Facility.objects.filter(external_id=request.data["facility"]).first() 245 + # if not facility: 246 + # raise ValidationError({"facility": "Does not Exist"}) 247 + # if not self.has_user_type_permission_elevation(requesting_user, user): 248 + # raise ValidationError({"facility": "cannot Access Higher Level User"}) 249 + # if not self.has_facility_permission(requesting_user, facility): 250 + # raise ValidationError({"facility": "Facility Access not Present"}) 251 + # if self.check_facility_user_exists(user, facility): 252 + # raise ValidationError( 253 + # {"facility": "User Already has permission to this facility"} 254 + # ) 255 + # FacilityUser(facility=facility, user=user, created_by=requesting_user).save() 256 + # return Response(status=status.HTTP_201_CREATED) 257 + # 258 + # @extend_schema(tags=["users"]) 259 + # @extend_schema( 260 + # request=None, 261 + # responses={204: "Deleted Successfully"}, 262 + # ) 263 + # @action(detail=True, methods=["DELETE"], permission_classes=[IsAuthenticated]) 264 + # def clear_home_facility(self, request, *args, **kwargs): 265 + # user = self.get_object() 266 + # requesting_user = request.user 267 + # 268 + # if not user.home_facility: 269 + # raise ValidationError({"home_facility": "No Home Facility Present"}) 270 + # if ( 271 + # requesting_user.id == user.id 272 + # and requesting_user.user_type == User.TYPE_VALUE_MAP["Nurse"] 273 + # ): 274 + # pass 275 + # elif ( 276 + # requesting_user.user_type < User.TYPE_VALUE_MAP["DistrictAdmin"] 277 + # or requesting_user.user_type in User.READ_ONLY_TYPES 278 + # ): 279 + # raise ValidationError({"home_facility": "Insufficient Permissions"}) 280 + # 281 + # if not self.has_user_type_permission_elevation(requesting_user, user): 282 + # raise ValidationError({"home_facility": "Cannot Access Higher Level User"}) 283 + # 284 + # # ensure that district admin only able to delete in the same district 285 + # if ( 286 + # requesting_user.user_type <= User.TYPE_VALUE_MAP["DistrictAdmin"] 287 + # and user.district_id != requesting_user.district_id 288 + # ): 289 + # raise ValidationError( 290 + # {"facility": "Cannot unlink User's Home Facility from other district"} 291 + # ) 292 + # 293 + # user.home_facility = None 294 + # user.save(update_fields=["home_facility"]) 295 + # return Response(status=status.HTTP_204_NO_CONTENT) 296 + # 297 + # @extend_schema(tags=["users"]) 298 + # @action(detail=True, methods=["DELETE"], permission_classes=[IsAuthenticated]) 299 + # def delete_facility(self, request, *args, **kwargs): 300 + # # Remove User Facility Cache 301 + # user = self.get_object() 302 + # remove_facility_user_cache(user.id) 303 + # # Cache Deleted 304 + # requesting_user = request.user 305 + # if "facility" not in request.data: 306 + # raise ValidationError({"facility": "required"}) 307 + # facility = Facility.objects.filter(external_id=request.data["facility"]).first() 308 + # if not facility: 309 + # raise ValidationError({"facility": "Does not Exist"}) 310 + # if not self.has_user_type_permission_elevation(requesting_user, user): 311 + # raise ValidationError({"facility": "cannot Access Higher Level User"}) 312 + # if not self.has_facility_permission(requesting_user, facility): 313 + # raise ValidationError({"facility": "Facility Access not Present"}) 314 + # if not self.has_facility_permission(user, facility): 315 + # raise ValidationError( 316 + # {"facility": "Intended User Does not have permission to this facility"} 317 + # ) 318 + # if user.home_facility == facility: 319 + # raise ValidationError({"facility": "Cannot Delete User's Home Facility"}) 320 + # FacilityUser.objects.filter(facility=facility, user=user).delete() 321 + # return Response(status=status.HTTP_204_NO_CONTENT) 322 + # 323 + # @extend_schema(tags=["users"]) 324 + # @action( 325 + # detail=True, 326 + # methods=["PATCH", "GET"], 327 + # permission_classes=[IsAuthenticated], 328 + # ) 329 + # def pnconfig(self, request, *args, **kwargs): 330 + # user = request.user 331 + # if request.method == "GET": 332 + # return Response( 333 + # { 334 + # "pf_endpoint": user.pf_endpoint, 335 + # "pf_p256dh": user.pf_p256dh, 336 + # "pf_auth": user.pf_auth, 337 + # } 338 + # ) 339 + # acceptable_fields = ["pf_endpoint", "pf_p256dh", "pf_auth"] 340 + # for field in acceptable_fields: 341 + # if field in request.data: 342 + # setattr(user, field, request.data[field]) 343 + # user.save() 344 + # return Response(status=status.HTTP_200_OK) 345 + # 346 + # @extend_schema(tags=["users"]) 347 + # @action(methods=["GET"], detail=True) 348 + # def check_availability(self, request, username): 349 + # """ 350 + # Checks availability of username by getting as query, returns 200 if available, and 409 otherwise. 351 + # """ 352 + # if User.check_username_exists(username): 353 + # return Response(status=status.HTTP_409_CONFLICT) 354 + # return Response(status=status.HTTP_200_OK) 355 + # 356 + # def has_profile_image_write_permission(self, request, user): 357 + # return request.user.is_superuser or (user.id == request.user.id) 358 + # 359 + # @extend_schema(tags=["users"]) 360 + # @method_decorator(parser_classes([MultiPartParser])) 361 + # @action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated]) 362 + # def profile_picture(self, request, *args, **kwargs): 363 + # user = self.get_object() 364 + # if not self.has_profile_image_write_permission(request, user): 365 + # return Response(status=status.HTTP_403_FORBIDDEN) 366 + # serializer = self.get_serializer(user, data=request.data) 367 + # serializer.is_valid(raise_exception=True) 368 + # serializer.save() 369 + # return Response(status=status.HTTP_200_OK) 370 + # 371 + # @extend_schema(tags=["users"]) 372 + # @profile_picture.mapping.delete 373 + # def profile_picture_delete(self, request, *args, **kwargs): 374 + # user = self.get_object() 375 + # if not self.has_profile_image_write_permission(request, user): 376 + # return Response(status=status.HTTP_403_FORBIDDEN) 377 + # delete_cover_image(user.profile_picture_url, "avatars") 378 + # user.profile_picture_url = None 379 + # user.save() 380 + # return Response(status=status.HTTP_204_NO_CONTENT)
-4
care/users/models.py
··· 418 418 def check_username_exists(username): 419 419 return User.objects.get_entire_queryset().filter(username=username).exists() 420 420 421 - def delete(self, *args, **kwargs): 422 - self.deleted = True 423 - self.save() 424 - 425 421 def get_absolute_url(self): 426 422 return reverse("users:detail", kwargs={"username": self.username}) 427 423
+79
care/users/tests/test_user_edit.py
··· 1 + from django.forms.models import model_to_dict 2 + from django.urls import reverse 3 + from polyfactory.factories.pydantic_factory import ModelFactory 4 + from rest_framework import status 5 + 6 + from care.emr.resources.patient.spec import GenderChoices 7 + from care.emr.resources.user.spec import ( 8 + UserCreateSpec, 9 + UserTypeOptions, 10 + UserTypeRoleMapping, 11 + ) 12 + from care.security.permissions.user import UserPermissions 13 + from care.utils.tests.base import CareAPITestBase 14 + 15 + 16 + class UserFactory(ModelFactory[UserCreateSpec]): 17 + __model__ = UserCreateSpec 18 + 19 + 20 + class UserTestEdit(CareAPITestBase): 21 + """ 22 + Test cases for checking edit user 23 + 24 + Tests should check if permission is checked when user is edited 25 + """ 26 + 27 + def setUp(self): 28 + self.organization = self.create_organization(org_type="govt") 29 + self.organization2 = self.create_organization(org_type="govt") 30 + role = self.create_role_with_permissions( 31 + permissions=[UserPermissions.can_create_user.name] 32 + ) 33 + self.user = self.create_user( 34 + first_name="Test", 35 + last_name="User", 36 + gender=GenderChoices.non_binary, 37 + geo_organization=self.organization, 38 + user_type=UserTypeOptions.doctor, 39 + ) 40 + self.attach_role_organization_user(self.organization, self.user, role) 41 + self.create_role( 42 + name=UserTypeRoleMapping[self.user.user_type.value].value.name, 43 + is_system=True, 44 + ) 45 + self.base_url = reverse("users-detail", kwargs={"username": self.user.username}) 46 + 47 + def get_user_data(self, **kwargs): 48 + user_data = model_to_dict(self.user) 49 + user_data.update(kwargs) 50 + return user_data 51 + 52 + def test_edit_user_unauthenticated(self): 53 + response = self.client.put( 54 + self.base_url, 55 + self.get_user_data(first_name="Test Edit User"), 56 + format="json", 57 + ) 58 + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 59 + 60 + def test_edit_user_authorization(self): 61 + self.client.force_authenticate(user=self.user) 62 + user_data = self.get_user_data( 63 + first_name="Test Edit User", 64 + gender=GenderChoices.female, 65 + geo_organization=self.organization.external_id, 66 + ) 67 + response = self.client.put(self.base_url, user_data, format="json") 68 + self.assertEqual(response.status_code, status.HTTP_200_OK) 69 + self.assertEqual(response.data["first_name"], "Test Edit User") 70 + self.assertEqual(response.data["gender"], "female") 71 + 72 + def test_edit_user_change_geo_organization(self): 73 + self.client.force_authenticate(user=self.user) 74 + user_data = self.get_user_data(geo_organization=self.organization2.external_id) 75 + response = self.client.put(self.base_url, user_data, format="json") 76 + self.assertEqual(response.status_code, status.HTTP_200_OK) 77 + self.assertEqual( 78 + response.data["geo_organization"]["id"], str(self.organization2.external_id) 79 + )
+3 -1
care/utils/filters/multiselect.py
··· 6 6 if not value: 7 7 return qs 8 8 if not self.field_name: 9 - return None 9 + return qs 10 10 values_list = value.split(",") 11 11 filters = {self.field_name + "__in": values_list} 12 + if self.exclude: 13 + return qs.exclude(**filters) 12 14 return qs.filter(**filters)
+12
care/utils/tests/base.py
··· 1 + import sys 2 + from unittest.mock import MagicMock 3 + 1 4 from faker import Faker 2 5 from model_bakery import baker 3 6 from rest_framework.test import APITestCase 4 7 5 8 from care.emr.models.organization import FacilityOrganizationUser, OrganizationUser 9 + 10 + # Global mocking, since the types are loaded when specs load, mocking using patch was not working as the validations were already loaded. 11 + sys.modules["care.emr.utils.valueset_coding_type"].validate_valueset = MagicMock( 12 + return_value={ 13 + "display": "Test Value", 14 + "system": "http://test_system.care/test", 15 + "code": "123", 16 + } 17 + ) 6 18 7 19 8 20 class CareAPITestBase(APITestCase):
-4
config/api_router.py
··· 9 9 from care.emr.api.viewsets.allergy_intolerance import AllergyIntoleranceViewSet 10 10 from care.emr.api.viewsets.batch_request import BatchRequestView 11 11 from care.emr.api.viewsets.condition import ( 12 - ChronicConditionViewSet, 13 12 DiagnosisViewSet, 14 13 SymptomViewSet, 15 14 ) ··· 231 230 patient_nested_router.register(r"diagnosis", DiagnosisViewSet, basename="diagnosis") 232 231 233 232 patient_nested_router.register(r"consent", ConsentViewSet, basename="consent") 234 - patient_nested_router.register( 235 - r"chronic_condition", ChronicConditionViewSet, basename="chronic-condition" 236 - ) 237 233 238 234 patient_nested_router.register( 239 235 "observation", ObservationViewSet, basename="observation"
+4 -6
config/settings/base.py
··· 18 18 from care.utils.csp import config as csp_config 19 19 from plug_config import manager 20 20 21 + from .custom_limits import * # noqa F403 22 + 21 23 warnings.filterwarnings("ignore", category=UserWarning) 22 24 23 25 logger = logging.getLogger(__name__) ··· 440 442 # ------------------------------------------------------------------------------ 441 443 # https://github.com/anexia-it/django-rest-passwordreset#configuration--settings 442 444 DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE = True 443 - DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME = 1 445 + DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME = 24 444 446 # https://github.com/anexia-it/django-rest-passwordreset#custom-email-lookup 445 447 DJANGO_REST_LOOKUP_FIELD = "username" 446 448 ··· 723 725 # Path to the typst binary, see scripts/install_typst.sh 724 726 TYPST_BIN = env("TYPST_BIN", default="typst") 725 727 726 - MAX_APPOINTMENTS_PER_PATIENT = env.int("MAX_APPOINTMENTS_PER_PATIENT", default=10) 727 - 728 - MAX_ACTIVE_ENCOUNTERS_PER_PATIENT = env.int( 729 - "MAX_ACTIVE_ENCOUNTERS_PER_PATIENT", default=5 730 - ) 728 + DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD = False
+26
config/settings/custom_limits.py
··· 1 + import environ 2 + 3 + env = environ.Env() 4 + 5 + MAX_APPOINTMENTS_PER_PATIENT = env.int("MAX_APPOINTMENTS_PER_PATIENT", default=10) 6 + 7 + MAX_ACTIVE_ENCOUNTERS_PER_PATIENT = env.int( 8 + "MAX_ACTIVE_ENCOUNTERS_PER_PATIENT", default=5 9 + ) 10 + 11 + # Maximum file upload size in MB 12 + MAX_FILE_UPLOAD_SIZE = env.int("MAX_FILE_UPLOAD_SIZE", default=5) 13 + 14 + LOCATION_MAX_DEPTH = env.int("LOCATION_MAX_DEPTH", default=10) 15 + 16 + ORGANIZATION_MAX_DEPTH = env.int("ORGANIZATION_MAX_DEPTH", default=10) 17 + 18 + FACILITY_ORGANIZATION_MAX_DEPTH = env.int("FACILITY_ORGANIZATION_MAX_DEPTH", default=10) 19 + 20 + MAX_LOCATION_IN_FACILITY = env.int("MAX_LOCATION_IN_FACILITY", default=1000) 21 + 22 + MAX_ORGANIZATION_IN_FACILITY = env.int("MAX_ORGANIZATION_IN_FACILITY", default=1000) 23 + 24 + MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE = env.int( 25 + "MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE", default=500 26 + )
+13 -2
config/settings/test.py
··· 42 42 # test in peace 43 43 CACHES = { 44 44 "default": { 45 - "BACKEND": "config.caches.DummyCache", 46 - } 45 + "BACKEND": "django_redis.cache.RedisCache", 46 + "LOCATION": REDIS_URL, # noqa F405 47 + "OPTIONS": { 48 + "CLIENT_CLASS": "django_redis.client.DefaultClient", 49 + # Mimicing memcache behavior. 50 + # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior 51 + "IGNORE_EXCEPTIONS": True, 52 + "KEY_PREFIX": "test_", 53 + }, 54 + }, 47 55 } 56 + 48 57 # for testing retelimit use override_settings decorator 49 58 SILENCED_SYSTEM_CHECKS = ["django_ratelimit.E003", "django_ratelimit.W001"] 50 59 ··· 91 100 ) 92 101 ) 93 102 ) 103 + 104 + DISABLE_RATELIMIT = True
+1 -1
docs/django-commands/configuration.rst
··· 14 14 | | | | 15 15 | | | Example Invocation: :code:`python manage.py load_dummy_data` | 16 16 +---------------------+---------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 17 - | load_data | state_name | This command is used to load all the DIstrict/Lsg/Ward Level data for a given state, The data that is imported is scraped from various sources, The admin can change this data at any point through the admin panel, If the state name is given as "all" then all available data is imported into care. | 17 + | load_data | state_name | This command is used to load all the District/Lsg/Ward Level data for a given state, The data that is imported is scraped from various sources, The admin can change this data at any point through the admin panel, If the state name is given as "all" then all available data is imported into care. | 18 18 | | | | 19 19 | | | Example Invocation: :code:`python manage.py load_data kerala` | 20 20 +---------------------+---------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+1 -1
docs/local-setup/configuration.rst
··· 149 149 If the command prompts for username only and after entering if it goes to error 150 150 do make sure that you have done the following 151 151 152 - Note: Make sure that you have created a database named `care` (replace thisw with your database name) with privileges set for the user `postgres` 152 + Note: Make sure that you have created a database named `care` (replace this with your database name) with privileges set for the user `postgres` 153 153 154 154 In the virtualenv shell type the following commands also:: 155 155