this repo has no description
0
fork

Configure Feed

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

Report Builder Final Final (#3382)

* Added Report Builder v2

---------

Co-authored-by: Prafful Sharma <praffulsharma1230@gmail.com>
Co-authored-by: Prafful Sharma <115104695+praffq@users.noreply.github.com>
Co-authored-by: Nandkishor R <96693626+nandkishorr@users.noreply.github.com>

authored by

Vignesh Hari
Prafful Sharma
Prafful Sharma
Nandkishor R
and committed by
GitHub
2b419cdb e2f9ead2

+3504 -168
+1
Pipfile
··· 51 51 python-magic = {version = "==0.4.28", index = "python-magic-bin"} 52 52 django-import-export = "==4.3.7" 53 53 evalidate = "==2.0.5" 54 + weasyprint = "==66.0" 54 55 55 56 [dev-packages] 56 57 boto3-stubs = { extras = ["s3", "boto3"], version = "*" }
+405 -167
Pipfile.lock
··· 1 1 { 2 2 "_meta": { 3 3 "hash": { 4 - "sha256": "0b8002b25e6bcf945a9648fad30e8bf34258972a56058f1bb61835207a21fd96" 4 + "sha256": "79f6ff0856345adb189ddc91b2aaef47d2d3756543720cfd21a4f4044f247bdb" 5 5 }, 6 6 "pipfile-spec": 6, 7 7 "requires": { ··· 222 222 }, 223 223 "asgiref": { 224 224 "hashes": [ 225 - "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", 226 - "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e" 225 + "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", 226 + "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d" 227 227 ], 228 228 "markers": "python_version >= '3.9'", 229 - "version": "==3.10.0" 229 + "version": "==3.11.0" 230 230 }, 231 231 "attrs": { 232 232 "hashes": [ ··· 247 247 }, 248 248 "billiard": { 249 249 "hashes": [ 250 - "sha256:96486f0885afc38219d02d5f0ccd5bec8226a414b834ab244008cbb0025b8dcb", 251 - "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b" 250 + "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", 251 + "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f" 252 252 ], 253 253 "markers": "python_version >= '3.7'", 254 - "version": "==4.2.3" 254 + "version": "==4.2.4" 255 255 }, 256 256 "boto3": { 257 257 "hashes": [ ··· 270 270 "markers": "python_version >= '3.9'", 271 271 "version": "==1.39.17" 272 272 }, 273 + "brotli": { 274 + "hashes": [ 275 + "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", 276 + "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", 277 + "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4", 278 + "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", 279 + "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", 280 + "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470", 281 + "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", 282 + "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", 283 + "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", 284 + "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502", 285 + "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937", 286 + "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7", 287 + "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", 288 + "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", 289 + "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17", 290 + "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc", 291 + "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", 292 + "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971", 293 + "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", 294 + "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d", 295 + "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", 296 + "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", 297 + "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", 298 + "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", 299 + "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", 300 + "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a", 301 + "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", 302 + "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", 303 + "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0", 304 + "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46", 305 + "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", 306 + "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8", 307 + "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", 308 + "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3", 309 + "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a", 310 + "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6", 311 + "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64", 312 + "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", 313 + "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", 314 + "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", 315 + "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5", 316 + "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a", 317 + "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", 318 + "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", 319 + "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", 320 + "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982", 321 + "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f", 322 + "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b", 323 + "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", 324 + "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518", 325 + "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", 326 + "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", 327 + "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16", 328 + "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a", 329 + "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", 330 + "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1", 331 + "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190", 332 + "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", 333 + "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e", 334 + "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", 335 + "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea", 336 + "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8", 337 + "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", 338 + "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", 339 + "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526", 340 + "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", 341 + "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92", 342 + "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12", 343 + "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", 344 + "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8", 345 + "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", 346 + "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", 347 + "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", 348 + "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", 349 + "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", 350 + "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", 351 + "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb", 352 + "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533", 353 + "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8", 354 + "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2", 355 + "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69", 356 + "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96", 357 + "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49", 358 + "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", 359 + "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", 360 + "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f", 361 + "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", 362 + "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7", 363 + "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", 364 + "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", 365 + "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8", 366 + "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990", 367 + "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e", 368 + "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", 369 + "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675", 370 + "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", 371 + "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c", 372 + "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13", 373 + "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", 374 + "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d" 375 + ], 376 + "version": "==1.2.0" 377 + }, 273 378 "celery": { 274 379 "hashes": [ 275 380 "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", ··· 587 692 "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", 588 693 "version": "==46.0.3" 589 694 }, 695 + "cssselect2": { 696 + "hashes": [ 697 + "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", 698 + "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a" 699 + ], 700 + "markers": "python_version >= '3.9'", 701 + "version": "==0.8.0" 702 + }, 590 703 "diff-match-patch": { 591 704 "hashes": [ 592 705 "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", ··· 753 866 "markers": "python_version >= '3.8'", 754 867 "version": "==2.0.5" 755 868 }, 869 + "fonttools": { 870 + "extras": [ 871 + "woff" 872 + ], 873 + "hashes": [ 874 + "sha256:0011d640afa61053bc6590f9a3394bd222de7cfde19346588beabac374e9d8ac", 875 + "sha256:02bdf8e04d1a70476564b8640380f04bb4ac74edc1fc71f1bacb840b3e398ee9", 876 + "sha256:0bdcf2e29d65c26299cc3d502f4612365e8b90a939f46cd92d037b6cb7bb544a", 877 + "sha256:13e3e20a5463bfeb77b3557d04b30bd6a96a6bb5c15c7b2e7908903e69d437a0", 878 + "sha256:14a290c5c93fcab76b7f451e6a4b7721b712d90b3b5ed6908f1abcf794e90d6d", 879 + "sha256:14fafda386377b6131d9e448af42d0926bad47e038de0e5ba1d58c25d621f028", 880 + "sha256:1cfa2eb9bae650e58f0e8ad53c49d19a844d6034d6b259f30f197238abc1ccee", 881 + "sha256:276f14c560e6f98d24ef7f5f44438e55ff5a67f78fa85236b218462c9f5d0635", 882 + "sha256:2cb5e45a824ce14b90510024d0d39dae51bd4fbb54c42a9334ea8c8cf4d95cbe", 883 + "sha256:2de14557d113faa5fb519f7f29c3abe4d69c17fe6a5a2595cc8cda7338029219", 884 + "sha256:2f0bafc8a3b3749c69cc610e5aa3da832d39c2a37a68f03d18ec9a02ecaac04a", 885 + "sha256:328a9c227984bebaf69f3ac9062265f8f6acc7ddf2e4e344c63358579af0aa3d", 886 + "sha256:3b2065d94e5d63aafc2591c8b6ccbdb511001d9619f1bca8ad39b745ebeb5efa", 887 + "sha256:4238120002e68296d55e091411c09eab94e111c8ce64716d17df53fd0eb3bb3d", 888 + "sha256:46cb3d9279f758ac0cf671dc3482da877104b65682679f01b246515db03dbb72", 889 + "sha256:58b4f1b78dfbfe855bb8a6801b31b8cdcca0e2847ec769ad8e0b0b692832dd3b", 890 + "sha256:59587bbe455dbdf75354a9dbca1697a35a8903e01fab4248d6b98a17032cee52", 891 + "sha256:5a9b78da5d5faa17e63b2404b77feeae105c1b7e75f26020ab7a27b76e02039f", 892 + "sha256:627216062d90ab0d98215176d8b9562c4dd5b61271d35f130bcd30f6a8aaa33a", 893 + "sha256:63c7125d31abe3e61d7bb917329b5543c5b3448db95f24081a13aaf064360fc8", 894 + "sha256:6781e7a4bb010be1cd69a29927b0305c86b843395f2613bdabe115f7d6ea7f34", 895 + "sha256:67d841aa272be5500de7f447c40d1d8452783af33b4c3599899319f6ef9ad3c1", 896 + "sha256:68704a8bbe0b61976262b255e90cde593dc0fe3676542d9b4d846bad2a890a76", 897 + "sha256:6b493c32d2555e9944ec1b911ea649ff8f01a649ad9cba6c118d6798e932b3f0", 898 + "sha256:6e5ca8c62efdec7972dfdfd454415c4db49b89aeaefaaacada432f3b7eea9866", 899 + "sha256:70e2a0c0182ee75e493ef33061bfebf140ea57e035481d2f95aa03b66c7a0e05", 900 + "sha256:787ef9dfd1ea9fe49573c272412ae5f479d78e671981819538143bec65863865", 901 + "sha256:7b446623c9cd5f14a59493818eaa80255eec2468c27d2c01b56e05357c263195", 902 + "sha256:7fb5b84f48a6a733ca3d7f41aa9551908ccabe8669ffe79586560abcc00a9cfd", 903 + "sha256:9064b0f55b947e929ac669af5311ab1f26f750214db6dd9a0c97e091e918f486", 904 + "sha256:96dfc9bc1f2302224e48e6ee37e656eddbab810b724b52e9d9c13a57a6abad01", 905 + "sha256:9821ed77bb676736b88fa87a737c97b6af06e8109667e625a4f00158540ce044", 906 + "sha256:a32a16951cbf113d38f1dd8551b277b6e06e0f6f776fece0f99f746d739e1be3", 907 + "sha256:a5c5fff72bf31b0e558ed085e4fd7ed96eb85881404ecc39ed2a779e7cf724eb", 908 + "sha256:ad751319dc532a79bdf628b8439af167181b4210a0cd28a8935ca615d9fdd727", 909 + "sha256:adbb4ecee1a779469a77377bbe490565effe8fce6fb2e6f95f064de58f8bac85", 910 + "sha256:b2b734d8391afe3c682320840c8191de9bd24e7eb85768dd4dc06ed1b63dbb1b", 911 + "sha256:b5ca59b7417d149cf24e4c1933c9f44b2957424fc03536f132346d5242e0ebe5", 912 + "sha256:b6ceac262cc62bec01b3bb59abccf41b24ef6580869e306a4e88b7e56bb4bdda", 913 + "sha256:ba774b8cbd8754f54b8eb58124e8bd45f736b2743325ab1a5229698942b9b433", 914 + "sha256:c53b47834ae41e8e4829171cc44fec0fdf125545a15f6da41776b926b9645a9a", 915 + "sha256:c84b430616ed73ce46e9cafd0bf0800e366a3e02fb7e1ad7c1e214dbe3862b1f", 916 + "sha256:dc25a4a9c1225653e4431a9413d0381b1c62317b0f543bdcec24e1991f612f33", 917 + "sha256:df8cbce85cf482eb01f4551edca978c719f099c623277bda8332e5dbe7dba09d", 918 + "sha256:e074bc07c31406f45c418e17c1722e83560f181d122c412fa9e815df0ff74810", 919 + "sha256:e0d87e81e4d869549585ba0beb3f033718501c1095004f5e6aef598d13ebc216", 920 + "sha256:e24a1565c4e57111ec7f4915f8981ecbb61adf66a55f378fdc00e206059fcfef", 921 + "sha256:e2bfacb5351303cae9f072ccf3fc6ecb437a6f359c0606bae4b1ab6715201d87", 922 + "sha256:e6cd0d9051b8ddaf7385f99dd82ec2a058e2b46cf1f1961e68e1ff20fcbb61af", 923 + "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7" 924 + ], 925 + "markers": "python_version >= '3.10'", 926 + "version": "==4.61.0" 927 + }, 756 928 "frozenlist": { 757 929 "hashes": [ 758 930 "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", ··· 1659 1831 "markers": "python_version >= '3.8'", 1660 1832 "version": "==2.10.2" 1661 1833 }, 1834 + "pydyf": { 1835 + "hashes": [ 1836 + "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", 1837 + "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095" 1838 + ], 1839 + "markers": "python_version >= '3.10'", 1840 + "version": "==0.12.1" 1841 + }, 1662 1842 "pyjwt": { 1663 1843 "hashes": [ 1664 1844 "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", ··· 1676 1856 "index": "pypi", 1677 1857 "markers": "python_version >= '3.7'", 1678 1858 "version": "==2.9.0" 1859 + }, 1860 + "pyphen": { 1861 + "hashes": [ 1862 + "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", 1863 + "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3" 1864 + ], 1865 + "markers": "python_version >= '3.9'", 1866 + "version": "==0.17.2" 1679 1867 }, 1680 1868 "python-dateutil": { 1681 1869 "hashes": [ ··· 1850 2038 }, 1851 2039 "rpds-py": { 1852 2040 "hashes": [ 1853 - "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", 1854 - "sha256:0248b19405422573621172ab8e3a1f29141362d13d9f72bafa2e28ea0cdca5a2", 1855 - "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", 1856 - "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", 1857 - "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", 1858 - "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", 1859 - "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", 1860 - "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", 1861 - "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", 1862 - "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", 1863 - "sha256:1a409b0310a566bfd1be82119891fefbdce615ccc8aa558aff7835c27988cbef", 1864 - "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", 1865 - "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", 1866 - "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", 1867 - "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", 1868 - "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", 1869 - "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", 1870 - "sha256:24a16cb7163933906c62c272de20ea3c228e4542c8c45c1d7dc2b9913e17369a", 1871 - "sha256:24a7231493e3c4a4b30138b50cca089a598e52c34cf60b2f35cebf62f274fdea", 1872 - "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", 1873 - "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", 1874 - "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", 1875 - "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", 1876 - "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", 1877 - "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", 1878 - "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", 1879 - "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", 1880 - "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", 1881 - "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", 1882 - "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", 1883 - "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", 1884 - "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", 1885 - "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", 1886 - "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", 1887 - "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", 1888 - "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", 1889 - "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", 1890 - "sha256:453783477aa4f2d9104c4b59b08c871431647cb7af51b549bbf2d9eb9c827756", 1891 - "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", 1892 - "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", 1893 - "sha256:4ae4b88c6617e1b9e5038ab3fccd7bac0842fdda2b703117b2aa99bc85379113", 1894 - "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", 1895 - "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", 1896 - "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", 1897 - "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", 1898 - "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", 1899 - "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", 1900 - "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", 1901 - "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", 1902 - "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", 1903 - "sha256:66786c3fb1d8de416a7fa8e1cb1ec6ba0a745b2b0eee42f9b7daa26f1a495545", 1904 - "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", 1905 - "sha256:7033c1010b1f57bb44d8067e8c25aa6fa2e944dbf46ccc8c92b25043839c3fd2", 1906 - "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", 1907 - "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", 1908 - "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", 1909 - "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", 1910 - "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", 1911 - "sha256:799156ef1f3529ed82c36eb012b5d7a4cf4b6ef556dd7cc192148991d07206ae", 1912 - "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", 1913 - "sha256:7d9128ec9d8cecda6f044001fde4fb71ea7c24325336612ef8179091eb9596b9", 1914 - "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", 1915 - "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", 1916 - "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", 1917 - "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", 1918 - "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", 1919 - "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", 1920 - "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", 1921 - "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", 1922 - "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", 1923 - "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", 1924 - "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", 1925 - "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", 1926 - "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", 1927 - "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", 1928 - "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", 1929 - "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", 1930 - "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", 1931 - "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", 1932 - "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", 1933 - "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", 1934 - "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", 1935 - "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", 1936 - "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", 1937 - "sha256:b58f5c77f1af888b5fd1876c9a0d9858f6f88a39c9dd7c073a88e57e577da66d", 1938 - "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", 1939 - "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", 1940 - "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", 1941 - "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", 1942 - "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", 1943 - "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", 1944 - "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", 1945 - "sha256:c5523b0009e7c3c1263471b69d8da1c7d41b3ecb4cb62ef72be206b92040a950", 1946 - "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", 1947 - "sha256:d37812c3da8e06f2bb35b3cf10e4a7b68e776a706c13058997238762b4e07f4f", 1948 - "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", 1949 - "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", 1950 - "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", 1951 - "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", 1952 - "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", 1953 - "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", 1954 - "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", 1955 - "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", 1956 - "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", 1957 - "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", 1958 - "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", 1959 - "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", 1960 - "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", 1961 - "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", 1962 - "sha256:f9f436aee28d13b9ad2c764fc273e0457e37c2e61529a07b928346b219fcde3b", 1963 - "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", 1964 - "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", 1965 - "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", 1966 - "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", 1967 - "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359" 2041 + "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", 2042 + "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", 2043 + "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", 2044 + "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", 2045 + "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", 2046 + "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", 2047 + "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", 2048 + "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", 2049 + "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", 2050 + "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", 2051 + "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", 2052 + "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", 2053 + "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", 2054 + "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", 2055 + "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", 2056 + "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", 2057 + "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", 2058 + "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", 2059 + "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", 2060 + "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", 2061 + "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", 2062 + "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", 2063 + "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", 2064 + "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", 2065 + "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", 2066 + "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", 2067 + "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", 2068 + "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", 2069 + "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", 2070 + "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", 2071 + "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", 2072 + "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", 2073 + "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", 2074 + "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", 2075 + "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", 2076 + "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", 2077 + "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", 2078 + "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", 2079 + "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", 2080 + "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", 2081 + "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", 2082 + "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", 2083 + "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", 2084 + "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", 2085 + "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", 2086 + "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", 2087 + "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", 2088 + "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", 2089 + "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", 2090 + "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", 2091 + "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", 2092 + "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", 2093 + "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", 2094 + "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", 2095 + "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", 2096 + "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", 2097 + "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", 2098 + "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", 2099 + "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", 2100 + "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", 2101 + "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", 2102 + "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", 2103 + "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", 2104 + "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", 2105 + "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", 2106 + "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", 2107 + "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", 2108 + "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", 2109 + "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", 2110 + "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", 2111 + "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", 2112 + "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", 2113 + "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", 2114 + "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", 2115 + "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", 2116 + "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", 2117 + "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", 2118 + "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", 2119 + "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", 2120 + "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", 2121 + "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", 2122 + "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", 2123 + "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", 2124 + "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", 2125 + "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", 2126 + "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", 2127 + "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", 2128 + "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", 2129 + "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", 2130 + "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", 2131 + "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", 2132 + "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", 2133 + "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", 2134 + "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", 2135 + "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", 2136 + "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", 2137 + "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", 2138 + "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", 2139 + "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", 2140 + "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", 2141 + "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", 2142 + "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", 2143 + "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", 2144 + "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", 2145 + "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", 2146 + "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", 2147 + "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", 2148 + "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", 2149 + "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", 2150 + "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", 2151 + "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", 2152 + "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", 2153 + "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", 2154 + "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", 2155 + "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" 1968 2156 ], 1969 2157 "markers": "python_version >= '3.10'", 1970 - "version": "==0.29.0" 2158 + "version": "==0.30.0" 1971 2159 }, 1972 2160 "s3transfer": { 1973 2161 "hashes": [ ··· 2121 2309 }, 2122 2310 "sqlparse": { 2123 2311 "hashes": [ 2124 - "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", 2125 - "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" 2312 + "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", 2313 + "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb" 2126 2314 ], 2127 2315 "markers": "python_version >= '3.8'", 2128 - "version": "==0.5.3" 2316 + "version": "==0.5.4" 2129 2317 }, 2130 2318 "tablib": { 2131 2319 "hashes": [ ··· 2141 2329 "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" 2142 2330 ], 2143 2331 "version": "==1.3" 2332 + }, 2333 + "tinycss2": { 2334 + "hashes": [ 2335 + "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", 2336 + "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957" 2337 + ], 2338 + "markers": "python_version >= '3.10'", 2339 + "version": "==1.5.1" 2340 + }, 2341 + "tinyhtml5": { 2342 + "hashes": [ 2343 + "sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc", 2344 + "sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e" 2345 + ], 2346 + "markers": "python_version >= '3.9'", 2347 + "version": "==2.0.0" 2144 2348 }, 2145 2349 "types-cffi": { 2146 2350 "hashes": [ ··· 2206 2410 }, 2207 2411 "urllib3": { 2208 2412 "hashes": [ 2209 - "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", 2210 - "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" 2413 + "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", 2414 + "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b" 2211 2415 ], 2212 2416 "markers": "python_version >= '3.9'", 2213 - "version": "==2.5.0" 2417 + "version": "==2.6.1" 2214 2418 }, 2215 2419 "vine": { 2216 2420 "hashes": [ ··· 2228 2432 "markers": "python_version >= '3.6'", 2229 2433 "version": "==0.2.14" 2230 2434 }, 2435 + "weasyprint": { 2436 + "hashes": [ 2437 + "sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0", 2438 + "sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40" 2439 + ], 2440 + "index": "pypi", 2441 + "markers": "python_version >= '3.9'", 2442 + "version": "==66.0" 2443 + }, 2444 + "webencodings": { 2445 + "hashes": [ 2446 + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 2447 + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 2448 + ], 2449 + "version": "==0.5.1" 2450 + }, 2231 2451 "whitenoise": { 2232 2452 "hashes": [ 2233 2453 "sha256:486bd7267a375fa9650b136daaec156ac572971acc8bf99add90817a530dd1d4", ··· 2372 2592 ], 2373 2593 "markers": "python_version >= '3.9'", 2374 2594 "version": "==1.22.0" 2595 + }, 2596 + "zopfli": { 2597 + "hashes": [ 2598 + "sha256:03181d48e719fcb6cf8340189c61e8f9883d8bbbdf76bf5212a74457f7d083c1", 2599 + "sha256:18b5f1570f64d4988482e4466f10ef5f2a30f687c19ad62a64560f2152dc89eb", 2600 + "sha256:25e4863b8dc30e5d5309f87c106b0b7d3da4ed0e340b8a52b36d4471e797589f", 2601 + "sha256:7d66337be6d5613dec55213e9ac28f378c41e2cc04fbad4a10748e4df774ca85", 2602 + "sha256:9097e8e1dfdb7f5aea5464e469946857e80502b6d29ba1b232450916bd4a74d1", 2603 + "sha256:a8ee992b2549e090cd3f0178bf606dd41a29e0613a04cdf5054224662c72dce6", 2604 + "sha256:b72a010d205d00b2855acc2302772067362f9ab5a012e3550662aec60d28e6b3", 2605 + "sha256:b8bdb41fbfdc4738b7bdc09ed7c1e951579fae192391a5e694d59bb186cdbec7", 2606 + "sha256:c3ba02a9a6ca90481d2b2f68bab038b310d63a1e3b5ae305e95a6599787ed941", 2607 + "sha256:d1b98ad47c434ef213444a03ef2f826eeec100144d64f6a57504b9893d3931ce", 2608 + "sha256:f67d04280065e24cb9a4174cb6b3d1f763687f8cb2963aa135ad8f57c6995f5a", 2609 + "sha256:f94e4dd7d76b4fe9f5d9229372be20d7f786164eea5152d1af1c34298c3d5975" 2610 + ], 2611 + "markers": "python_version >= '3.10'", 2612 + "version": "==0.4.0" 2375 2613 } 2376 2614 }, 2377 2615 "develop": { 2378 2616 "asgiref": { 2379 2617 "hashes": [ 2380 - "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", 2381 - "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e" 2618 + "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", 2619 + "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d" 2382 2620 ], 2383 2621 "markers": "python_version >= '3.9'", 2384 - "version": "==3.10.0" 2622 + "version": "==3.11.0" 2385 2623 }, 2386 2624 "asttokens": { 2387 2625 "hashes": [ ··· 2422 2660 }, 2423 2661 "botocore-stubs": { 2424 2662 "hashes": [ 2425 - "sha256:088b259c4500127ecc33d4cdea785d50e0035b2456a794eaa82ce52cb3871107", 2426 - "sha256:4c215592a8c26f66e0af773b513f1a34437da2a6d0f53a04928bbba1b131c935" 2663 + "sha256:271b53fc8bbe54e437002fafea1f0b126d16fe5dc9df84f2a6779157c0af9281", 2664 + "sha256:39d2608db375d7f518c0166e36c609a3ca64c03b93ddbacedd4a9af0912ca0c5" 2427 2665 ], 2428 2666 "markers": "python_version >= '3.9'", 2429 - "version": "==1.40.74" 2667 + "version": "==1.42.5" 2430 2668 }, 2431 2669 "certifi": { 2432 2670 "hashes": [ ··· 2438 2676 }, 2439 2677 "cfgv": { 2440 2678 "hashes": [ 2441 - "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", 2442 - "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560" 2679 + "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", 2680 + "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" 2443 2681 ], 2444 - "markers": "python_version >= '3.8'", 2445 - "version": "==3.4.0" 2682 + "markers": "python_version >= '3.10'", 2683 + "version": "==3.5.0" 2446 2684 }, 2447 2685 "charset-normalizer": { 2448 2686 "hashes": [ ··· 2752 2990 }, 2753 2991 "django-stubs": { 2754 2992 "hashes": [ 2755 - "sha256:2864e74b56ead866ff1365a051f24d852f6ed02238959664f558a6c9601c95bf", 2756 - "sha256:2a07e47a8a867836a763c6bba8bf3775847b4fd9555bfa940360e32d0ee384a1" 2993 + "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", 2994 + "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c" 2757 2995 ], 2758 2996 "markers": "python_version >= '3.10'", 2759 - "version": "==5.2.7" 2997 + "version": "==5.2.8" 2760 2998 }, 2761 2999 "django-stubs-ext": { 2762 3000 "hashes": [ 2763 - "sha256:0466a7132587d49c5bbe12082ac9824d117a0dedcad5d0ada75a6e0d3aca6f60", 2764 - "sha256:b690655bd4cb8a44ae57abb314e0995dc90414280db8f26fff0cb9fb367d1cac" 3001 + "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", 3002 + "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b" 2765 3003 ], 2766 3004 "markers": "python_version >= '3.10'", 2767 - "version": "==5.2.7" 3005 + "version": "==5.2.8" 2768 3006 }, 2769 3007 "djangorestframework-stubs": { 2770 3008 "hashes": [ ··· 3082 3320 }, 3083 3321 "platformdirs": { 3084 3322 "hashes": [ 3085 - "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", 3086 - "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3" 3323 + "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", 3324 + "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31" 3087 3325 ], 3088 3326 "markers": "python_version >= '3.10'", 3089 - "version": "==4.5.0" 3327 + "version": "==4.5.1" 3090 3328 }, 3091 3329 "polyfactory": { 3092 3330 "hashes": [ ··· 3285 3523 }, 3286 3524 "sqlparse": { 3287 3525 "hashes": [ 3288 - "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", 3289 - "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" 3526 + "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", 3527 + "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb" 3290 3528 ], 3291 3529 "markers": "python_version >= '3.8'", 3292 - "version": "==0.5.3" 3530 + "version": "==0.5.4" 3293 3531 }, 3294 3532 "stack-data": { 3295 3533 "hashes": [ ··· 3317 3555 }, 3318 3556 "types-awscrt": { 3319 3557 "hashes": [ 3320 - "sha256:15929da84802f27019ee8e4484fb1c102e1f6d4cf22eb48688c34a5a86d02eb6", 3321 - "sha256:2d453f9e27583fcc333771b69a5255a5a4e2c52f86e70f65f3c5a6789d3443d0" 3558 + "sha256:3f5d1e6c99b0b551af6365f9c04d8ce2effbcfe18bb719a34501efea279ae7bb", 3559 + "sha256:41e01e14d646877bd310e7e3c49ff193f8361480b9568e97b1639775009bbefa" 3322 3560 ], 3323 3561 "markers": "python_version >= '3.8'", 3324 - "version": "==0.28.4" 3562 + "version": "==0.29.2" 3325 3563 }, 3326 3564 "types-pyyaml": { 3327 3565 "hashes": [ ··· 3341 3579 }, 3342 3580 "types-s3transfer": { 3343 3581 "hashes": [ 3344 - "sha256:108134854069a38b048e9b710b9b35904d22a9d0f37e4e1889c2e6b58e5b3253", 3345 - "sha256:17f800a87c7eafab0434e9d87452c809c290ae906c2024c24261c564479e9c95" 3582 + "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", 3583 + "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443" 3346 3584 ], 3347 - "markers": "python_version >= '3.8'", 3348 - "version": "==0.14.0" 3585 + "markers": "python_version >= '3.9'", 3586 + "version": "==0.16.0" 3349 3587 }, 3350 3588 "typing-extensions": { 3351 3589 "hashes": [ ··· 3357 3595 }, 3358 3596 "urllib3": { 3359 3597 "hashes": [ 3360 - "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", 3361 - "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" 3598 + "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", 3599 + "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b" 3362 3600 ], 3363 3601 "markers": "python_version >= '3.9'", 3364 - "version": "==2.5.0" 3602 + "version": "==2.6.1" 3365 3603 }, 3366 3604 "virtualenv": { 3367 3605 "hashes": [ ··· 3453 3691 }, 3454 3692 "beautifulsoup4": { 3455 3693 "hashes": [ 3456 - "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", 3457 - "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515" 3694 + "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", 3695 + "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86" 3458 3696 ], 3459 3697 "markers": "python_full_version >= '3.7.0'", 3460 - "version": "==4.14.2" 3698 + "version": "==4.14.3" 3461 3699 }, 3462 3700 "certifi": { 3463 3701 "hashes": [ ··· 3958 4196 }, 3959 4197 "urllib3": { 3960 4198 "hashes": [ 3961 - "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", 3962 - "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" 4199 + "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", 4200 + "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b" 3963 4201 ], 3964 4202 "markers": "python_version >= '3.9'", 3965 - "version": "==2.5.0" 4203 + "version": "==2.6.1" 3966 4204 } 3967 4205 } 3968 4206 }
+1 -1
care/emr/api/viewsets/file_upload.py
··· 153 153 "file_type" not in self.request.GET 154 154 and "associating_id" not in self.request.GET 155 155 ): 156 - raise PermissionError("Cannot filter files") 156 + raise PermissionDenied("Cannot filter files") 157 157 file_authorizer( 158 158 self.request.user, 159 159 self.request.GET.get("file_type"),
care/emr/api/viewsets/report/__init__.py

This is a binary file and will not be displayed.

+172
care/emr/api/viewsets/report/report_upload.py
··· 1 + import logging 2 + 3 + from django.utils import timezone 4 + from django_filters import BooleanFilter, CharFilter, FilterSet 5 + from django_filters.rest_framework import DjangoFilterBackend 6 + from drf_spectacular.utils import extend_schema 7 + from pydantic import UUID4, BaseModel, field_validator 8 + from rest_framework import status 9 + from rest_framework.decorators import action 10 + from rest_framework.exceptions import PermissionDenied, ValidationError 11 + from rest_framework.filters import OrderingFilter 12 + from rest_framework.response import Response 13 + 14 + from care.emr.api.viewsets.base import EMRBaseViewSet, EMRListMixin, EMRRetrieveMixin 15 + from care.emr.models.report.report_upload import ReportUpload 16 + from care.emr.models.report.template import Template 17 + from care.emr.reports import report_utils 18 + from care.emr.reports.authorizers import report_authorizer 19 + from care.emr.reports.authorizers.utils import ( 20 + read_report_authorizer, 21 + write_report_authorizer, 22 + ) 23 + from care.emr.reports.renderer.generators import GeneratorRegistry 24 + from care.emr.resources.report.report_upload.spec import ( 25 + ReportUploadListSpec, 26 + ReportUploadRetrieveSpec, 27 + ) 28 + from care.emr.tasks.report_generation import generate_report_task 29 + from care.security.authorization.base import AuthorizationController 30 + from care.utils.shortcuts import get_object_or_404 31 + 32 + logger = logging.getLogger(__name__) 33 + 34 + 35 + class ReportUploadFilters(FilterSet): 36 + name = CharFilter(field_name="name", lookup_expr="icontains") 37 + template = CharFilter(field_name="template__slug", lookup_expr="exact") 38 + associating_id = CharFilter(field_name="associating_id", lookup_expr="exact") 39 + is_archived = BooleanFilter(field_name="is_archived") 40 + upload_completed = BooleanFilter(field_name="upload_completed") 41 + 42 + 43 + class GenerateReportRequest(BaseModel): 44 + template_id: UUID4 45 + associating_id: UUID4 46 + output_format: str | None = None 47 + force: bool = False 48 + 49 + @field_validator("output_format") 50 + @classmethod 51 + def validate_output_format(cls, v): 52 + if v and not GeneratorRegistry.is_registered(v): 53 + raise ValueError("Invalid output format") 54 + return v 55 + 56 + 57 + class ReportUploadViewSet(EMRRetrieveMixin, EMRListMixin, EMRBaseViewSet): 58 + database_model = ReportUpload 59 + pydantic_read_model = ReportUploadListSpec 60 + pydantic_retrieve_model = ReportUploadRetrieveSpec 61 + 62 + filter_backends = [DjangoFilterBackend, OrderingFilter] 63 + filterset_class = ReportUploadFilters 64 + ordering_fields = ["created_date", "name"] 65 + 66 + def get_queryset(self): 67 + queryset = super().get_queryset() 68 + if self.action == "list": 69 + if ( 70 + "report_type" not in self.request.GET 71 + or "associating_id" not in self.request.GET 72 + ): 73 + raise PermissionDenied("report_type and associating_id are required") 74 + report_type = self.request.GET.get("report_type") 75 + associating_id = self.request.GET.get("associating_id") 76 + read_report_authorizer(self.request.user, report_type, associating_id) 77 + return queryset.filter( 78 + report_type=report_type, 79 + associating_id=associating_id, 80 + ) 81 + return queryset 82 + 83 + def authorize_retrieve(self, model_instance): 84 + read_report_authorizer( 85 + self.request.user, 86 + model_instance.report_type, 87 + model_instance.associating_id, 88 + ) 89 + return super().authorize_retrieve(model_instance) 90 + 91 + def authorize_update(self, request_obj, model_instance): 92 + write_report_authorizer( 93 + self.request.user, model_instance.report_type, model_instance.associating_id 94 + ) 95 + 96 + @extend_schema( 97 + description="Generate a report from a template with patient/encounter data", 98 + request=GenerateReportRequest, 99 + responses={201: "Report generation started"}, 100 + tags=["report"], 101 + ) 102 + @action(detail=False, methods=["POST"]) 103 + def generate(self, request, *args, **kwargs): 104 + request_data = GenerateReportRequest.model_validate(request.data) 105 + 106 + template_id = request_data.template_id 107 + associating_id = request_data.associating_id 108 + 109 + template = get_object_or_404(Template, external_id=template_id) 110 + 111 + output_format = request_data.output_format or template.default_format 112 + 113 + report_authorizer(request.user, template.template_type, associating_id, "write") 114 + 115 + if template.facility and not AuthorizationController.call( 116 + "can_generate_report_from_template", request.user, template.facility 117 + ): 118 + raise PermissionDenied("You are not authorized to generate reports") 119 + 120 + if template.status != "active": 121 + raise ValidationError("Template is not active") 122 + 123 + lock_key = f"{template.template_type}_{associating_id}" 124 + 125 + if request_data.force: 126 + report_utils.clear_lock(lock_key) 127 + 128 + if current_progress := report_utils.get_progress(lock_key): 129 + return Response( 130 + { 131 + "detail": ( 132 + f"Report generation is already in progress for this report, " 133 + f"current progress {current_progress}%" 134 + ) 135 + }, 136 + status=status.HTTP_409_CONFLICT, 137 + ) 138 + 139 + generate_report_task.delay( 140 + template_id=template_id, 141 + report_type=template.template_type, 142 + associating_id=associating_id, 143 + output_format=output_format, 144 + user_id=request.user.id, 145 + ) 146 + 147 + return Response( 148 + status=status.HTTP_201_CREATED, 149 + ) 150 + 151 + class ArchiveRequestSpec(BaseModel): 152 + archive_reason: str 153 + 154 + @extend_schema(request=ArchiveRequestSpec, responses={200: ReportUploadListSpec}) 155 + @action(detail=True, methods=["POST"]) 156 + def archive(self, request, *args, **kwargs): 157 + obj = self.get_object() 158 + request_data = self.ArchiveRequestSpec(**request.data) 159 + report_authorizer(request.user, obj.report_type, obj.associating_id, "write") 160 + obj.is_archived = True 161 + obj.archive_reason = request_data.archive_reason 162 + obj.archived_datetime = timezone.now() 163 + obj.archived_by = request.user 164 + obj.save( 165 + update_fields=[ 166 + "is_archived", 167 + "archive_reason", 168 + "archived_datetime", 169 + "archived_by", 170 + ] 171 + ) 172 + return Response(ReportUploadListSpec.serialize(obj).to_json())
+199
care/emr/api/viewsets/report/template.py
··· 1 + import logging 2 + 3 + from django.conf import settings 4 + from django.db.models import Q 5 + from django_filters import CharFilter, FilterSet 6 + from django_filters.rest_framework import DjangoFilterBackend 7 + from drf_spectacular.utils import extend_schema 8 + from pydantic import BaseModel 9 + from rest_framework import status 10 + from rest_framework.decorators import action 11 + from rest_framework.exceptions import PermissionDenied, ValidationError 12 + from rest_framework.filters import OrderingFilter 13 + from rest_framework.response import Response 14 + 15 + from care.emr.api.viewsets.base import EMRModelViewSet 16 + from care.emr.models.report.template import Template 17 + from care.emr.reports.context_builder import Field, types # noqa 18 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 19 + from care.emr.reports.context_builder.data_points.utils import build_schema 20 + from care.emr.reports.renderer.generators import GeneratorRegistry 21 + from care.emr.reports.renderer.renderer import Renderer 22 + from care.emr.resources.report.template.spec import ( 23 + TemplateCreateSpec, 24 + TemplateReadSpec, 25 + TemplateRetrieveSpec, 26 + TemplateUpdateSpec, 27 + ) 28 + from care.facility.models.facility import Facility 29 + from care.security.authorization.base import AuthorizationController 30 + from care.utils.filters.dummy_filter import DummyBooleanFilter 31 + from care.utils.shortcuts import get_object_or_404 32 + 33 + logger = logging.getLogger(__name__) 34 + 35 + 36 + class TemplateFilters(FilterSet): 37 + name = CharFilter(field_name="name", lookup_expr="icontains") 38 + template_type = CharFilter(field_name="template_type", lookup_expr="exact") 39 + status = CharFilter(field_name="status", lookup_expr="exact") 40 + facility = CharFilter(field_name="facility__external_id", lookup_expr="exact") 41 + facility_only = DummyBooleanFilter() 42 + 43 + 44 + class PreviewTemplateRequest(BaseModel): 45 + template_data: str 46 + output_format: str = "html" 47 + context: str 48 + options: dict = {} 49 + 50 + 51 + class TemplateViewSet(EMRModelViewSet): 52 + lookup_field = "slug" 53 + database_model = Template 54 + pydantic_model = TemplateCreateSpec 55 + pydantic_read_model = TemplateReadSpec 56 + pydantic_update_model = TemplateUpdateSpec 57 + pydantic_retrieve_model = TemplateRetrieveSpec 58 + 59 + filter_backends = [DjangoFilterBackend, OrderingFilter] 60 + filterset_class = TemplateFilters 61 + ordering_fields = ["created_date", "name", "template_type"] 62 + 63 + def authorize_retrieve(self, model_instance): 64 + if model_instance.facility and not AuthorizationController.call( 65 + "can_list_facility_template", self.request.user, model_instance.facility 66 + ): 67 + raise PermissionDenied("You do not have permission to read templates") 68 + 69 + def authorize_update(self, request_obj, model_instance): 70 + if model_instance.facility and not AuthorizationController.call( 71 + "can_write_facility_template", self.request.user, model_instance.facility 72 + ): 73 + raise PermissionDenied("You do not have permission to write templates") 74 + if not model_instance.facility and not self.request.user.is_superuser: 75 + raise PermissionDenied("You do not have permission to write templates") 76 + 77 + def authorize_create(self, instance): 78 + if instance.facility: 79 + facility = get_object_or_404(Facility, external_id=instance.facility) 80 + if not AuthorizationController.call( 81 + "can_write_facility_template", self.request.user, facility 82 + ): 83 + raise PermissionDenied("You do not have permission to write templates") 84 + if not instance.facility and not self.request.user.is_superuser: 85 + raise PermissionDenied("You do not have permission to write templates") 86 + 87 + def recalculate_slug(self, instance): 88 + if instance.facility: 89 + instance.slug = Template.calculate_slug_from_facility( 90 + instance.facility.external_id, instance.slug 91 + ) 92 + else: 93 + instance.slug = Template.calculate_slug_from_instance(instance.slug) 94 + 95 + def perform_create(self, instance): 96 + self.recalculate_slug(instance) 97 + super().perform_create(instance) 98 + 99 + def perform_update(self, instance): 100 + self.recalculate_slug(instance) 101 + return super().perform_update(instance) 102 + 103 + def validate_data(self, instance, model_obj=None): 104 + queryset = Template.objects.all() 105 + facility = None 106 + if model_obj: 107 + queryset = queryset.exclude(id=model_obj.id) 108 + facility = ( 109 + str(model_obj.facility.external_id) if model_obj.facility else None 110 + ) 111 + else: 112 + facility = instance.facility 113 + 114 + if facility: 115 + slug = Template.calculate_slug_from_facility(facility, instance.slug_value) 116 + else: 117 + slug = Template.calculate_slug_from_instance(instance.slug_value) 118 + 119 + queryset = queryset.filter(slug__iexact=slug) 120 + if queryset.exists(): 121 + raise ValidationError("Slug already exists.") 122 + 123 + return super().validate_data(instance, model_obj) 124 + 125 + def get_queryset(self): 126 + queryset = super().get_queryset() 127 + if self.action == "list": 128 + if "facility" in self.request.GET: 129 + facility = get_object_or_404( 130 + Facility, external_id=self.request.GET["facility"] 131 + ) 132 + if not AuthorizationController.call( 133 + "can_list_facility_template", self.request.user, facility 134 + ): 135 + raise PermissionDenied( 136 + "You do not have permission to read templates" 137 + ) 138 + if self.request.GET.get("facility_only", "false").lower() == "true": 139 + queryset = queryset.filter(facility=facility) 140 + else: 141 + queryset = queryset.filter( 142 + Q(facility=facility) | Q(facility__isnull=True) 143 + ) 144 + else: 145 + queryset = queryset.filter(facility__isnull=True) 146 + return queryset 147 + 148 + @extend_schema(responses={200: "Success"}, tags=["template"]) 149 + @action(detail=False, methods=["GET"], url_path="schema") 150 + def get_schema(self, request, *args, **kwargs): 151 + if not AuthorizationController.call("can_view_template_schema", request.user): 152 + raise PermissionDenied( 153 + "You do not have permission to access template schema" 154 + ) 155 + try: 156 + return Response(build_schema()) 157 + except Exception as e: 158 + logger.exception("Failed to generate schema: %s", e) 159 + return Response( 160 + {"error": "Failed to generate schema"}, 161 + status=status.HTTP_500_INTERNAL_SERVER_ERROR, 162 + ) 163 + 164 + @extend_schema( 165 + request=PreviewTemplateRequest, responses={200: "Success"}, tags=["template"] 166 + ) 167 + @action(detail=False, methods=["POST"]) 168 + def preview(self, request, *args, **kwargs): 169 + AuthorizationController.call("can_preview_template", request.user) 170 + 171 + request_data = PreviewTemplateRequest.model_validate(request.data) 172 + 173 + if not GeneratorRegistry.is_registered(request_data.output_format): 174 + raise ValidationError("Invalid output format") 175 + 176 + if not DataPointRegistry.is_registered(request_data.context): 177 + raise ValidationError("Invalid context") 178 + 179 + try: 180 + generator_class = GeneratorRegistry.get(request_data.output_format) 181 + generator = generator_class() 182 + 183 + options_model = generator_class.options_model 184 + validated_options = options_model.model_validate(request_data.options) 185 + 186 + context_class = DataPointRegistry.get(request_data.context) 187 + preview_context = context_class(is_preview=True) 188 + context_dict = {context_class.context_key: preview_context} 189 + 190 + rendered_content = Renderer(generator).render( 191 + request_data.template_data, context_dict, validated_options 192 + ) 193 + 194 + return generator.get_http_response(rendered_content) 195 + 196 + except Exception as e: 197 + if settings.DEBUG: 198 + raise e 199 + raise ValidationError("Preview generation failed") from e
+1
care/emr/apps.py
··· 7 7 verbose_name = _("Electronic Medical Record") 8 8 9 9 def ready(self): 10 + import care.emr.reports.context_builder 10 11 import care.emr.signals # noqa F401 11 12 from care.utils.evaluators.evaluation_metric import EvaluationMetricBase # noqa
+71
care/emr/migrations/0040_template_reportupload.py
··· 1 + # Generated by Django 5.1.4 on 2025-11-28 16:02 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', '0039_formsubmission_questionnaireresponse_form_submission'), 13 + ('facility', '0478_facility_discount_codes_and_more'), 14 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 + ] 16 + 17 + operations = [ 18 + migrations.CreateModel( 19 + name='Template', 20 + fields=[ 21 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), 23 + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), 24 + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), 25 + ('deleted', models.BooleanField(db_index=True, default=False)), 26 + ('history', models.JSONField(default=dict)), 27 + ('meta', models.JSONField(default=dict)), 28 + ('slug', models.CharField(max_length=255)), 29 + ('name', models.CharField(max_length=255)), 30 + ('status', models.CharField(max_length=255)), 31 + ('template_data', models.TextField()), 32 + ('template_type', models.CharField(max_length=255)), 33 + ('default_format', models.CharField(max_length=255)), 34 + ('context', models.CharField(default='encounter_base', max_length=100)), 35 + ('description', models.TextField(blank=True, default='')), 36 + ('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)), 37 + ('facility', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='facility.facility')), 38 + ('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)), 39 + ], 40 + options={ 41 + 'abstract': False, 42 + }, 43 + ), 44 + migrations.CreateModel( 45 + name='ReportUpload', 46 + fields=[ 47 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), 49 + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), 50 + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), 51 + ('deleted', models.BooleanField(db_index=True, default=False)), 52 + ('history', models.JSONField(default=dict)), 53 + ('meta', models.JSONField(default=dict)), 54 + ('name', models.CharField(max_length=2000)), 55 + ('internal_name', models.CharField(max_length=2000)), 56 + ('associating_id', models.CharField(max_length=100)), 57 + ('upload_completed', models.BooleanField(default=False)), 58 + ('report_type', models.CharField(max_length=50)), 59 + ('is_archived', models.BooleanField(default=False)), 60 + ('archive_reason', models.TextField(blank=True)), 61 + ('archived_datetime', models.DateTimeField(blank=True, null=True)), 62 + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='archived_reports', to=settings.AUTH_USER_MODEL)), 63 + ('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)), 64 + ('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)), 65 + ('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='emr.template')), 66 + ], 67 + options={ 68 + 'abstract': False, 69 + }, 70 + ), 71 + ]
+18
care/emr/migrations/0041_template_options.py
··· 1 + # Generated by Django 5.1.4 on 2025-12-07 21:39 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0040_template_reportupload'), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name='template', 15 + name='options', 16 + field=models.JSONField(default=dict), 17 + ), 18 + ]
+14
care/emr/migrations/0042_merge_20251209_2240.py
··· 1 + # Generated by Django 5.1.4 on 2025-12-09 17:10 2 + 3 + from django.db import migrations 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('emr', '0040_alter_observation_interpretation'), 10 + ('emr', '0041_template_options'), 11 + ] 12 + 13 + operations = [ 14 + ]
+47
care/emr/models/questionnaire.py
··· 55 55 internal_organization_cache = ArrayField(models.IntegerField(), default=list) 56 56 tags = ArrayField(models.IntegerField(), default=list) 57 57 58 + def get_questions_by_id(self) -> dict: 59 + cached_result = getattr(self, "_questions_by_id_cache", None) 60 + if cached_result is not None: 61 + return cached_result 62 + 63 + questions_dict = {} 64 + 65 + def process_question(question: dict): 66 + question_id = question.get("id") 67 + if question_id: 68 + questions_dict[str(question_id)] = question 69 + 70 + nested_questions = question.get("questions", []) 71 + if nested_questions: 72 + for nested_question in nested_questions: 73 + process_question(nested_question) 74 + 75 + questions_list = self.questions if isinstance(self.questions, list) else [] 76 + for question in questions_list: 77 + process_question(question) 78 + 79 + self._questions_by_id_cache = questions_dict 80 + return questions_dict 81 + 58 82 59 83 class FormSubmission(EMRBaseModel): 60 84 questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) ··· 82 106 FormSubmission, on_delete=models.CASCADE, null=True, blank=True 83 107 ) 84 108 # TODO : Add index for subject_id and subject_type in descending order 109 + 110 + def render_responses(self): 111 + """ 112 + Convert the responses into a human understandable JSON 113 + with current questionnaire data 114 + """ 115 + responses = self.responses 116 + structured_responses = [] 117 + if not responses: 118 + return structured_responses 119 + if not self.questionnaire: 120 + return structured_responses 121 + questions_by_id = self.questionnaire.get_questions_by_id() 122 + for response in responses: 123 + if response["question_id"] not in questions_by_id: 124 + continue 125 + structured_responses.append( 126 + { 127 + "answer": response, 128 + "question": questions_by_id[response["question_id"]], 129 + } 130 + ) 131 + return structured_responses 85 132 86 133 87 134 class QuestionnaireOrganization(EMRBaseModel):
care/emr/models/report/__init__.py

This is a binary file and will not be displayed.

+56
care/emr/models/report/report_upload.py
··· 1 + import time 2 + from uuid import uuid4 3 + 4 + from django.db import models 5 + 6 + from care.emr.models import EMRBaseModel 7 + from care.emr.utils.file_manager import S3FilesManager 8 + from care.users.models import User 9 + from care.utils.csp.config import BucketType 10 + from care.utils.models.validators import parse_file_extension 11 + 12 + 13 + class ReportUpload(EMRBaseModel): 14 + template = models.ForeignKey("emr.Template", on_delete=models.PROTECT) 15 + 16 + name = models.CharField(max_length=2000) 17 + internal_name = models.CharField(max_length=2000) 18 + associating_id = models.CharField(max_length=100, blank=False, null=False) 19 + upload_completed = models.BooleanField(default=False) 20 + report_type = models.CharField(max_length=50) 21 + 22 + # Archived metadata 23 + is_archived = models.BooleanField(default=False) 24 + archive_reason = models.TextField(blank=True) 25 + archived_datetime = models.DateTimeField(blank=True, null=True) 26 + archived_by = models.ForeignKey( 27 + User, 28 + on_delete=models.PROTECT, 29 + null=True, 30 + blank=True, 31 + related_name="archived_reports", 32 + ) 33 + 34 + files_manager = S3FilesManager(BucketType.REPORT) 35 + 36 + @property 37 + def file_type(self): 38 + """Alias for report_type to maintain compatibility with S3FilesManager""" 39 + return self.report_type 40 + 41 + def get_extension(self): 42 + extensions = parse_file_extension(self.internal_name) 43 + return f".{".".join(extensions)}" if extensions else "" 44 + 45 + def save(self, *args, **kwargs): 46 + """ 47 + Create a random internal name to internally manage the file 48 + This is used as an intermediate step to avoid leakage of PII in-case of data leak 49 + """ 50 + skip_internal_name = kwargs.pop("skip_internal_name", False) 51 + if (not self.internal_name or not self.id) and not skip_internal_name: 52 + internal_name = str(uuid4()) + str(int(time.time())) 53 + if self.internal_name and (extension := self.get_extension()): 54 + internal_name = f"{internal_name}{extension}" 55 + self.internal_name = internal_name 56 + return super().save(*args, **kwargs)
+21
care/emr/models/report/template.py
··· 1 + from django.db import models 2 + 3 + from care.emr.models import SlugBaseModel 4 + 5 + 6 + class Template(SlugBaseModel): 7 + facility = models.ForeignKey( 8 + "facility.Facility", 9 + on_delete=models.PROTECT, 10 + null=True, 11 + blank=True, 12 + ) 13 + slug = models.CharField(max_length=255) 14 + name = models.CharField(max_length=255) 15 + status = models.CharField(max_length=255) 16 + template_data = models.TextField() 17 + template_type = models.CharField(max_length=255) 18 + default_format = models.CharField(max_length=255) 19 + context = models.CharField(max_length=100, default="encounter_base") 20 + description = models.TextField(blank=True, default="") 21 + options = models.JSONField(default=dict)
+1
care/emr/reports/__init__.py
··· 1 + from .report_types import * # noqa F403
+12
care/emr/reports/authorizers/__init__.py
··· 1 + from . import discharge_summary 2 + from .base import BaseReportAuthorizer 3 + from .discharge_summary import DischargeSummaryReportAuthorizer 4 + from .encounter import EncounterReportAuthorizer 5 + from .utils import report_authorizer 6 + 7 + __all__ = [ 8 + "BaseReportAuthorizer", 9 + "DischargeSummaryReportAuthorizer", 10 + "EncounterReportAuthorizer", 11 + "report_authorizer", 12 + ]
+11
care/emr/reports/authorizers/base.py
··· 1 + from abc import ABC, abstractmethod 2 + 3 + 4 + class BaseReportAuthorizer(ABC): 5 + @abstractmethod 6 + def authorize_read(self, user, associating_id: str) -> bool: 7 + pass 8 + 9 + @abstractmethod 10 + def authorize_write(self, user, associating_id: str) -> bool: 11 + pass
+5
care/emr/reports/authorizers/discharge_summary.py
··· 1 + from care.emr.reports.authorizers.encounter import EncounterReportAuthorizer 2 + 3 + 4 + class DischargeSummaryReportAuthorizer(EncounterReportAuthorizer): 5 + pass
+18
care/emr/reports/authorizers/encounter.py
··· 1 + from care.emr.models import Encounter 2 + from care.emr.reports.authorizers.base import BaseReportAuthorizer 3 + from care.security.authorization import AuthorizationController 4 + from care.utils.shortcuts import get_object_or_404 5 + 6 + 7 + class EncounterReportAuthorizer(BaseReportAuthorizer): 8 + def authorize_read(self, user, associating_id: str) -> bool: 9 + encounter_obj = get_object_or_404(Encounter, external_id=associating_id) 10 + return AuthorizationController.call( 11 + "can_view_clinical_data", user, encounter_obj.patient 12 + ) or AuthorizationController.call("can_view_encounter_obj", user, encounter_obj) 13 + 14 + def authorize_write(self, user, associating_id: str) -> bool: 15 + encounter_obj = get_object_or_404(Encounter, external_id=associating_id) 16 + return AuthorizationController.call( 17 + "can_update_encounter_obj", user, encounter_obj 18 + )
+45
care/emr/reports/authorizers/utils.py
··· 1 + from rest_framework.exceptions import PermissionDenied 2 + 3 + from care.emr.reports.report_type_registry import ReportTypeRegistry 4 + 5 + 6 + def report_authorizer(user, report_type: str, associating_id: str, permission: str): 7 + """ 8 + Authorize user access to a report based on report type and permission. 9 + 10 + Args: 11 + user: User requesting access 12 + report_type: Type of report (from ReportTypeRegistry) 13 + associating_id: UUID of the associated resource 14 + permission: Either "read" or "write" 15 + 16 + Raises: 17 + PermissionDenied: If user doesn't have permission 18 + """ 19 + try: 20 + config = ReportTypeRegistry.get(report_type) 21 + except KeyError: 22 + msg = f"Invalid report type: {report_type}" 23 + raise PermissionDenied(msg) from KeyError 24 + 25 + authorizer = config.authorizer_class() 26 + 27 + if permission == "read": 28 + allowed = authorizer.authorize_read(user, associating_id) 29 + elif permission == "write": 30 + allowed = authorizer.authorize_write(user, associating_id) 31 + else: 32 + msg = f"Invalid permission type: {permission}. Must be 'read' or 'write'" 33 + raise ValueError(msg) 34 + 35 + if not allowed: 36 + msg = f"Cannot {permission} report of type {report_type}" 37 + raise PermissionDenied(msg) 38 + 39 + 40 + def read_report_authorizer(user, report_type: str, associating_id: str): 41 + return report_authorizer(user, report_type, associating_id, "read") 42 + 43 + 44 + def write_report_authorizer(user, report_type: str, associating_id: str): 45 + return report_authorizer(user, report_type, associating_id, "write")
+1
care/emr/reports/context_builder/__init__.py
··· 1 + from .data_points.encounter import * # noqa F403
+31
care/emr/reports/context_builder/data_point_registry.py
··· 1 + class DataPointRegistry: 2 + _data_points: dict[str, dict] = {} 3 + _model_mapping: dict[str, dict] = {} 4 + 5 + @classmethod 6 + def register(cls, data_point): 7 + cls._data_points[data_point.__slug__] = data_point 8 + 9 + @classmethod 10 + def get(cls, slug: str): 11 + return cls._data_points.get(slug) 12 + 13 + @classmethod 14 + def get_all(cls): 15 + return cls._data_points.copy() 16 + 17 + @classmethod 18 + def is_registered(cls, slug: str) -> bool: 19 + return slug in cls._data_points 20 + 21 + @classmethod 22 + def clear(cls): 23 + cls._data_points.clear() 24 + 25 + @classmethod 26 + def get_contexts_by_model(cls, model): 27 + return [ 28 + context.__slug__ 29 + for context in cls._data_points.values() 30 + if context.__associating_model__ == model 31 + ]
care/emr/reports/context_builder/data_points/__init__.py

This is a binary file and will not be displayed.

+91
care/emr/reports/context_builder/data_points/allergy_intolerance.py
··· 1 + from django_filters import rest_framework as filters 2 + 3 + from care.emr.models.allergy_intolerance import AllergyIntolerance 4 + from care.emr.reports.context_builder.data_points.base import ( 5 + Field, 6 + QuerysetContextBuilder, 7 + ) 8 + from care.utils.filters.multiselect import MultiSelectFilter 9 + 10 + CLINICAL_STATUS_DISPLAY = { 11 + "active": "Active", 12 + "inactive": "Inactive", 13 + "resolved": "Resolved", 14 + } 15 + 16 + VERIFICATION_STATUS_DISPLAY = { 17 + "unconfirmed": "Unconfirmed", 18 + "confirmed": "Confirmed", 19 + "refuted": "Refuted", 20 + "entered_in_error": "Entered in Error", 21 + } 22 + 23 + CRITICALITY_DISPLAY = { 24 + "low": "Low", 25 + "high": "High", 26 + "unable_to_assess": "Unable to Assess", 27 + } 28 + 29 + 30 + class AllergyIntoleranceReportFilter(filters.FilterSet): 31 + clinical_status = MultiSelectFilter(field_name="clinical_status") 32 + exclude_clinical_status = MultiSelectFilter( 33 + field_name="clinical_status", exclude=True 34 + ) 35 + verification_status = MultiSelectFilter(field_name="verification_status") 36 + exclude_verification_status = MultiSelectFilter( 37 + field_name="verification_status", exclude=True 38 + ) 39 + 40 + 41 + class AllergyIntoleranceContextBuilder(QuerysetContextBuilder): 42 + filterset_class = AllergyIntoleranceReportFilter 43 + __filterset_backends__ = [filters.DjangoFilterBackend] 44 + 45 + clinical_status = Field( 46 + display="Clinical Status", 47 + preview_value="Active", 48 + mapping=lambda a: CLINICAL_STATUS_DISPLAY.get( 49 + a.clinical_status, a.clinical_status.title() 50 + ) 51 + if a.clinical_status 52 + else "", 53 + description="Clinical status of the allergy or intolerance", 54 + ) 55 + verification_status = Field( 56 + display="Verification Status", 57 + preview_value="Confirmed", 58 + mapping=lambda a: VERIFICATION_STATUS_DISPLAY.get( 59 + a.verification_status, a.verification_status.title() 60 + ) 61 + if a.verification_status 62 + else "", 63 + description="Verification status of the allergy or intolerance", 64 + ) 65 + criticality = Field( 66 + display="Criticality", 67 + preview_value="High", 68 + mapping=lambda a: CRITICALITY_DISPLAY.get(a.criticality, a.criticality.title()) 69 + if a.criticality 70 + else "", 71 + description="Criticality of the allergy or intolerance", 72 + ) 73 + name = Field( 74 + display="Name", 75 + mapping=lambda a: a.code.get("display") if a.code else "", 76 + preview_value="Fezolinetant", 77 + description="Name representing the allergy or intolerance", 78 + ) 79 + note = Field( 80 + display="Note", 81 + preview_value="Patient reports severe reaction to peanuts.", 82 + description="Additional notes about the allergy or intolerance", 83 + ) 84 + last_occurrence = Field( 85 + display="Occurrence", 86 + preview_value="2025-12-03 12:09:13.880000+00:00", 87 + description="The last occurrence date and time of the allergy or intolerance", 88 + ) 89 + 90 + def get_context(self): 91 + return AllergyIntolerance.objects.filter(encounter=self.parent_context)
+133
care/emr/reports/context_builder/data_points/base.py
··· 1 + import random 2 + from collections.abc import Callable 3 + from types import SimpleNamespace 4 + from typing import Any 5 + 6 + 7 + class Field: 8 + DEFAULT_NONE_VALUE = "" 9 + 10 + def __init__( 11 + self, 12 + display: str = "", 13 + preview_value: Any = None, 14 + preview_fn: Callable | None = None, 15 + mapping=None, 16 + target_context=None, 17 + description: str = "", 18 + field_type: str = "string", 19 + ): 20 + self.display = display 21 + self.mapping = mapping 22 + self.preview_value = preview_value 23 + self.description = description 24 + self.type = field_type 25 + self.target_context = target_context 26 + self.preview_fn = preview_fn 27 + 28 + def get_value(self, parent_context, parent_attribute, is_preview): # noqa PLR0911 29 + if self.target_context: 30 + return self.target_context( 31 + parent_context, parent_attribute, is_preview=is_preview 32 + ) 33 + 34 + if is_preview: 35 + if self.preview_value: 36 + return self.preview_value 37 + if self.preview_fn: 38 + return self.preview_fn() 39 + return self.DEFAULT_NONE_VALUE 40 + 41 + if isinstance(self.mapping, str): 42 + value = getattr(parent_context, self.mapping, None) 43 + elif callable(self.mapping): 44 + value = self.mapping(parent_context) 45 + else: 46 + value = getattr(parent_context, parent_attribute, None) 47 + 48 + if value is None: 49 + return self.DEFAULT_NONE_VALUE 50 + 51 + if isinstance(value, (list, dict)): 52 + return value 53 + 54 + return str(value) 55 + 56 + 57 + class ContextBuilderBase: 58 + __context_type__ = "" 59 + standalone_context = False 60 + context_key = "" 61 + # Standalone Contexts are contexts which can generate their own reports 62 + __slug__ = "" 63 + __display_name__ = "" 64 + __description__ = "" 65 + # Used to identify the right context 66 + 67 + def __init__( 68 + self, parent_context=None, parent_attribute=None, context=None, is_preview=None 69 + ): 70 + self.parent_context = parent_context 71 + self.parent_attribute = parent_attribute 72 + if not context and not bool(is_preview): 73 + context = self.get_context() 74 + self.context = context 75 + self.is_preview = bool(is_preview) 76 + 77 + def get_context(self): 78 + return self.parent_context 79 + 80 + def __getattribute__(self, name: str) -> Any: 81 + val = super().__getattribute__(name) 82 + if val and isinstance(val, Field): 83 + return val.get_value(self.context, name, self.is_preview) 84 + return val 85 + 86 + def get_iterable(self, qs): 87 + return iter(self.__class__(context=c, is_preview=self.is_preview) for c in qs) 88 + 89 + def filter(self, **kwargs): 90 + if self.is_preview: 91 + limit = kwargs.get("limit", 4) 92 + return [ 93 + self.__class__(is_preview=True) 94 + for c in range(random.randint(1, limit)) # noqa S311 95 + ] 96 + return self._filter(**kwargs) 97 + 98 + 99 + class QuerysetContextBuilder(ContextBuilderBase): 100 + __context_type__ = "queryset" 101 + 102 + filterset_class = None 103 + __filterset_backends__ = [] 104 + 105 + def __iter__(self): 106 + if self.is_preview: 107 + return iter( 108 + self.__class__(is_preview=True) 109 + for _ in range(random.randint(1, 4)) # noqa: S311 110 + ) 111 + return self.get_iterable(self.context) 112 + 113 + def perform_extra_filters(self, qs, **kwargs): 114 + return qs 115 + 116 + def _filter(self, **kwargs): 117 + qs = self.context 118 + for filterset_class in self.__filterset_backends__: 119 + qs = filterset_class().filter_queryset( 120 + SimpleNamespace(query_params=kwargs), qs, self 121 + ) 122 + qs = self.perform_extra_filters(qs, **kwargs) 123 + if "limit" in kwargs: 124 + qs = qs[: kwargs["limit"]] 125 + return self.get_iterable(qs) 126 + 127 + 128 + class SingleObjectContextBuilder(ContextBuilderBase): 129 + __context_type__ = "single_object" 130 + 131 + 132 + class ListContextBuilder(ContextBuilderBase): 133 + __context_type__ = "list"
+90
care/emr/reports/context_builder/data_points/diagnosis.py
··· 1 + from django_filters import rest_framework as filters 2 + 3 + from care.emr.models.condition import Condition 4 + from care.emr.reports.context_builder.data_points.base import ( 5 + Field, 6 + QuerysetContextBuilder, 7 + ) 8 + from care.emr.resources.condition.spec import CategoryChoices 9 + from care.utils.filters.multiselect import MultiSelectFilter 10 + 11 + CLINICAL_STATUS_DISPLAY = { 12 + "active": "Active", 13 + "recurrence": "Recurrence", 14 + "relapse": "Relapse", 15 + "inactive": "Inactive", 16 + "remission": "Remission", 17 + "resolved": "Resolved", 18 + "unknown": "Unknown", 19 + } 20 + 21 + VERIFICATION_STATUS_DISPLAY = { 22 + "unconfirmed": "Unconfirmed", 23 + "provisional": "Provisional", 24 + "differential": "Differential", 25 + "confirmed": "Confirmed", 26 + "refuted": "Refuted", 27 + "entered_in_error": "Entered in Error", 28 + } 29 + 30 + 31 + class DiagnosisReportFilter(filters.FilterSet): 32 + clinical_status = MultiSelectFilter(field_name="clinical_status") 33 + exclude_clinical_status = MultiSelectFilter( 34 + field_name="clinical_status", exclude=True 35 + ) 36 + verification_status = MultiSelectFilter(field_name="verification_status") 37 + exclude_verification_status = MultiSelectFilter( 38 + field_name="verification_status", exclude=True 39 + ) 40 + 41 + 42 + class DiagnosisContextBuilder(QuerysetContextBuilder): 43 + filterset_class = DiagnosisReportFilter 44 + __filterset_backends__ = [filters.DjangoFilterBackend] 45 + 46 + clinical_status = Field( 47 + display="Clinical Status", 48 + preview_value="Active", 49 + mapping=lambda c: CLINICAL_STATUS_DISPLAY.get( 50 + c.clinical_status, c.clinical_status.title() 51 + ) 52 + if c.clinical_status 53 + else "", 54 + description="Clinical status of the condition", 55 + ) 56 + verification_status = Field( 57 + display="Verification Status", 58 + preview_value="Confirmed", 59 + mapping=lambda c: VERIFICATION_STATUS_DISPLAY.get( 60 + c.verification_status, c.verification_status.title() 61 + ) 62 + if c.verification_status 63 + else "", 64 + description="Verification status of the condition", 65 + ) 66 + name = Field( 67 + display="Name", 68 + mapping=lambda c: c.code.get("display", "") if c.code else "", 69 + preview_value="Gastroptosis", 70 + description="Name of the diagnosis", 71 + ) 72 + 73 + onset = Field( 74 + display="Onset", 75 + mapping=lambda c: c.onset.get("onset_datetime", "") if c.onset else "", 76 + preview_value="2025-11-30T18:30:00Z", 77 + description="The onset date of the diagnosis", 78 + ) 79 + 80 + note = Field( 81 + display="Note", 82 + preview_value="", 83 + description="Additional notes about the diagnosis", 84 + ) 85 + 86 + def get_context(self): 87 + return Condition.objects.filter( 88 + encounter=self.parent_context, 89 + category=CategoryChoices.encounter_diagnosis.value, 90 + )
+154
care/emr/reports/context_builder/data_points/encounter.py
··· 1 + from types import SimpleNamespace 2 + 3 + from care.emr.models.encounter import Encounter 4 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 5 + from care.emr.reports.context_builder.data_points.allergy_intolerance import ( 6 + AllergyIntoleranceContextBuilder, 7 + ) 8 + from care.emr.reports.context_builder.data_points.base import ( 9 + Field, 10 + QuerysetContextBuilder, 11 + SingleObjectContextBuilder, 12 + ) 13 + from care.emr.reports.context_builder.data_points.diagnosis import ( 14 + DiagnosisContextBuilder, 15 + ) 16 + from care.emr.reports.context_builder.data_points.medication import ( 17 + MedicationPrescriptionContextBuilder, 18 + ) 19 + from care.emr.reports.context_builder.data_points.questionnaire import ( 20 + QuestionnaireContextBuilder, 21 + ) 22 + from care.emr.reports.context_builder.data_points.service_request import ( 23 + ServiceRequestDataPointBuilder, 24 + ) 25 + from care.emr.reports.context_builder.data_points.symptom import SymptomsContextBuilder 26 + from care.emr.reports.context_builder.data_points.user import SingleUserIdContextBuilder 27 + 28 + STATUS_DISPLAY = { 29 + "planned": "Planned", 30 + "in_progress": "In Progress", 31 + "completed": "Completed", 32 + "cancelled": "Cancelled", 33 + "entered_in_error": "Entered in Error", 34 + } 35 + 36 + 37 + class EncounterCareTeamContextBuilder(QuerysetContextBuilder): 38 + def get_context(self): 39 + return self.parent_context.care_team 40 + 41 + user = Field( 42 + display="User", 43 + target_context=SingleUserIdContextBuilder, 44 + preview_value="", 45 + description="User who is part of the encounter care team", 46 + ) 47 + role = Field( 48 + display="Role", 49 + preview_value={"display": "Test Role"}, 50 + description="Role of the user in the encounter care team", 51 + ) 52 + 53 + def __iter__(self): 54 + if self.is_preview: 55 + return iter(self.__class__(is_preview=True) for c in range(3)) 56 + return iter( 57 + self.__class__(context=SimpleNamespace(user=c["user_id"], role=c["role"])) 58 + for c in self.context 59 + ) 60 + 61 + 62 + class EncounterPatientContextBuilder(SingleObjectContextBuilder): 63 + def get_context(self): 64 + return self.parent_context.patient 65 + 66 + name = Field( 67 + display="Patient Name", 68 + preview_value="John Doe", 69 + description="Full name of the patient", 70 + ) 71 + age = Field( 72 + display="Patient Age", 73 + mapping=lambda p: p.get_age(), 74 + preview_value="30 Y", 75 + description="Age of the patient", 76 + ) 77 + 78 + gender = Field( 79 + display="Patient Gender", 80 + mapping=lambda p: p.gender, 81 + preview_value="Male", 82 + description="Gender of the patient", 83 + ) 84 + 85 + 86 + class EncounterReportContextBase(SingleObjectContextBuilder): 87 + standalone_context = True 88 + __slug__ = "encounter_base" 89 + __associating_model__ = Encounter 90 + __display_name__ = "Encounter Report" 91 + __description__ = "Report context for encounter-based reports" 92 + context_key = "encounter" 93 + 94 + status = Field( 95 + display="Encounter Status", 96 + mapping=lambda e: STATUS_DISPLAY.get( 97 + e.status, e.status.title() if e.status else "" 98 + ), 99 + preview_value="In Progress", 100 + description="Current status of the encounter", 101 + ) 102 + symptoms = Field( 103 + target_context=SymptomsContextBuilder, 104 + display="Symptoms", 105 + preview_value="", 106 + description="Symptoms of the encounter", 107 + ) 108 + allergy_intolerances = Field( 109 + target_context=AllergyIntoleranceContextBuilder, 110 + display="Allergy Intolerances", 111 + preview_value="", 112 + description="Allergy intolerances of the encounter", 113 + ) 114 + diagnoses = Field( 115 + target_context=DiagnosisContextBuilder, 116 + display="Diagnoses", 117 + preview_value="", 118 + description="Diagnoses of the encounter", 119 + ) 120 + care_team = Field( 121 + target_context=EncounterCareTeamContextBuilder, 122 + display="Care Team", 123 + preview_value="", 124 + description="Care team of the encounter", 125 + ) 126 + questionnaire_responses = Field( 127 + target_context=QuestionnaireContextBuilder, 128 + display="Questionnaire Responses", 129 + preview_value="", 130 + description="Questionnaire responses of the encounter", 131 + ) 132 + 133 + medication_prescriptions = Field( 134 + display="Medication Prescriptions", 135 + target_context=MedicationPrescriptionContextBuilder, 136 + preview_value="", 137 + description="Medication prescriptions of the encounter", 138 + ) 139 + patient = Field( 140 + display="Patient Details", 141 + target_context=EncounterPatientContextBuilder, 142 + preview_value="", 143 + description="Details of the patient associated with the encounter", 144 + ) 145 + 146 + service_requests = Field( 147 + display="Service Requests", 148 + target_context=ServiceRequestDataPointBuilder, 149 + preview_value="", 150 + description="Service requests associated with the encounter", 151 + ) 152 + 153 + 154 + DataPointRegistry.register(EncounterReportContextBase)
+202
care/emr/reports/context_builder/data_points/medication.py
··· 1 + from django_filters import rest_framework as filters 2 + 3 + from care.emr.models.medication_request import ( 4 + MedicationRequest, 5 + MedicationRequestPrescription, 6 + ) 7 + from care.emr.reports.context_builder.data_points.base import ( 8 + Field, 9 + QuerysetContextBuilder, 10 + ) 11 + from care.emr.reports.context_builder.data_points.user import ( 12 + SingleUserRelatedContextBuilder, 13 + ) 14 + 15 + STATUS_DISPLAY = { 16 + "active": "Active", 17 + "on_hold": "On Hold", 18 + "cancelled": "Cancelled", 19 + "completed": "Completed", 20 + "entered_in_error": "Entered in Error", 21 + "stopped": "Stopped", 22 + "draft": "Draft", 23 + "unknown": "Unknown", 24 + } 25 + 26 + INTENT_DISPLAY = { 27 + "proposal": "Proposal", 28 + "plan": "Plan", 29 + "order": "Order", 30 + "original_order": "Original Order", 31 + "reflex_order": "Reflex Order", 32 + "filler_order": "Filler Order", 33 + "instance_order": "Instance Order", 34 + "option": "Option", 35 + } 36 + 37 + PRIORITY_DISPLAY = { 38 + "routine": "Routine", 39 + "urgent": "Urgent", 40 + "asap": "ASAP", 41 + "stat": "STAT", 42 + } 43 + 44 + 45 + class MedicationRequestReportFilter(filters.FilterSet): 46 + status = filters.CharFilter(lookup_expr="iexact") 47 + intent = filters.CharFilter(lookup_expr="iexact") 48 + priority = filters.CharFilter(lookup_expr="iexact") 49 + 50 + 51 + class MedicationPrescriptionReportFilter(filters.FilterSet): 52 + status = filters.CharFilter(lookup_expr="iexact") 53 + 54 + 55 + class DosageInstructionContextBuilder(QuerysetContextBuilder): 56 + def get_context(self): 57 + return self.parent_context.dosage_instruction 58 + 59 + dosage = Field( 60 + display="Dosage", 61 + mapping=lambda d: ( 62 + f"{int(d.get('dose_and_rate', {}).get('dose_quantity', {}).get('value', 0)) if d.get('dose_and_rate', {}).get('dose_quantity', {}).get('value', 0) % 1 == 0 else d.get('dose_and_rate', {}).get('dose_quantity', {}).get('value', '')} " 63 + f"{d.get('dose_and_rate', {}).get('dose_quantity', {}).get('unit', {}).get('display', '')}" 64 + if d.get("dose_and_rate") 65 + and d.get("dose_and_rate", {}).get("dose_quantity") 66 + else "" 67 + ).strip(), 68 + preview_value="2 tablet", 69 + description="Dose quantity for the medication", 70 + ) 71 + 72 + frequency = Field( 73 + display="Frequency", 74 + mapping=lambda d: f"{d.get('timing', {}).get('code', {}).get('display', '')}" 75 + if d.get("timing") and d.get("timing").get("code") 76 + else "", 77 + preview_value="3 times every 1 day", 78 + description="Frequency of the medication dosage", 79 + ) 80 + 81 + duration = Field( 82 + display="Duration", 83 + mapping=lambda d: ( 84 + f"{int(d.get('timing', {}).get('repeat', {}).get('bounds_duration', {}).get('value', 0)) if d.get('timing', {}).get('repeat', {}).get('bounds_duration', {}).get('value', 0) % 1 == 0 else d.get('timing', {}).get('repeat', {}).get('bounds_duration', {}).get('value', '')} " 85 + f"{d.get('timing', {}).get('repeat', {}).get('bounds_duration', {}).get('unit', '')}" 86 + if d.get("timing", {}).get("repeat", {}).get("bounds_duration") 87 + else "" 88 + ).strip(), 89 + preview_value="2 d", 90 + description="Duration for which the medication is to be taken", 91 + ) 92 + 93 + site = Field( 94 + display="Site", 95 + mapping=lambda d: d.get("site", {}).get("display", "") if d.get("site") else "", 96 + preview_value="Structure of product of conception of ectopic pregnancy", 97 + description="Site of administration for the medication", 98 + ) 99 + 100 + method = Field( 101 + display="Method", 102 + mapping=lambda d: d.get("method", {}).get("display", "") 103 + if d.get("method") 104 + else "", 105 + preview_value="Injection", 106 + description="Method of administration for the medication", 107 + ) 108 + route = Field( 109 + display="Route", 110 + mapping=lambda d: d.get("route", {}).get("display", "") 111 + if d.get("route") 112 + else "", 113 + preview_value="Peritumoural route", 114 + description="Route of administration for the medication", 115 + ) 116 + 117 + 118 + class MedicationRequestContextBuilder(QuerysetContextBuilder): 119 + filterset_class = MedicationRequestReportFilter 120 + __filterset_backends__ = [filters.DjangoFilterBackend] 121 + 122 + name = Field( 123 + display="Medication", 124 + preview_value="Morphine sulfate 60 mg oral tablet", 125 + mapping=lambda m: ( 126 + m.medication.get("display", "") 127 + if m.medication 128 + else (m.requested_product.name if m.requested_product else "") 129 + ), 130 + description="Name of the medication", 131 + ) 132 + status = Field( 133 + display="Status", 134 + preview_value="Active", 135 + mapping=lambda m: STATUS_DISPLAY.get(m.status, m.status.title()) 136 + if m.status 137 + else "", 138 + description="Status of the medication", 139 + ) 140 + intent = Field( 141 + display="Intent", 142 + preview_value="Order", 143 + mapping=lambda m: INTENT_DISPLAY.get(m.intent, m.intent.title()) 144 + if m.intent 145 + else "", 146 + description="Intent of the medication", 147 + ) 148 + priority = Field( 149 + display="Priority", 150 + preview_value="Routine", 151 + mapping=lambda m: PRIORITY_DISPLAY.get(m.priority, m.priority.title()) 152 + if m.priority 153 + else "", 154 + description="Priority of the medication", 155 + ) 156 + authored_on = Field( 157 + display="Authored On", 158 + preview_value="2025-11-30T18:30:00Z", 159 + description="Date when the medication was authored", 160 + ) 161 + dosage_instructions = Field( 162 + display="Dosage Instructions", 163 + preview_value="", 164 + description="Dosage instructions for the medication", 165 + target_context=DosageInstructionContextBuilder, 166 + ) 167 + note = Field( 168 + display="Note", 169 + preview_value="", 170 + description="Additional notes about the medication", 171 + ) 172 + 173 + def get_context(self): 174 + return MedicationRequest.objects.filter(prescription=self.parent_context) 175 + 176 + 177 + class MedicationPrescriptionContextBuilder(QuerysetContextBuilder): 178 + filterset_class = MedicationPrescriptionReportFilter 179 + __filterset_backends__ = [filters.DjangoFilterBackend] 180 + 181 + medications = Field( 182 + display="Medication", 183 + preview_value="", 184 + target_context=MedicationRequestContextBuilder, 185 + description="Details of the medication prescription", 186 + ) 187 + status = Field( 188 + display="Status", 189 + preview_value="active", 190 + description="Status of the medication prescription", 191 + ) 192 + prescribed_by = Field( 193 + display="Prescribed By", 194 + preview_value="", 195 + target_context=SingleUserRelatedContextBuilder, 196 + description="Details of the prescriber", 197 + ) 198 + 199 + def get_context(self): 200 + return MedicationRequestPrescription.objects.filter( 201 + encounter=self.parent_context 202 + )
+80
care/emr/reports/context_builder/data_points/questionnaire.py
··· 1 + from types import SimpleNamespace 2 + 3 + from faker import Faker 4 + 5 + from care.emr.models.questionnaire import Questionnaire, QuestionnaireResponse 6 + from care.emr.reports.context_builder.data_points.base import ( 7 + Field, 8 + QuerysetContextBuilder, 9 + ) 10 + 11 + 12 + class QuestionnaireResponsesContextBuilder(QuerysetContextBuilder): 13 + def get_context(self): 14 + return self.parent_context.render_responses() 15 + 16 + question = Field( 17 + display="Question", 18 + preview_value={ 19 + "code": { 20 + "code": "8480-6", 21 + "system": "http://loinc.org", 22 + "display": "Systolic blood pressure", 23 + }, 24 + "text": "Systolic Blood Pressure", 25 + "type": "decimal", 26 + "unit": { 27 + "code": "[degF]", 28 + "system": "http://unitsofmeasure.org", 29 + "display": "degrees Fahrenheit", 30 + }, 31 + }, 32 + description="Question of the questionnaire response", 33 + ) 34 + answer = Field( 35 + display="Answer", 36 + preview_value={"values": [{"value": "123"}]}, 37 + description="Value of the response", 38 + ) 39 + 40 + def __iter__(self): 41 + if self.is_preview: 42 + return iter(self.__class__(is_preview=True) for c in range(3)) 43 + return iter( 44 + self.__class__( 45 + context=SimpleNamespace(answer=c["answer"], question=c["question"]) 46 + ) 47 + for c in self.context 48 + ) 49 + 50 + 51 + class QuestionnaireContextBuilder(QuerysetContextBuilder): 52 + title = Field( 53 + display="Title", 54 + mapping=lambda obj: obj.questionnaire.title, 55 + preview_fn=lambda: Faker().catch_phrase(), 56 + description="Title of the questionnaire", 57 + ) 58 + description = Field( 59 + display="Description", 60 + mapping=lambda obj: obj.questionnaire.description, 61 + preview_fn=lambda: Faker().catch_phrase(), 62 + description="Description of the questionnaire", 63 + ) 64 + responses = Field( 65 + target_context=QuestionnaireResponsesContextBuilder, 66 + display="Responses", 67 + preview_value="", 68 + description="Responses of the questionnaire", 69 + ) 70 + 71 + def get_context(self): 72 + return QuestionnaireResponse.objects.filter( 73 + encounter=self.parent_context, questionnaire__isnull=False 74 + ) 75 + 76 + def perform_extra_filters(self, qs, **kwargs): 77 + if "slug" not in kwargs: 78 + raise ValueError("slug is required") 79 + questionnaire = Questionnaire.objects.get(slug=kwargs["slug"]) 80 + return qs.filter(questionnaire=questionnaire)
+86
care/emr/reports/context_builder/data_points/service_request.py
··· 1 + from django_filters import rest_framework as filters 2 + 3 + from care.emr.models.service_request import ServiceRequest 4 + from care.emr.reports.context_builder.data_points.base import ( 5 + Field, 6 + QuerysetContextBuilder, 7 + ) 8 + from care.emr.reports.context_builder.data_points.user import ( 9 + SingleUserRelatedContextBuilder, 10 + ) 11 + 12 + STATUS_CHOICE = { 13 + "draft": "Draft", 14 + "active": "Active", 15 + "on_hold": "On Hold", 16 + "entered_in_error": "Entered in Error", 17 + "ended": "Ended", 18 + "completed": "Completed", 19 + "revoked": "Revoked", 20 + } 21 + 22 + INTENT_CHOICE = { 23 + "proposal": "Proposal", 24 + "plan": "Plan", 25 + "directive": "Directive", 26 + "order": "Order", 27 + } 28 + 29 + CATEGORY_CHOICE = { 30 + "laboratory": "Laboratory", 31 + "imaging": "Imaging", 32 + "counselling": "Counselling", 33 + "surgical_procedure": "Surgical Procedure", 34 + } 35 + 36 + 37 + class ServiceRequestReportFilterSet(filters.FilterSet): 38 + status = filters.CharFilter(field_name="status", lookup_expr="iexact") 39 + intent = filters.CharFilter(field_name="intent", lookup_expr="iexact") 40 + category = filters.CharFilter(field_name="category", lookup_expr="iexact") 41 + priority = filters.CharFilter(field_name="priority", lookup_expr="iexact") 42 + 43 + 44 + class ServiceRequestDataPointBuilder(QuerysetContextBuilder): 45 + filterset_class = ServiceRequestReportFilterSet 46 + __filterset_backends__ = [filters.DjangoFilterBackend] 47 + 48 + title = Field( 49 + display="Title", 50 + preview_value="Complete Blood Count", 51 + description="Title of the service request", 52 + ) 53 + status = Field( 54 + display="Status", 55 + preview_value="Active", 56 + mapping=lambda sr: STATUS_CHOICE.get(sr.status, sr.status.title()) 57 + if sr.status 58 + else "", 59 + description="Current status of the service request", 60 + ) 61 + intent = Field( 62 + display="Intent", 63 + preview_value="Order", 64 + mapping=lambda sr: INTENT_CHOICE.get(sr.intent, sr.intent.title()) 65 + if sr.intent 66 + else "", 67 + description="Intent of the service request", 68 + ) 69 + category = Field( 70 + display="Category", 71 + preview_value="Laboratory", 72 + mapping=lambda sr: CATEGORY_CHOICE.get(sr.category, sr.category.title()) 73 + if sr.category 74 + else "", 75 + description="Category of the service request", 76 + ) 77 + 78 + requester = Field( 79 + display="Requester", 80 + target_context=SingleUserRelatedContextBuilder, 81 + preview_value="", 82 + description="User who requested the service", 83 + ) 84 + 85 + def get_context(self): 86 + return ServiceRequest.objects.filter(encounter=self.parent_context)
+103
care/emr/reports/context_builder/data_points/symptom.py
··· 1 + from django_filters import rest_framework as filters 2 + 3 + from care.emr.models.condition import Condition 4 + from care.emr.reports.context_builder.data_points.base import ( 5 + Field, 6 + QuerysetContextBuilder, 7 + ) 8 + from care.emr.reports.context_builder.data_points.user import SingleUserIdContextBuilder 9 + from care.emr.resources.condition.spec import CategoryChoices 10 + from care.utils.filters.multiselect import MultiSelectFilter 11 + 12 + CLINICAL_STATUS_DISPLAY = { 13 + "active": "Active", 14 + "recurrence": "Recurrence", 15 + "relapse": "Relapse", 16 + "inactive": "Inactive", 17 + "remission": "Remission", 18 + "resolved": "Resolved", 19 + "unknown": "Unknown", 20 + } 21 + 22 + VERIFICATION_STATUS_DISPLAY = { 23 + "unconfirmed": "Unconfirmed", 24 + "provisional": "Provisional", 25 + "differential": "Differential", 26 + "confirmed": "Confirmed", 27 + "refuted": "Refuted", 28 + "entered_in_error": "Entered in Error", 29 + } 30 + 31 + 32 + class SymptomsReportFilter(filters.FilterSet): 33 + clinical_status = MultiSelectFilter(field_name="clinical_status") 34 + exclude_clinical_status = MultiSelectFilter( 35 + field_name="clinical_status", exclude=True 36 + ) 37 + verification_status = MultiSelectFilter(field_name="verification_status") 38 + exclude_verification_status = MultiSelectFilter( 39 + field_name="verification_status", exclude=True 40 + ) 41 + 42 + 43 + class SymptomsContextBuilder(QuerysetContextBuilder): 44 + filterset_class = SymptomsReportFilter 45 + __filterset_backends__ = [filters.DjangoFilterBackend] 46 + 47 + clinical_status = Field( 48 + display="Clinical Status", 49 + preview_value="Active", 50 + mapping=lambda c: CLINICAL_STATUS_DISPLAY.get( 51 + c.clinical_status, c.clinical_status.title() 52 + ) 53 + if c.clinical_status 54 + else "", 55 + description="Clinical status of the condition", 56 + ) 57 + verification_status = Field( 58 + display="Verification Status", 59 + preview_value="Confirmed", 60 + mapping=lambda c: VERIFICATION_STATUS_DISPLAY.get( 61 + c.verification_status, c.verification_status.title() 62 + ) 63 + if c.verification_status 64 + else "", 65 + description="Verification status of the condition", 66 + ) 67 + name = Field( 68 + display="Name", 69 + mapping=lambda c: c.code.get("display") if c.code else "", 70 + preview_value="Fever", 71 + description="Name of the symptom", 72 + ) 73 + 74 + onset = Field( 75 + display="Onset", 76 + mapping=lambda c: c.onset.get("onset_datetime") if c.onset else "", 77 + preview_value="2025-11-30T18:30:00Z", 78 + description="The onset date of the symptom", 79 + ) 80 + 81 + note = Field( 82 + display="Note", 83 + preview_value="", 84 + description="Additional notes about the symptom", 85 + ) 86 + created_by = Field( 87 + display="Created By", 88 + target_context=SingleUserIdContextBuilder, 89 + preview_value="", 90 + description="User who created the symptom", 91 + ) 92 + updated_by = Field( 93 + display="Updated By", 94 + target_context=SingleUserIdContextBuilder, 95 + preview_value="", 96 + description="User who updated the symptom", 97 + ) 98 + 99 + def get_context(self): 100 + return Condition.objects.filter( 101 + encounter=self.parent_context, 102 + category=CategoryChoices.problem_list_item.value, 103 + )
+30
care/emr/reports/context_builder/data_points/user.py
··· 1 + from faker import Faker 2 + 3 + from care.emr.reports.context_builder.data_points.base import ( 4 + Field, 5 + SingleObjectContextBuilder, 6 + ) 7 + from care.users.models import User 8 + 9 + 10 + class SingleUserRelatedContextBuilder(SingleObjectContextBuilder): 11 + def get_context(self): 12 + return getattr(self.parent_context, self.parent_attribute) 13 + 14 + full_name = Field( 15 + display="Full Name", 16 + mapping="full_name", 17 + preview_fn=lambda: Faker().name(), 18 + description="Full name of the user", 19 + ) 20 + id = Field( 21 + display="ID", 22 + mapping="id", 23 + preview_value="", 24 + description="ID of the user", 25 + ) 26 + 27 + 28 + class SingleUserIdContextBuilder(SingleUserRelatedContextBuilder): 29 + def get_context(self): 30 + return User.objects.get(id=getattr(self.parent_context, self.parent_attribute))
+107
care/emr/reports/context_builder/data_points/utils.py
··· 1 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 2 + from care.emr.reports.context_builder.data_points.base import Field 3 + from care.emr.reports.context_builder.type_registry import FieldTypeRegistry 4 + from care.emr.reports.renderer.generators.registry import GeneratorRegistry 5 + from care.emr.reports.report_type_registry import ReportTypeRegistry 6 + 7 + 8 + def _extract_fields_from_context(context_class, visited=None): # noqa: PLR0912 9 + if visited is None: 10 + visited = set() 11 + 12 + class_name = context_class.__name__ 13 + if class_name in visited: 14 + return [] 15 + visited.add(class_name) 16 + 17 + fields = [] 18 + for attr_name in dir(context_class): 19 + if attr_name.startswith("_"): 20 + continue 21 + try: 22 + attr = getattr(context_class, attr_name) 23 + if isinstance(attr, Field): 24 + field_schema = { 25 + "key": attr_name, 26 + "display": attr.display, 27 + "description": attr.description, 28 + "type": attr.type, 29 + } 30 + 31 + if attr.preview_value is not None: 32 + if isinstance(attr.preview_value, (list, dict)): 33 + field_schema["preview_value"] = attr.preview_value 34 + else: 35 + field_schema["preview_value"] = str(attr.preview_value) 36 + elif attr.preview_fn: 37 + try: 38 + sample = attr.preview_fn() 39 + field_schema["preview_value"] = str(sample) 40 + except Exception: 41 + field_schema["preview_value"] = "" 42 + 43 + if attr.target_context: 44 + field_schema["is_nested_context"] = True 45 + field_schema["nested_context_type"] = ( 46 + attr.target_context.__context_type__ 47 + ) 48 + field_schema["nested_context_filters"] = ( 49 + _extract_filters_from_context(attr.target_context) 50 + ) 51 + nested_fields = _extract_fields_from_context( 52 + attr.target_context, visited.copy() 53 + ) 54 + if nested_fields: 55 + field_schema["fields"] = nested_fields 56 + 57 + fields.append(field_schema) 58 + except Exception: # noqa: S112 59 + continue 60 + 61 + return fields 62 + 63 + 64 + def _extract_filters_from_context(context_class): 65 + filters = [] 66 + if hasattr(context_class, "filterset_class") and context_class.filterset_class: 67 + for _, filter_obj in context_class.filterset_class.base_filters.items(): 68 + filters.append( 69 + { 70 + "field_name": filter_obj.field_name, 71 + "lookup_expr": getattr(filter_obj, "lookup_expr", "exact"), 72 + } 73 + ) 74 + 75 + return filters 76 + 77 + 78 + def build_schema(): 79 + all_data_points = DataPointRegistry.get_all() 80 + contexts = {} 81 + 82 + for slug, context_class in all_data_points.items(): 83 + fields = _extract_fields_from_context(context_class) 84 + contexts[slug] = { 85 + "slug": slug, 86 + "display_name": getattr( 87 + context_class, 88 + "__display_name__", 89 + slug.replace("_", " ").title(), 90 + ), 91 + "description": getattr(context_class, "__description__", ""), 92 + "context_type": getattr(context_class, "__context_type__", ""), 93 + "context_key": getattr(context_class, "context_key", slug), 94 + "standalone": getattr(context_class, "standalone_context", False), 95 + "fields": fields, 96 + } 97 + 98 + output_formats = GeneratorRegistry.get_schema() 99 + custom_types = FieldTypeRegistry.get_all() 100 + report_types = ReportTypeRegistry.get_schema() 101 + 102 + return { 103 + "contexts": contexts, 104 + "output_formats": output_formats, 105 + "custom_types": custom_types, 106 + "report_types": report_types, 107 + }
+22
care/emr/reports/context_builder/type_registry.py
··· 1 + class FieldTypeRegistry: 2 + _types: dict[str, dict] = {} 3 + 4 + @classmethod 5 + def register(cls, type_name: str, schema: dict): 6 + cls._types[type_name] = schema 7 + 8 + @classmethod 9 + def get(cls, type_name: str) -> dict | None: 10 + return cls._types.get(type_name) 11 + 12 + @classmethod 13 + def get_all(cls) -> dict[str, dict]: 14 + return cls._types.copy() 15 + 16 + @classmethod 17 + def is_registered(cls, type_name: str) -> bool: 18 + return type_name in cls._types 19 + 20 + @classmethod 21 + def clear(cls): 22 + cls._types.clear()
+80
care/emr/reports/context_builder/types.py
··· 1 + from care.emr.reports.context_builder.type_registry import FieldTypeRegistry 2 + 3 + FieldTypeRegistry.register( 4 + "DosageInstruction", 5 + { 6 + "name": "DosageInstruction", 7 + "description": "Medication dosage instruction with timing and route", 8 + "structure": { 9 + "dose": "string", 10 + "route": "string", 11 + "frequency": "string", 12 + "duration": "string", 13 + "site": "string", 14 + "method": "string", 15 + "as_needed": "boolean", 16 + "additional_instructions": "list[string]", 17 + }, 18 + "example": { 19 + "dose": "1 tablet", 20 + "route": "Oral", 21 + "frequency": "Twice daily", 22 + "duration": "7 days", 23 + "site": "", 24 + "method": "", 25 + "as_needed": False, 26 + "additional_instructions": ["Take with food"], 27 + }, 28 + }, 29 + ) 30 + 31 + FieldTypeRegistry.register( 32 + "CareTeamMember", 33 + { 34 + "name": "CareTeamMember", 35 + "description": "Member of the healthcare team caring for the patient", 36 + "structure": { 37 + "name": "string", 38 + "role": "string", 39 + }, 40 + "example": {"name": "Dr. Rajesh Kumar", "role": "Primary Physician"}, 41 + }, 42 + ) 43 + 44 + FieldTypeRegistry.register( 45 + "CodeableConcept", 46 + { 47 + "name": "CodeableConcept", 48 + "description": "FHIR CodeableConcept - coded value with display text and coding system", 49 + "structure": { 50 + "code": "string", 51 + "display": "string", 52 + "system": "string", 53 + }, 54 + "example": { 55 + "code": "5A11", 56 + "display": "Type 2 Diabetes Mellitus", 57 + "system": "ICD-11", 58 + }, 59 + }, 60 + ) 61 + 62 + FieldTypeRegistry.register( 63 + "Quantity", 64 + { 65 + "name": "Quantity", 66 + "description": "FHIR Quantity - measured amount with unit", 67 + "structure": { 68 + "value": "float", 69 + "unit": "string", 70 + "system": "string", 71 + "code": "string", 72 + }, 73 + "example": { 74 + "value": 120.0, 75 + "unit": "mmHg", 76 + "system": "http://unitsofmeasure.org", 77 + "code": "mm[Hg]", 78 + }, 79 + }, 80 + )
+38
care/emr/reports/context_builder/utils.py
··· 1 + from datetime import date, datetime 2 + 3 + 4 + def format_date(value, format_str="%d/%m/%Y"): 5 + if not value: 6 + return "" 7 + if isinstance(value, str): 8 + try: 9 + value = datetime.fromisoformat(value.replace("Z", "+00:00")) 10 + except ValueError: 11 + return value 12 + if isinstance(value, (datetime, date)): 13 + return value.strftime(format_str) 14 + return str(value) 15 + 16 + 17 + def format_datetime(value, format_str="%d/%m/%Y %I:%M %p"): 18 + if not value: 19 + return "" 20 + if isinstance(value, str): 21 + try: 22 + value = datetime.fromisoformat(value.replace("Z", "+00:00")) 23 + except ValueError: 24 + return value 25 + if isinstance(value, datetime): 26 + return value.strftime(format_str) 27 + return str(value) 28 + 29 + 30 + def format_phone_number(phone): 31 + if not phone: 32 + return "" 33 + phone = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "") 34 + if phone.startswith("+91"): 35 + return f"+91 {phone[3:8]} {phone[8:]}" 36 + if len(phone) == 10: # noqa: PLR2004 37 + return f"{phone[:5]} {phone[5:]}" 38 + return phone
care/emr/reports/renderer/__init__.py

This is a binary file and will not be displayed.

+5
care/emr/reports/renderer/generators/__init__.py
··· 1 + from care.emr.reports.renderer.generators import ( 2 + html_generator, 3 + weasyprint_generator, 4 + ) 5 + from care.emr.reports.renderer.generators.registry import GeneratorRegistry
+27
care/emr/reports/renderer/generators/base.py
··· 1 + from abc import ABC, abstractmethod 2 + from typing import Any 3 + 4 + from pydantic import BaseModel, ConfigDict 5 + 6 + 7 + class BaseOptions(BaseModel): 8 + model_config = ConfigDict(extra="forbid") 9 + 10 + 11 + class BaseOutputGenerator(ABC): 12 + options_model = BaseOptions 13 + 14 + @abstractmethod 15 + def generate(self, html: str, options: dict[str, Any] | None = None) -> bytes: 16 + pass 17 + 18 + @abstractmethod 19 + def get_format(self) -> str: 20 + pass 21 + 22 + def get_supported_options(self) -> dict[str, Any]: 23 + return {} 24 + 25 + @abstractmethod 26 + def get_http_response(self, response): 27 + pass
+80
care/emr/reports/renderer/generators/html_generator.py
··· 1 + from typing import Any 2 + 3 + from django.http import HttpResponse 4 + from pydantic import Field 5 + 6 + from care.emr.reports.renderer.generators.base import BaseOptions, BaseOutputGenerator 7 + from care.emr.reports.renderer.generators.registry import GeneratorRegistry 8 + 9 + 10 + class HTMLGeneratorOptions(BaseOptions): 11 + wrap_document: bool = Field(default=False) 12 + title: str = Field(default="Report") 13 + charset: str = Field(default="utf-8") 14 + 15 + 16 + class HTMLGenerator(BaseOutputGenerator): 17 + options_model = HTMLGeneratorOptions 18 + 19 + def generate(self, html: str, options: HTMLGeneratorOptions | None = None) -> bytes: 20 + options = options or HTMLGeneratorOptions() 21 + if options.wrap_document and "<html" not in html.lower(): 22 + html = self._wrap_html_document(html, options) 23 + return html.encode("utf-8") 24 + 25 + def _wrap_html_document( 26 + self, html_fragment: str, options: HTMLGeneratorOptions 27 + ) -> str: 28 + title = options.title 29 + charset = options.charset 30 + 31 + return f"""<!DOCTYPE html> 32 + <html lang="en"> 33 + <head> 34 + <meta charset="{charset}"> 35 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 36 + <title>{title}</title> 37 + <style> 38 + body {{ 39 + font-family: Arial, sans-serif; 40 + line-height: 1.6; 41 + margin: 2em; 42 + }} 43 + table {{ 44 + border-collapse: collapse; 45 + width: 100%; 46 + margin: 1em 0; 47 + }} 48 + table, th, td {{ 49 + border: 1px solid #ddd; 50 + }} 51 + th, td {{ 52 + padding: 8px; 53 + text-align: left; 54 + }} 55 + th {{ 56 + background-color: #f2f2f2; 57 + }} 58 + </style> 59 + </head> 60 + <body> 61 + {html_fragment} 62 + </body> 63 + </html>""" 64 + 65 + def get_format(self) -> str: 66 + return "html" 67 + 68 + def get_supported_options(self) -> dict[str, Any]: 69 + return HTMLGeneratorOptions().model_dump() 70 + 71 + def get_http_response(self, content): 72 + return HttpResponse(content, content_type="text/html") 73 + 74 + 75 + GeneratorRegistry.register( 76 + format_type="html", 77 + generator_class=HTMLGenerator, 78 + mime_type="text/html", 79 + file_extension=".html", 80 + )
+101
care/emr/reports/renderer/generators/registry.py
··· 1 + from typing import Any 2 + 3 + from care.emr.reports.renderer.generators.base import BaseOutputGenerator 4 + 5 + 6 + class GeneratorRegistry: 7 + _registry: dict[str, type[BaseOutputGenerator]] = {} 8 + _format_config: dict[str, dict[str, str]] = {} 9 + 10 + @classmethod 11 + def register( 12 + cls, 13 + format_type: str, 14 + generator_class: type[BaseOutputGenerator], 15 + mime_type: str, 16 + file_extension: str, 17 + ) -> None: 18 + if not format_type or not isinstance(format_type, str): 19 + raise ValueError("format_type must be a non-empty string") 20 + 21 + if not mime_type or not isinstance(mime_type, str): 22 + raise ValueError("mime_type must be a non-empty string") 23 + 24 + if not file_extension or not isinstance(file_extension, str): 25 + raise ValueError("file_extension must be a non-empty string") 26 + 27 + if not issubclass(generator_class, BaseOutputGenerator): 28 + err = f"Generator class must be a subclass of BaseOutputGenerator, got {generator_class.__name__}" 29 + raise TypeError(err) 30 + 31 + format_type = format_type.lower() 32 + cls._registry[format_type] = generator_class 33 + cls._format_config[format_type] = { 34 + "mime_type": mime_type, 35 + "file_extension": file_extension, 36 + } 37 + 38 + @classmethod 39 + def get(cls, format_type: str) -> type[BaseOutputGenerator]: 40 + format_type = format_type.lower() 41 + if format_type not in cls._registry: 42 + available = ", ".join(cls.get_all_formats()) 43 + error = f"No generator registered for format '{format_type}'. Available formats: {available}" 44 + raise KeyError(error) 45 + return cls._registry[format_type] 46 + 47 + @classmethod 48 + def get_format_config(cls, format_type: str) -> dict[str, str]: 49 + format_type = format_type.lower() 50 + if format_type not in cls._format_config: 51 + available = ", ".join(cls.get_all_formats()) 52 + err = ( 53 + f"No format config for '{format_type}'. Available formats: {available}" 54 + ) 55 + raise KeyError(err) 56 + return cls._format_config[format_type].copy() 57 + 58 + @classmethod 59 + def is_registered(cls, format_type: str) -> bool: 60 + return format_type.lower() in cls._registry 61 + 62 + @classmethod 63 + def get_all_formats(cls) -> list[str]: 64 + return list(cls._registry.keys()) 65 + 66 + @classmethod 67 + def get_all_generators(cls) -> dict[str, type[BaseOutputGenerator]]: 68 + return cls._registry.copy() 69 + 70 + @classmethod 71 + def get_schema(cls) -> dict[str, dict[str, Any]]: 72 + schema = {} 73 + for format_type, generator_class in cls._registry.items(): 74 + try: 75 + generator = generator_class() 76 + supported_options = generator.get_supported_options() 77 + except Exception: 78 + supported_options = {} 79 + 80 + format_config = cls._format_config.get(format_type, {}) 81 + schema[format_type] = { 82 + "format": format_type, 83 + "generator": generator_class.__name__, 84 + "mime_type": format_config.get("mime_type"), 85 + "file_extension": format_config.get("file_extension"), 86 + "supported_options": supported_options, 87 + } 88 + return schema 89 + 90 + @classmethod 91 + def unregister(cls, format_type: str) -> None: 92 + format_type = format_type.lower() 93 + if format_type in cls._registry: 94 + del cls._registry[format_type] 95 + if format_type in cls._format_config: 96 + del cls._format_config[format_type] 97 + 98 + @classmethod 99 + def clear(cls) -> None: 100 + cls._registry.clear() 101 + cls._format_config.clear()
+110
care/emr/reports/renderer/generators/weasyprint_generator.py
··· 1 + import logging 2 + from typing import Any, Literal 3 + 4 + from django.http import HttpResponse 5 + from pydantic import Field 6 + from weasyprint import CSS, HTML 7 + 8 + from care.emr.reports.renderer.generators.base import BaseOptions, BaseOutputGenerator 9 + 10 + logger = logging.getLogger(__name__) 11 + 12 + 13 + class WeasyPrintGeneratorOptions(BaseOptions): 14 + page_size: Literal["A4", "A3", "A5", "Letter", "Legal"] = Field(default="A4") 15 + margin: str = Field(default="1cm") 16 + orientation: Literal["portrait", "landscape"] = Field(default="portrait") 17 + stylesheets: list[str] = Field(default=[]) 18 + 19 + 20 + class WeasyPrintGenerator(BaseOutputGenerator): 21 + options_model = WeasyPrintGeneratorOptions 22 + 23 + def __init__(self): 24 + self.HTML = HTML 25 + self.CSS = CSS 26 + 27 + def generate( 28 + self, html: str, options: WeasyPrintGeneratorOptions | None = None 29 + ) -> bytes: 30 + options = options or WeasyPrintGeneratorOptions() 31 + try: 32 + html_obj = self.HTML(string=html) 33 + stylesheets = [] 34 + 35 + if options.stylesheets: 36 + for css_string in options.stylesheets: 37 + stylesheets.append(self.CSS(string=css_string)) 38 + else: 39 + stylesheets.append(self.CSS(string=self._get_default_css(options))) 40 + 41 + return html_obj.write_pdf(stylesheets=stylesheets) 42 + except Exception as e: 43 + logger.error("WeasyPrint PDF generation failed: %s", e) 44 + msg = f"Failed to generate PDF: {e!s}" 45 + raise Exception(msg) from e 46 + 47 + def _get_default_css(self, options: WeasyPrintGeneratorOptions) -> str: 48 + page_size = options.page_size 49 + margin = options.margin 50 + orientation = options.orientation 51 + 52 + return f""" 53 + @page {{ 54 + size: {page_size} {orientation}; 55 + margin: {margin}; 56 + }} 57 + body {{ 58 + font-family: 'DejaVu Sans', Arial, sans-serif; 59 + font-size: 12pt; 60 + line-height: 1.6; 61 + }} 62 + table {{ 63 + border-collapse: collapse; 64 + width: 100%; 65 + margin: 1em 0; 66 + }} 67 + table, th, td {{ 68 + border: 1px solid #ddd; 69 + }} 70 + th, td {{ 71 + padding: 8px; 72 + text-align: left; 73 + }} 74 + th {{ 75 + background-color: #f2f2f2; 76 + font-weight: bold; 77 + }} 78 + h1, h2, h3, h4, h5, h6 {{ 79 + margin-top: 0.5em; 80 + margin-bottom: 0.5em; 81 + page-break-after: avoid; 82 + }} 83 + p {{ 84 + margin: 0.5em 0; 85 + }} 86 + ul, ol {{ 87 + margin: 0.5em 0; 88 + padding-left: 2em; 89 + }} 90 + """ 91 + 92 + def get_format(self) -> str: 93 + return "pdf" 94 + 95 + def get_supported_options(self) -> dict[str, Any]: 96 + return WeasyPrintGeneratorOptions().model_dump() 97 + 98 + def get_http_response(self, content): 99 + response = HttpResponse(content, content_type="application/pdf") 100 + response["Content-Disposition"] = 'attachment; filename="template_preview.pdf"' 101 + return response 102 + 103 + 104 + def _register(): 105 + from care.emr.reports.renderer.generators.registry import GeneratorRegistry 106 + 107 + GeneratorRegistry.register("pdf", WeasyPrintGenerator, "application/pdf", ".pdf") 108 + 109 + 110 + _register()
+35
care/emr/reports/renderer/renderer.py
··· 1 + import logging 2 + 3 + from care.emr.reports.renderer.generators.base import BaseOptions, BaseOutputGenerator 4 + from care.emr.reports.renderer.template_engine import TemplateEngine 5 + 6 + logger = logging.getLogger(__name__) 7 + 8 + 9 + class Renderer: 10 + def __init__( 11 + self, 12 + output_generator: BaseOutputGenerator, 13 + template_engine: TemplateEngine | None = None, 14 + ): 15 + self.template_engine = template_engine or TemplateEngine() 16 + self.output_generator = output_generator 17 + 18 + def render( 19 + self, template_string: str, context: dict, options: BaseOptions | None = None 20 + ) -> bytes: 21 + options = options or BaseOptions() 22 + 23 + try: 24 + html = self.template_engine.render(template_string, context) 25 + except Exception as e: 26 + logger.error("Template rendering failed: %s", e) 27 + msg = "Failed to render template" 28 + raise Exception(msg) from e 29 + 30 + try: 31 + return self.output_generator.generate(html, options) 32 + except Exception as e: 33 + logger.error("Output generation failed: %s", e) 34 + msg = f"Failed to generate {self.output_generator.get_format()}" 35 + raise Exception(msg) from e
+166
care/emr/reports/renderer/template_engine.py
··· 1 + from datetime import date, datetime 2 + 3 + from jinja2 import BaseLoader, Environment, StrictUndefined, TemplateSyntaxError 4 + from jinja2.sandbox import SandboxedEnvironment 5 + 6 + from care.utils.time_util import care_now 7 + 8 + 9 + class TemplateEngine: 10 + def __init__(self, use_sandbox: bool = True, strict_undefined: bool = True): 11 + self.use_sandbox = use_sandbox 12 + self.strict_undefined = strict_undefined 13 + self.env = self._setup_jinja_env() 14 + 15 + def _setup_jinja_env(self): 16 + env = ( 17 + SandboxedEnvironment( 18 + loader=BaseLoader(), 19 + undefined=StrictUndefined if self.strict_undefined else None, 20 + autoescape=True, 21 + ) 22 + if self.use_sandbox 23 + else Environment( 24 + loader=BaseLoader(), 25 + undefined=StrictUndefined if self.strict_undefined else None, 26 + autoescape=True, 27 + ) 28 + ) 29 + env.trim_blocks = True 30 + env.lstrip_blocks = True 31 + 32 + env.filters["date"] = self._filter_date 33 + env.filters["datetime"] = self._filter_datetime 34 + env.filters["time"] = self._filter_time 35 + env.filters["currency"] = self._filter_currency 36 + env.filters["phone"] = self._filter_phone 37 + 38 + env.globals["current_date"] = self._current_date 39 + env.globals["current_datetime"] = self._current_datetime 40 + env.globals["current_time"] = self._current_time 41 + 42 + return env 43 + 44 + @staticmethod 45 + def _filter_date(value: str | date | datetime, format_str: str = "%d/%m/%Y") -> str: 46 + if not value: 47 + return "" 48 + if isinstance(value, str): 49 + try: 50 + value = datetime.fromisoformat(value.replace("Z", "+00:00")) 51 + except (ValueError, AttributeError): 52 + return value 53 + if isinstance(value, (datetime, date)): 54 + return value.strftime(format_str) 55 + return str(value) 56 + 57 + @staticmethod 58 + def _filter_datetime( 59 + value: str | datetime, format_str: str = "%d/%m/%Y %I:%M %p" 60 + ) -> str: 61 + if not value: 62 + return "" 63 + if isinstance(value, str): 64 + try: 65 + value = datetime.fromisoformat(value.replace("Z", "+00:00")) 66 + except (ValueError, AttributeError): 67 + return value 68 + if isinstance(value, datetime): 69 + return value.strftime(format_str) 70 + return str(value) 71 + 72 + @staticmethod 73 + def _filter_time(value: str | datetime, format_str: str = "%I:%M %p") -> str: 74 + if not value: 75 + return "" 76 + if isinstance(value, str): 77 + try: 78 + value = datetime.fromisoformat(value.replace("Z", "+00:00")) 79 + except (ValueError, AttributeError): 80 + return value 81 + if isinstance(value, datetime): 82 + return value.strftime(format_str) 83 + return str(value) 84 + 85 + @staticmethod 86 + def _filter_currency(value: int | float | str, symbol: str = "₹") -> str: 87 + if value is None or value == "": 88 + return "" 89 + try: 90 + amount = float(value) 91 + except (ValueError, TypeError): 92 + return str(value) 93 + 94 + negative = amount < 0 95 + amount = abs(amount) 96 + rupees = int(amount) 97 + paise = int(round((amount - rupees) * 100)) 98 + 99 + rupees_str = str(rupees) 100 + if len(rupees_str) <= 3: # noqa: PLR2004 101 + formatted = rupees_str 102 + else: 103 + last_three = rupees_str[-3:] 104 + remaining = rupees_str[:-3] 105 + formatted = "" 106 + for i, digit in enumerate(reversed(remaining)): 107 + if i > 0 and i % 2 == 0: 108 + formatted = "," + formatted 109 + formatted = digit + formatted 110 + formatted = formatted + "," + last_three 111 + 112 + if paise > 0: 113 + formatted = f"{formatted}.{paise:02d}" 114 + 115 + result = f"{symbol}{formatted}" 116 + if negative: 117 + result = f"-{result}" 118 + return result 119 + 120 + @staticmethod 121 + def _filter_phone(value: str) -> str: 122 + if not value: 123 + return "" 124 + phone = ( 125 + str(value) 126 + .replace(" ", "") 127 + .replace("-", "") 128 + .replace("(", "") 129 + .replace(")", "") 130 + ) 131 + if phone.startswith("+91") and len(phone) >= 13: # noqa: PLR2004 132 + return f"+91 {phone[3:8]} {phone[8:]}" 133 + if len(phone) == 10: # noqa: PLR2004 134 + return f"{phone[:5]} {phone[5:]}" 135 + return phone 136 + 137 + @staticmethod 138 + def _current_date(format_str: str = "%d/%m/%Y") -> str: 139 + return care_now().strftime(format_str) 140 + 141 + @staticmethod 142 + def _current_datetime(format_str: str = "%d/%m/%Y %I:%M %p") -> str: 143 + return care_now().strftime(format_str) 144 + 145 + @staticmethod 146 + def _current_time(format_str: str = "%I:%M %p") -> str: 147 + return care_now().strftime(format_str) 148 + 149 + def validate_syntax(self, template_string: str) -> tuple[bool, str]: 150 + try: 151 + self.env.parse(template_string) 152 + return True, "" 153 + except TemplateSyntaxError as e: 154 + return False, f"Template syntax error at line {e.lineno}: {e.message}" 155 + except Exception as e: 156 + return False, f"Template validation error: {e!s}" 157 + 158 + def render(self, template_string: str, context: dict) -> str: 159 + try: 160 + template = self.env.from_string(template_string) 161 + return template.render(**context) 162 + except TemplateSyntaxError as e: 163 + msg = f"Template syntax error at line {e.lineno}: {e.message}" 164 + raise TemplateSyntaxError(msg, e.lineno) from e 165 + except Exception as e: 166 + raise e
+87
care/emr/reports/report_type_registry.py
··· 1 + from django.db import models 2 + 3 + from care.emr.reports.authorizers.base import BaseReportAuthorizer 4 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 5 + 6 + 7 + class ReportTypeConfig: 8 + def __init__( 9 + self, 10 + key: str, 11 + display_name: str, 12 + associating_model: type[models.Model], 13 + authorizer_class: type[BaseReportAuthorizer], 14 + description: str = "", 15 + ): 16 + self.key = key 17 + self.display_name = display_name 18 + self.associating_model = associating_model 19 + self.authorizer_class = authorizer_class 20 + self.description = description 21 + 22 + 23 + class ReportTypeRegistry: 24 + _registry: dict[str, ReportTypeConfig] = {} 25 + 26 + @classmethod 27 + def register( 28 + cls, 29 + key: str, 30 + display_name: str, 31 + associating_model: type[models.Model], 32 + authorizer_class: type[BaseReportAuthorizer], 33 + description: str = "", 34 + ) -> None: 35 + if key in cls._registry: 36 + msg = f"Report type '{key}' is already registered" 37 + raise ValueError(msg) 38 + 39 + if not issubclass(authorizer_class, BaseReportAuthorizer): 40 + msg = "Authorizer must be a subclass of BaseReportAuthorizer" 41 + raise ValueError(msg) 42 + 43 + config = ReportTypeConfig( 44 + key=key, 45 + display_name=display_name, 46 + associating_model=associating_model, 47 + authorizer_class=authorizer_class, 48 + description=description, 49 + ) 50 + cls._registry[key] = config 51 + 52 + @classmethod 53 + def get(cls, key: str) -> ReportTypeConfig: 54 + if key not in cls._registry: 55 + err = f"Report type '{key}' not found" 56 + raise KeyError(err) 57 + return cls._registry[key] 58 + 59 + @classmethod 60 + def get_all_keys(cls) -> list[str]: 61 + return list(cls._registry.keys()) 62 + 63 + @classmethod 64 + def get_all_configs(cls) -> dict[str, ReportTypeConfig]: 65 + return cls._registry.copy() 66 + 67 + @classmethod 68 + def get_schema(cls) -> dict: 69 + schema = {} 70 + for key, config in cls._registry.items(): 71 + schema[key] = { 72 + "display_name": config.display_name, 73 + "description": config.description, 74 + "supported_contexts": DataPointRegistry.get_contexts_by_model( 75 + config.associating_model 76 + ), 77 + } 78 + return schema 79 + 80 + @classmethod 81 + def unregister(cls, key: str) -> None: 82 + if key in cls._registry: 83 + del cls._registry[key] 84 + 85 + @classmethod 86 + def clear(cls) -> None: 87 + cls._registry.clear()
+9
care/emr/reports/report_type_utils.py
··· 1 + from django.db import models 2 + 3 + 4 + def validate_associating_id( 5 + associating_model: type[models.Model], 6 + associating_id: str, 7 + report_type_key: str, 8 + ) -> models.Model: 9 + return associating_model.objects.get(external_id=associating_id)
+13
care/emr/reports/report_types.py
··· 1 + from care.emr.models.encounter import Encounter 2 + from care.emr.reports.authorizers.discharge_summary import ( 3 + DischargeSummaryReportAuthorizer, 4 + ) 5 + from care.emr.reports.report_type_registry import ReportTypeRegistry 6 + 7 + ReportTypeRegistry.register( 8 + key="discharge_summary", 9 + display_name="Discharge Summary", 10 + associating_model=Encounter, 11 + authorizer_class=DischargeSummaryReportAuthorizer, 12 + description="Discharge summary generated for an encounter", 13 + )
+126
care/emr/reports/report_utils.py
··· 1 + import logging 2 + import time 3 + from uuid import uuid4 4 + 5 + from django.core.cache import cache 6 + from django.utils import timezone 7 + 8 + from care.emr.models.report.report_upload import ReportUpload 9 + from care.emr.models.report.template import Template 10 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 11 + from care.emr.reports.renderer.generators import GeneratorRegistry 12 + from care.emr.reports.renderer.renderer import Renderer 13 + from care.emr.reports.renderer.template_engine import TemplateEngine 14 + from care.emr.reports.report_type_registry import ReportTypeRegistry 15 + from care.emr.reports.report_type_utils import validate_associating_id 16 + from care.users.models import User 17 + 18 + logger = logging.getLogger(__name__) 19 + LOCK_DURATION = 2 * 60 20 + 21 + 22 + def get_lock_key(report_type: str, associating_id: str) -> str: 23 + return f"{report_type}_{associating_id}" 24 + 25 + 26 + def set_lock(key: str, progress: int, timeout: int = LOCK_DURATION) -> None: 27 + cache_key = f"report_generation_lock:{key}" 28 + cache.set(cache_key, progress, timeout) 29 + 30 + 31 + def get_progress(key: str) -> int | None: 32 + cache_key = f"report_generation_lock:{key}" 33 + return cache.get(cache_key) 34 + 35 + 36 + def clear_lock(key: str) -> None: 37 + cache_key = f"report_generation_lock:{key}" 38 + cache.delete(cache_key) 39 + 40 + 41 + def generate_and_upload_report( 42 + template: Template, 43 + report_type: str, 44 + associating_id: str, 45 + output_format: str = "pdf", 46 + **kwargs, 47 + ) -> ReportUpload: 48 + context_class = DataPointRegistry.get(template.context) 49 + if not context_class: 50 + error_msg = f"Context '{template.context}' not found in DataPointRegistry" 51 + raise ValueError(error_msg) 52 + 53 + try: 54 + report_type_config = ReportTypeRegistry.get(report_type) 55 + except KeyError as e: 56 + error_msg = f"Report Type '{report_type}' not found in ReportTypeRegistry" 57 + raise ValueError(error_msg) from e 58 + 59 + associating_object = validate_associating_id( 60 + associating_model=report_type_config.associating_model, 61 + associating_id=associating_id, 62 + report_type_key=report_type, 63 + ) 64 + 65 + context_key = context_class.context_key or template.context 66 + 67 + context = {context_key: context_class(context=associating_object)} 68 + 69 + template_engine = TemplateEngine() 70 + 71 + format_lower = output_format.lower() 72 + 73 + generator_class = GeneratorRegistry.get(format_lower) 74 + generator = generator_class() 75 + format_config = GeneratorRegistry.get_format_config(format_lower) 76 + file_extension = format_config["file_extension"] 77 + mime_type = format_config["mime_type"] 78 + 79 + renderer = Renderer(generator, template_engine) 80 + 81 + validated_options = generator.options_model.model_validate(template.options) 82 + output_bytes = renderer.render(template.template_data, context, validated_options) 83 + 84 + current_date = timezone.now() 85 + timestamp = int(current_date.timestamp() * 1000) 86 + 87 + report_name = f"{report_type}-{associating_id}-{timestamp}" 88 + internal_name = f"{uuid4()}{int(time.time())}{file_extension}" 89 + 90 + user_id = kwargs.get("user_id") 91 + 92 + report_upload = ReportUpload( 93 + template=template, 94 + name=report_name, 95 + internal_name=internal_name, 96 + associating_id=associating_id, 97 + report_type=report_type, 98 + upload_completed=False, 99 + ) 100 + 101 + if user_id: 102 + try: 103 + report_upload.created_by = User.objects.get(id=user_id) 104 + except User.DoesNotExist: 105 + logger.warning( 106 + "User with id %s not found, report will have no created_by", user_id 107 + ) 108 + 109 + report_upload.meta["mime_type"] = mime_type 110 + report_upload.meta["generated_at"] = current_date.isoformat() 111 + report_upload.meta["template_id"] = str(template.external_id) 112 + report_upload.meta["output_format"] = output_format 113 + 114 + report_upload.save(skip_internal_name=True) 115 + 116 + try: 117 + report_upload.files_manager.put_object( 118 + report_upload, output_bytes, ContentType=mime_type 119 + ) 120 + report_upload.upload_completed = True 121 + report_upload.save() 122 + except Exception as e: 123 + report_upload.delete() 124 + raise e 125 + 126 + return report_upload
care/emr/resources/report/__init__.py

This is a binary file and will not be displayed.

care/emr/resources/report/report_upload/__init__.py

This is a binary file and will not be displayed.

+54
care/emr/resources/report/report_upload/spec.py
··· 1 + import datetime 2 + 3 + from pydantic import UUID4 4 + 5 + from care.emr.models.report.report_upload import ReportUpload 6 + from care.emr.reports import report_types # noqa: F401 - Trigger registration 7 + from care.emr.resources.base import EMRResource 8 + from care.emr.resources.report.template.spec import TemplateReadSpec 9 + from care.emr.resources.user.spec import UserSpec 10 + 11 + 12 + class ReportUploadBaseSpec(EMRResource): 13 + __model__ = ReportUpload 14 + 15 + id: UUID4 | None = None 16 + name: str 17 + 18 + 19 + class ReportUploadListSpec(ReportUploadBaseSpec): 20 + template: dict 21 + report_type: str 22 + associating_id: str 23 + archived_by: UserSpec | None = None 24 + archived_datetime: datetime.datetime | None = None 25 + upload_completed: bool 26 + is_archived: bool | None = None 27 + archive_reason: str | None = None 28 + created_date: datetime.datetime 29 + extension: str 30 + uploaded_by: dict 31 + mime_type: str 32 + 33 + @classmethod 34 + def perform_extra_serialization(cls, mapping, obj): 35 + mapping["id"] = obj.external_id 36 + mapping["extension"] = obj.get_extension() 37 + mapping["mime_type"] = obj.meta.get("mime_type") 38 + if obj.template: 39 + mapping["template"] = TemplateReadSpec.serialize(obj.template).to_json() 40 + cls.serialize_audit_users(mapping, obj) 41 + 42 + 43 + class ReportUploadRetrieveSpec(ReportUploadListSpec): 44 + signed_url: str | None = None 45 + read_signed_url: str | None = None 46 + internal_name: str 47 + 48 + @classmethod 49 + def perform_extra_serialization(cls, mapping, obj): 50 + super().perform_extra_serialization(mapping, obj) 51 + if getattr(obj, "_just_created", False): 52 + mapping["signed_url"] = obj.files_manager.signed_url(obj) 53 + else: 54 + mapping["read_signed_url"] = obj.files_manager.read_signed_url(obj)
care/emr/resources/report/template/__init__.py

This is a binary file and will not be displayed.

+119
care/emr/resources/report/template/spec.py
··· 1 + from enum import Enum 2 + 3 + from pydantic import UUID4, field_validator, model_validator 4 + 5 + from care.emr.models.report.template import Template 6 + from care.emr.reports.context_builder.data_point_registry import DataPointRegistry 7 + from care.emr.reports.renderer.generators import GeneratorRegistry 8 + from care.emr.reports.report_type_registry import ReportTypeRegistry 9 + from care.emr.resources.base import EMRResource 10 + from care.emr.resources.facility.spec import FacilityBareMinimumSpec 11 + from care.emr.utils.slug_type import SlugType 12 + from care.facility.models.facility import Facility 13 + from care.utils.shortcuts import get_object_or_404 14 + 15 + 16 + class TemplateStatusOptions(str, Enum): 17 + draft = "draft" 18 + active = "active" 19 + retired = "retired" 20 + 21 + 22 + class TemplateFormatOptions(str, Enum): 23 + pdf = "pdf" 24 + html = "html" 25 + 26 + 27 + class TemplateBaseSpec(EMRResource): 28 + __model__ = Template 29 + 30 + __exclude__ = ["facility"] 31 + 32 + id: UUID4 | None = None 33 + name: str 34 + status: TemplateStatusOptions 35 + default_format: TemplateFormatOptions 36 + description: str = "" 37 + options: dict = {} 38 + 39 + 40 + class TemplateCreateSpec(TemplateBaseSpec): 41 + facility: UUID4 | None = None 42 + slug_value: SlugType 43 + template_data: str 44 + template_type: str 45 + context: str 46 + 47 + def perform_extra_deserialization(self, is_update, obj): 48 + if self.facility: 49 + obj.facility = get_object_or_404(Facility, external_id=self.facility) 50 + obj.slug = self.slug_value 51 + 52 + @model_validator(mode="after") 53 + def validate_report_type_and_context(self): 54 + report_type = ReportTypeRegistry.get(self.template_type) 55 + context = DataPointRegistry.get(self.context) 56 + if not report_type or not context: 57 + raise ValueError("Invalid report type or context") 58 + if report_type.associating_model != context.__associating_model__: 59 + raise ValueError("Report Type and Context are not compatible") 60 + 61 + generator_class = GeneratorRegistry.get(self.default_format) 62 + 63 + options_model = generator_class.options_model 64 + options_model.model_validate(self.options) 65 + 66 + return self 67 + 68 + @field_validator("template_type") 69 + @classmethod 70 + def validate_report_type(cls, v): 71 + if not v: 72 + msg = "Report Type is required" 73 + raise ValueError(msg) 74 + try: 75 + ReportTypeRegistry.get(v) 76 + except KeyError as e: 77 + raise ValueError("Invalid report type") from e 78 + return v 79 + 80 + @field_validator("context") 81 + @classmethod 82 + def validate_context(cls, v): 83 + if not v: 84 + msg = "Report Type is required" 85 + raise ValueError(msg) 86 + try: 87 + DataPointRegistry.get(v) 88 + except KeyError as e: 89 + raise ValueError("Invalid Context type") from e 90 + return v 91 + 92 + 93 + class TemplateUpdateSpec(TemplateCreateSpec): 94 + pass 95 + 96 + 97 + class TemplateReadSpec(TemplateBaseSpec): 98 + slug_config: dict 99 + slug: str 100 + report_type: str 101 + context: str 102 + 103 + @classmethod 104 + def perform_extra_serialization(cls, mapping, obj): 105 + mapping["id"] = obj.external_id 106 + mapping["slug_config"] = obj.parse_slug(obj.slug) 107 + 108 + 109 + class TemplateRetrieveSpec(TemplateReadSpec): 110 + facility: dict | None = None 111 + template_data: str 112 + 113 + @classmethod 114 + def perform_extra_serialization(cls, mapping, obj): 115 + super().perform_extra_serialization(mapping, obj) 116 + if obj.facility: 117 + mapping["facility"] = FacilityBareMinimumSpec.serialize( 118 + obj.facility 119 + ).to_json()
+78
care/emr/tasks/report_generation.py
··· 1 + from botocore.exceptions import ClientError 2 + from celery import shared_task 3 + from celery.utils.log import get_task_logger 4 + 5 + from care.emr.models.report.template import Template 6 + from care.emr.reports import report_utils 7 + from care.utils.exceptions import CeleryTaskError 8 + 9 + logger = get_task_logger(__name__) 10 + 11 + 12 + @shared_task( 13 + autoretry_for=(ClientError,), retry_kwargs={"max_retries": 3}, expires=10 * 60 14 + ) 15 + def generate_report_task( 16 + template_id: str, 17 + report_type: str, 18 + associating_id: str, 19 + output_format: str = "pdf", 20 + **kwargs, 21 + ): 22 + lock_key = f"{report_type}_{associating_id}" 23 + 24 + logger.info( 25 + "Starting report generation task - report_type: %s, " 26 + "associating_id: %s, template_id: %s, output_format: %s", 27 + report_type, 28 + associating_id, 29 + template_id, 30 + output_format, 31 + ) 32 + 33 + try: 34 + logger.debug("Setting initial lock for %s at 10%% progress", lock_key) 35 + report_utils.set_lock(lock_key, 10) 36 + 37 + try: 38 + logger.debug("Fetching template with external_id: %s", template_id) 39 + template = Template.objects.get(external_id=template_id) 40 + except Template.DoesNotExist as e: 41 + logger.error("Template not found: %s", template_id) 42 + msg = f"Template {template_id} does not exist" 43 + raise CeleryTaskError(msg) from e 44 + 45 + logger.debug("Updating lock for %s to 30%% progress", lock_key) 46 + report_utils.set_lock(lock_key, 30) 47 + 48 + report_upload = report_utils.generate_and_upload_report( 49 + template=template, 50 + output_format=output_format, 51 + report_type=report_type, 52 + associating_id=associating_id, 53 + **kwargs, 54 + ) 55 + 56 + if not report_upload: 57 + logger.error( 58 + "Report generation failed - generate_and_upload_report returned None" 59 + ) 60 + raise CeleryTaskError("Unable to generate report") 61 + 62 + logger.info( 63 + "Report generation task completed - external_id: %s", 64 + report_upload.external_id, 65 + ) 66 + return str(report_upload.external_id) 67 + 68 + except CeleryTaskError: 69 + logger.exception("Celery task error in report generation for %s", lock_key) 70 + raise 71 + except Exception as e: 72 + logger.exception( 73 + "Unexpected error in report generation task for %s: %s", lock_key, e 74 + ) 75 + raise e 76 + finally: 77 + logger.debug("Clearing lock for %s", lock_key) 78 + report_utils.clear_lock(lock_key)
+1
care/security/authorization/__init__.py
··· 28 28 from .supply_delivery import * # noqa 29 29 from .supply_request import * # noqa 30 30 from .tag_config import * # noqa 31 + from .template import * # noqa 31 32 from .token import * # noqa 32 33 from .user import * # noqa
+57
care/security/authorization/template.py
··· 1 + from care.security.authorization import AuthorizationController 2 + from care.security.authorization.base import AuthorizationHandler 3 + from care.security.permissions.template import TemplatePermissions 4 + 5 + 6 + class TemplateAccess(AuthorizationHandler): 7 + def can_list_facility_template(self, user, facility): 8 + """ 9 + Check if the user has permission to view templates in the facility 10 + """ 11 + return self.check_permission_in_facility_organization( 12 + [TemplatePermissions.can_read_template.name], 13 + user, 14 + facility=facility, 15 + ) 16 + 17 + def can_write_facility_template(self, user, facility): 18 + """ 19 + Check if the user has permission to write templates in the facility 20 + """ 21 + return self.check_permission_in_facility_organization( 22 + [TemplatePermissions.can_write_template.name], 23 + user, 24 + facility=facility, 25 + root=True, 26 + ) 27 + 28 + def can_preview_template(self, user): 29 + """ 30 + Authorize user to preview templates - allows superuser, admin and facility admin 31 + """ 32 + return self.check_permission_in_facility_organization( 33 + [TemplatePermissions.can_preview_template.name], 34 + user, 35 + ) 36 + 37 + def can_view_template_schema(self, user): 38 + """ 39 + Authorize user to view template schema - allows superuser, admin and facility admin 40 + """ 41 + return self.check_permission_in_facility_organization( 42 + [TemplatePermissions.can_view_template_schema.name], 43 + user, 44 + ) 45 + 46 + def can_generate_report_from_template(self, user, facility): 47 + """ 48 + Check if the user has permission to generate reports from templates 49 + """ 50 + return self.check_permission_in_facility_organization( 51 + [TemplatePermissions.can_generate_report_from_template.name], 52 + user, 53 + facility=facility, 54 + ) 55 + 56 + 57 + AuthorizationController.register_internal_controller(TemplateAccess)
+2
care/security/permissions/base.py
··· 40 40 from care.security.permissions.supply_delivery import SupplyDeliveryPermissions 41 41 from care.security.permissions.supply_request import SupplyRequestPermissions 42 42 from care.security.permissions.tag_config import TagConfigPermissions 43 + from care.security.permissions.template import TemplatePermissions 43 44 from care.security.permissions.token import TokenPermissions 44 45 from care.security.permissions.user import UserPermissions 45 46 ··· 86 87 SupplyRequestPermissions, 87 88 InventoryItemPermissions, 88 89 TagConfigPermissions, 90 + TemplatePermissions, 89 91 PatientIdentifierConfigPermissions, 90 92 MedicationPermissions, 91 93 TokenPermissions,
+64
care/security/permissions/template.py
··· 1 + import enum 2 + 3 + from care.security.permissions.constants import Permission, PermissionContext 4 + from care.security.roles.role import ( 5 + ADMIN_ROLE, 6 + ADMINISTRATOR, 7 + DOCTOR_ROLE, 8 + FACILITY_ADMIN_ROLE, 9 + NURSE_ROLE, 10 + PHARMACIST_ROLE, 11 + STAFF_ROLE, 12 + VOLUNTEER_ROLE, 13 + ) 14 + 15 + 16 + class TemplatePermissions(enum.Enum): 17 + can_write_template = Permission( 18 + "Can Create Template on Facility", 19 + "", 20 + PermissionContext.FACILITY, 21 + [FACILITY_ADMIN_ROLE, ADMIN_ROLE], 22 + ) 23 + can_read_template = Permission( 24 + "Can Read Template", 25 + "", 26 + PermissionContext.FACILITY, 27 + [ 28 + FACILITY_ADMIN_ROLE, 29 + ADMINISTRATOR, 30 + ADMIN_ROLE, 31 + STAFF_ROLE, 32 + DOCTOR_ROLE, 33 + NURSE_ROLE, 34 + VOLUNTEER_ROLE, 35 + PHARMACIST_ROLE, 36 + ], 37 + ) 38 + can_preview_template = Permission( 39 + "Can Preview Template", 40 + "", 41 + PermissionContext.FACILITY, 42 + [FACILITY_ADMIN_ROLE, ADMIN_ROLE], 43 + ) 44 + can_view_template_schema = Permission( 45 + "Can View Template Schema", 46 + "", 47 + PermissionContext.FACILITY, 48 + [FACILITY_ADMIN_ROLE, ADMIN_ROLE], 49 + ) 50 + can_generate_report_from_template = Permission( 51 + "Can generate report from template", 52 + "", 53 + PermissionContext.FACILITY, 54 + [ 55 + FACILITY_ADMIN_ROLE, 56 + ADMINISTRATOR, 57 + ADMIN_ROLE, 58 + STAFF_ROLE, 59 + DOCTOR_ROLE, 60 + NURSE_ROLE, 61 + VOLUNTEER_ROLE, 62 + PHARMACIST_ROLE, 63 + ], 64 + )
+17
care/utils/csp/config.py
··· 27 27 class BucketType(enum.Enum): 28 28 PATIENT = "PATIENT" 29 29 FACILITY = "FACILITY" 30 + REPORT = "REPORT" 30 31 31 32 32 33 def get_facility_bucket_config(external) -> tuple[ClientConfig, BucketName]: ··· 55 56 return params, settings.FILE_UPLOAD_BUCKET 56 57 57 58 59 + def get_report_bucket_config(external) -> tuple[ClientConfig, BucketName]: 60 + """Get bucket configuration for reports - uses same bucket as patient files""" 61 + params = {"region_name": settings.FACILITY_S3_REGION} 62 + if CSProvider.AWS_ROLE_BASED.value != settings.BUCKET_PROVIDER: 63 + params["aws_access_key_id"] = settings.FACILITY_S3_KEY 64 + params["aws_secret_access_key"] = settings.FACILITY_S3_SECRET 65 + params["endpoint_url"] = ( 66 + settings.FILE_UPLOAD_BUCKET_EXTERNAL_ENDPOINT 67 + if external 68 + else settings.FILE_UPLOAD_BUCKET_ENDPOINT 69 + ) 70 + return params, settings.FILE_UPLOAD_BUCKET 71 + 72 + 58 73 def get_client_config(bucket_type: BucketType, external=False): 59 74 if bucket_type == BucketType.FACILITY: 60 75 return get_facility_bucket_config(external=external) 61 76 if bucket_type == BucketType.PATIENT: 62 77 return get_patient_bucket_config(external=external) 78 + if bucket_type == BucketType.REPORT: 79 + return get_report_bucket_config(external=external) 63 80 msg = "Invalid Bucket Type" 64 81 raise ValueError(msg)
+5
config/api_router.py
··· 76 76 QuestionnaireViewSet, 77 77 ) 78 78 from care.emr.api.viewsets.questionnaire_response import QuestionnaireResponseViewSet 79 + from care.emr.api.viewsets.report.report_upload import ReportUploadViewSet 80 + from care.emr.api.viewsets.report.template import TemplateViewSet 79 81 from care.emr.api.viewsets.resource_category import ResourceCategoryViewSet 80 82 from care.emr.api.viewsets.resource_request import ( 81 83 ResourceRequestCommentViewSet, ··· 485 487 NoteMessageViewSet, 486 488 basename="note", 487 489 ) 490 + 491 + router.register("template", TemplateViewSet, basename="template") 492 + router.register("template_reports", ReportUploadViewSet, basename="template-reports") 488 493 489 494 app_name = "api" 490 495 urlpatterns = [
+1
docker/dev.Dockerfile
··· 10 10 RUN apt-get update && apt-get install --no-install-recommends -y \ 11 11 build-essential libjpeg-dev zlib1g-dev libgmp-dev \ 12 12 libpq-dev gettext wget curl gnupg git \ 13 + libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libharfbuzz-subset0 libffi-dev libjpeg-dev libopenjp2-7-dev \ 13 14 && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 14 15 && rm -rf /var/lib/apt/lists/* 15 16
+1
docker/prod.Dockerfile
··· 20 20 21 21 RUN apt-get update && apt-get install --no-install-recommends -y \ 22 22 build-essential libjpeg-dev zlib1g-dev libgmp-dev libpq-dev git wget \ 23 + libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libharfbuzz-subset0 libffi-dev libopenjp2-7-dev \ 23 24 && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 24 25 && rm -rf /var/lib/apt/lists/* 25 26