endoreg-db 0.8.6.1__py3-none-any.whl → 0.8.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of endoreg-db might be problematic. Click here for more details.

Files changed (360) hide show
  1. endoreg_db/authz/auth.py +74 -0
  2. endoreg_db/authz/backends.py +168 -0
  3. endoreg_db/authz/management/commands/list_routes.py +18 -0
  4. endoreg_db/authz/middleware.py +83 -0
  5. endoreg_db/authz/permissions.py +127 -0
  6. endoreg_db/authz/policy.py +218 -0
  7. endoreg_db/authz/views_auth.py +66 -0
  8. endoreg_db/config/env.py +13 -8
  9. endoreg_db/data/__init__.py +8 -31
  10. endoreg_db/data/_examples/disease.yaml +55 -0
  11. endoreg_db/data/_examples/disease_classification.yaml +13 -0
  12. endoreg_db/data/_examples/disease_classification_choice.yaml +62 -0
  13. endoreg_db/data/_examples/event.yaml +64 -0
  14. endoreg_db/data/_examples/examination.yaml +72 -0
  15. endoreg_db/data/_examples/finding/anatomy_colon.yaml +128 -0
  16. endoreg_db/data/_examples/finding/colonoscopy.yaml +40 -0
  17. endoreg_db/data/_examples/finding/colonoscopy_bowel_prep.yaml +56 -0
  18. endoreg_db/data/_examples/finding/complication.yaml +16 -0
  19. endoreg_db/data/_examples/finding/data.yaml +105 -0
  20. endoreg_db/data/_examples/finding/examination_setting.yaml +16 -0
  21. endoreg_db/data/_examples/finding/medication_related.yaml +18 -0
  22. endoreg_db/data/_examples/finding/outcome.yaml +12 -0
  23. endoreg_db/data/_examples/finding_classification/colonoscopy_bowel_preparation.yaml +68 -0
  24. endoreg_db/data/_examples/finding_classification/colonoscopy_jnet.yaml +22 -0
  25. endoreg_db/data/_examples/finding_classification/colonoscopy_kudo.yaml +25 -0
  26. endoreg_db/data/_examples/finding_classification/colonoscopy_lesion_circularity.yaml +20 -0
  27. endoreg_db/data/_examples/finding_classification/colonoscopy_lesion_planarity.yaml +24 -0
  28. endoreg_db/data/_examples/finding_classification/colonoscopy_lesion_size.yaml +68 -0
  29. endoreg_db/data/_examples/finding_classification/colonoscopy_lesion_surface.yaml +20 -0
  30. endoreg_db/data/_examples/finding_classification/colonoscopy_location.yaml +80 -0
  31. endoreg_db/data/_examples/finding_classification/colonoscopy_lst.yaml +21 -0
  32. endoreg_db/data/_examples/finding_classification/colonoscopy_nice.yaml +20 -0
  33. endoreg_db/data/_examples/finding_classification/colonoscopy_paris.yaml +26 -0
  34. endoreg_db/data/_examples/finding_classification/colonoscopy_sano.yaml +22 -0
  35. endoreg_db/data/_examples/finding_classification/colonoscopy_summary.yaml +53 -0
  36. endoreg_db/data/_examples/finding_classification/complication_generic.yaml +25 -0
  37. endoreg_db/data/_examples/finding_classification/examination_setting_generic.yaml +40 -0
  38. endoreg_db/data/_examples/finding_classification/histology_colo.yaml +51 -0
  39. endoreg_db/data/_examples/finding_classification/intervention_required.yaml +26 -0
  40. endoreg_db/data/_examples/finding_classification/medication_related.yaml +23 -0
  41. endoreg_db/data/_examples/finding_classification/visualized.yaml +33 -0
  42. endoreg_db/data/_examples/finding_classification_choice/bowel_preparation.yaml +78 -0
  43. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_circularity_default.yaml +32 -0
  44. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_jnet.yaml +15 -0
  45. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_kudo.yaml +23 -0
  46. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_lst.yaml +15 -0
  47. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_nice.yaml +17 -0
  48. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_paris.yaml +57 -0
  49. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_planarity_default.yaml +49 -0
  50. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_sano.yaml +14 -0
  51. endoreg_db/data/_examples/finding_classification_choice/colon_lesion_surface_intact_default.yaml +36 -0
  52. endoreg_db/data/_examples/finding_classification_choice/colonoscopy_location.yaml +229 -0
  53. endoreg_db/data/_examples/finding_classification_choice/colonoscopy_not_complete_reason.yaml +19 -0
  54. endoreg_db/data/_examples/finding_classification_choice/colonoscopy_size.yaml +82 -0
  55. endoreg_db/data/_examples/finding_classification_choice/colonoscopy_summary_worst_finding.yaml +15 -0
  56. endoreg_db/data/_examples/finding_classification_choice/complication_generic_types.yaml +15 -0
  57. endoreg_db/data/_examples/finding_classification_choice/examination_setting_generic_types.yaml +15 -0
  58. endoreg_db/data/_examples/finding_classification_choice/histology.yaml +24 -0
  59. endoreg_db/data/_examples/finding_classification_choice/histology_polyp.yaml +20 -0
  60. endoreg_db/data/_examples/finding_classification_choice/outcome.yaml +19 -0
  61. endoreg_db/data/_examples/finding_classification_choice/yes_no_na.yaml +11 -0
  62. endoreg_db/data/_examples/finding_classification_type/colonoscopy_basic.yaml +48 -0
  63. endoreg_db/data/_examples/finding_intervention/endoscopy.yaml +43 -0
  64. endoreg_db/data/_examples/finding_intervention/endoscopy_colonoscopy.yaml +168 -0
  65. endoreg_db/data/_examples/finding_intervention/endoscopy_egd.yaml +128 -0
  66. endoreg_db/data/_examples/finding_intervention/endoscopy_ercp.yaml +32 -0
  67. endoreg_db/data/_examples/finding_intervention/endoscopy_eus_lower.yaml +9 -0
  68. endoreg_db/data/_examples/finding_intervention/endoscopy_eus_upper.yaml +36 -0
  69. endoreg_db/data/_examples/finding_intervention_type/endoscopy.yaml +15 -0
  70. endoreg_db/data/_examples/finding_type/data.yaml +43 -0
  71. endoreg_db/data/_examples/requirement/age.yaml +26 -0
  72. endoreg_db/data/_examples/requirement/colonoscopy_baseline_austria.yaml +45 -0
  73. endoreg_db/data/_examples/requirement/disease_cardiovascular.yaml +79 -0
  74. endoreg_db/data/_examples/requirement/disease_classification_choice_cardiovascular.yaml +41 -0
  75. endoreg_db/data/_examples/requirement/disease_hepatology.yaml +12 -0
  76. endoreg_db/data/_examples/requirement/disease_misc.yaml +12 -0
  77. endoreg_db/data/_examples/requirement/disease_renal.yaml +96 -0
  78. endoreg_db/data/_examples/requirement/endoscopy_bleeding_risk.yaml +59 -0
  79. endoreg_db/data/_examples/requirement/event_cardiology.yaml +251 -0
  80. endoreg_db/data/_examples/requirement/event_requirements.yaml +145 -0
  81. endoreg_db/data/_examples/requirement/finding_colon_polyp.yaml +50 -0
  82. endoreg_db/data/_examples/requirement/gender.yaml +25 -0
  83. endoreg_db/data/_examples/requirement/lab_value.yaml +441 -0
  84. endoreg_db/data/_examples/requirement/medication.yaml +93 -0
  85. endoreg_db/data/_examples/requirement_operator/age.yaml +13 -0
  86. endoreg_db/data/_examples/requirement_operator/lab_operators.yaml +129 -0
  87. endoreg_db/data/_examples/requirement_operator/model_operators.yaml +96 -0
  88. endoreg_db/data/_examples/requirement_set/01_endoscopy_generic.yaml +48 -0
  89. endoreg_db/data/_examples/requirement_set/colonoscopy_austria_screening.yaml +57 -0
  90. endoreg_db/data/_examples/yaml_examples.xlsx +0 -0
  91. endoreg_db/data/ai_model_meta/default_multilabel_classification.yaml +4 -3
  92. endoreg_db/data/event_classification/data.yaml +4 -0
  93. endoreg_db/data/event_classification_choice/data.yaml +9 -0
  94. endoreg_db/data/finding_classification/colonoscopy_bowel_preparation.yaml +43 -70
  95. endoreg_db/data/finding_classification/colonoscopy_lesion_size.yaml +22 -52
  96. endoreg_db/data/finding_classification/colonoscopy_location.yaml +31 -62
  97. endoreg_db/data/finding_classification/histology_colo.yaml +28 -36
  98. endoreg_db/data/requirement/colon_polyp_intervention.yaml +49 -0
  99. endoreg_db/data/requirement/coloreg_colon_polyp.yaml +49 -0
  100. endoreg_db/data/requirement_set/01_endoscopy_generic.yaml +31 -12
  101. endoreg_db/data/requirement_set/01_laboratory.yaml +13 -0
  102. endoreg_db/data/requirement_set/02_endoscopy_bleeding_risk.yaml +46 -0
  103. endoreg_db/data/requirement_set/90_coloreg.yaml +178 -0
  104. endoreg_db/data/requirement_set/_old_ +109 -0
  105. endoreg_db/data/requirement_set_type/data.yaml +21 -0
  106. endoreg_db/data/setup_config.yaml +4 -4
  107. endoreg_db/data/tag/requirement_set_tags.yaml +21 -0
  108. endoreg_db/exceptions.py +5 -2
  109. endoreg_db/helpers/data_loader.py +1 -1
  110. endoreg_db/management/commands/create_model_meta_from_huggingface.py +21 -10
  111. endoreg_db/management/commands/create_multilabel_model_meta.py +299 -129
  112. endoreg_db/management/commands/import_video.py +9 -10
  113. endoreg_db/management/commands/import_video_with_classification.py +1 -1
  114. endoreg_db/management/commands/init_default_ai_model.py +1 -1
  115. endoreg_db/management/commands/list_routes.py +18 -0
  116. endoreg_db/management/commands/load_center_data.py +12 -12
  117. endoreg_db/management/commands/load_requirement_data.py +60 -31
  118. endoreg_db/management/commands/load_requirement_set_tags.py +95 -0
  119. endoreg_db/management/commands/setup_endoreg_db.py +3 -3
  120. endoreg_db/management/commands/storage_management.py +271 -203
  121. endoreg_db/migrations/0001_initial.py +1799 -1300
  122. endoreg_db/migrations/0002_requirementset_depends_on.py +18 -0
  123. endoreg_db/migrations/_old/0001_initial.py +1857 -0
  124. endoreg_db/migrations/_old/0004_employee_city_employee_post_code_employee_street_and_more.py +68 -0
  125. endoreg_db/migrations/_old/0004_remove_casetemplate_rules_and_more.py +77 -0
  126. endoreg_db/migrations/_old/0005_merge_20251111_1003.py +14 -0
  127. endoreg_db/migrations/_old/0006_sensitivemeta_anonymized_text_and_more.py +68 -0
  128. endoreg_db/migrations/_old/0007_remove_rule_attribute_dtype_remove_rule_rule_type_and_more.py +89 -0
  129. endoreg_db/migrations/_old/0008_remove_event_event_classification_and_more.py +27 -0
  130. endoreg_db/migrations/_old/0009_alter_modelmeta_options_and_more.py +21 -0
  131. endoreg_db/models/__init__.py +78 -123
  132. endoreg_db/models/administration/__init__.py +21 -42
  133. endoreg_db/models/administration/ai/active_model.py +2 -2
  134. endoreg_db/models/administration/ai/ai_model.py +7 -6
  135. endoreg_db/models/administration/case/__init__.py +1 -15
  136. endoreg_db/models/administration/case/case.py +3 -3
  137. endoreg_db/models/administration/case/case_template/__init__.py +2 -14
  138. endoreg_db/models/administration/case/case_template/case_template.py +2 -124
  139. endoreg_db/models/administration/case/case_template/case_template_rule.py +2 -268
  140. endoreg_db/models/administration/case/case_template/case_template_rule_value.py +2 -85
  141. endoreg_db/models/administration/case/case_template/case_template_type.py +2 -25
  142. endoreg_db/models/administration/center/center.py +33 -19
  143. endoreg_db/models/administration/center/center_product.py +12 -9
  144. endoreg_db/models/administration/center/center_resource.py +25 -19
  145. endoreg_db/models/administration/center/center_shift.py +21 -17
  146. endoreg_db/models/administration/center/center_waste.py +16 -8
  147. endoreg_db/models/administration/person/__init__.py +2 -0
  148. endoreg_db/models/administration/person/employee/employee.py +10 -5
  149. endoreg_db/models/administration/person/employee/employee_qualification.py +9 -4
  150. endoreg_db/models/administration/person/employee/employee_type.py +12 -6
  151. endoreg_db/models/administration/person/examiner/examiner.py +13 -11
  152. endoreg_db/models/administration/person/patient/__init__.py +2 -0
  153. endoreg_db/models/administration/person/patient/patient.py +103 -100
  154. endoreg_db/models/administration/person/patient/patient_external_id.py +37 -0
  155. endoreg_db/models/administration/person/person.py +4 -0
  156. endoreg_db/models/administration/person/profession/__init__.py +8 -4
  157. endoreg_db/models/administration/person/user/portal_user_information.py +11 -7
  158. endoreg_db/models/administration/product/product.py +20 -15
  159. endoreg_db/models/administration/product/product_material.py +17 -18
  160. endoreg_db/models/administration/product/product_weight.py +12 -8
  161. endoreg_db/models/administration/product/reference_product.py +23 -55
  162. endoreg_db/models/administration/qualification/qualification.py +7 -3
  163. endoreg_db/models/administration/qualification/qualification_type.py +7 -3
  164. endoreg_db/models/administration/shift/scheduled_days.py +8 -5
  165. endoreg_db/models/administration/shift/shift.py +16 -12
  166. endoreg_db/models/administration/shift/shift_type.py +23 -31
  167. endoreg_db/models/label/__init__.py +7 -8
  168. endoreg_db/models/label/annotation/image_classification.py +10 -9
  169. endoreg_db/models/label/annotation/video_segmentation_annotation.py +8 -5
  170. endoreg_db/models/label/label.py +15 -15
  171. endoreg_db/models/label/label_set.py +19 -6
  172. endoreg_db/models/label/label_type.py +1 -1
  173. endoreg_db/models/label/label_video_segment/_create_from_video.py +5 -8
  174. endoreg_db/models/label/label_video_segment/label_video_segment.py +76 -102
  175. endoreg_db/models/label/video_segmentation_label.py +4 -0
  176. endoreg_db/models/label/video_segmentation_labelset.py +4 -3
  177. endoreg_db/models/media/frame/frame.py +22 -22
  178. endoreg_db/models/media/pdf/raw_pdf.py +110 -182
  179. endoreg_db/models/media/pdf/report_file.py +25 -29
  180. endoreg_db/models/media/pdf/report_reader/report_reader_config.py +30 -46
  181. endoreg_db/models/media/pdf/report_reader/report_reader_flag.py +23 -7
  182. endoreg_db/models/media/video/__init__.py +1 -0
  183. endoreg_db/models/media/video/create_from_file.py +48 -56
  184. endoreg_db/models/media/video/pipe_2.py +8 -9
  185. endoreg_db/models/media/video/video_file.py +150 -108
  186. endoreg_db/models/media/video/video_file_ai.py +288 -74
  187. endoreg_db/models/media/video/video_file_anonymize.py +38 -38
  188. endoreg_db/models/media/video/video_file_frames/__init__.py +3 -1
  189. endoreg_db/models/media/video/video_file_frames/_bulk_create_frames.py +6 -8
  190. endoreg_db/models/media/video/video_file_frames/_create_frame_object.py +7 -9
  191. endoreg_db/models/media/video/video_file_frames/_delete_frames.py +9 -8
  192. endoreg_db/models/media/video/video_file_frames/_extract_frames.py +38 -45
  193. endoreg_db/models/media/video/video_file_frames/_get_frame.py +6 -8
  194. endoreg_db/models/media/video/video_file_frames/_get_frame_number.py +4 -18
  195. endoreg_db/models/media/video/video_file_frames/_get_frame_path.py +4 -3
  196. endoreg_db/models/media/video/video_file_frames/_get_frame_paths.py +7 -6
  197. endoreg_db/models/media/video/video_file_frames/_get_frame_range.py +6 -8
  198. endoreg_db/models/media/video/video_file_frames/_get_frames.py +6 -8
  199. endoreg_db/models/media/video/video_file_frames/_initialize_frames.py +15 -25
  200. endoreg_db/models/media/video/video_file_frames/_manage_frame_range.py +26 -23
  201. endoreg_db/models/media/video/video_file_frames/_mark_frames_extracted_status.py +23 -14
  202. endoreg_db/models/media/video/video_file_io.py +109 -62
  203. endoreg_db/models/media/video/video_file_meta/get_crop_template.py +3 -3
  204. endoreg_db/models/media/video/video_file_meta/get_endo_roi.py +5 -3
  205. endoreg_db/models/media/video/video_file_meta/get_fps.py +37 -34
  206. endoreg_db/models/media/video/video_file_meta/initialize_video_specs.py +19 -25
  207. endoreg_db/models/media/video/video_file_meta/text_meta.py +41 -38
  208. endoreg_db/models/media/video/video_file_meta/video_meta.py +14 -7
  209. endoreg_db/models/media/video/video_file_segments.py +24 -17
  210. endoreg_db/models/media/video/video_metadata.py +19 -35
  211. endoreg_db/models/media/video/video_processing.py +96 -95
  212. endoreg_db/models/medical/contraindication/__init__.py +13 -3
  213. endoreg_db/models/medical/disease.py +22 -16
  214. endoreg_db/models/medical/event.py +31 -18
  215. endoreg_db/models/medical/examination/__init__.py +13 -6
  216. endoreg_db/models/medical/examination/examination.py +17 -18
  217. endoreg_db/models/medical/examination/examination_indication.py +26 -25
  218. endoreg_db/models/medical/examination/examination_time.py +16 -6
  219. endoreg_db/models/medical/examination/examination_time_type.py +9 -6
  220. endoreg_db/models/medical/examination/examination_type.py +3 -4
  221. endoreg_db/models/medical/finding/finding.py +38 -39
  222. endoreg_db/models/medical/finding/finding_classification.py +37 -48
  223. endoreg_db/models/medical/finding/finding_intervention.py +27 -22
  224. endoreg_db/models/medical/finding/finding_type.py +13 -12
  225. endoreg_db/models/medical/hardware/endoscope.py +20 -26
  226. endoreg_db/models/medical/hardware/endoscopy_processor.py +2 -2
  227. endoreg_db/models/medical/laboratory/lab_value.py +62 -91
  228. endoreg_db/models/medical/medication/medication.py +22 -10
  229. endoreg_db/models/medical/medication/medication_indication.py +29 -3
  230. endoreg_db/models/medical/medication/medication_indication_type.py +25 -14
  231. endoreg_db/models/medical/medication/medication_intake_time.py +31 -19
  232. endoreg_db/models/medical/medication/medication_schedule.py +27 -16
  233. endoreg_db/models/medical/organ/__init__.py +15 -12
  234. endoreg_db/models/medical/patient/medication_examples.py +1 -5
  235. endoreg_db/models/medical/patient/patient_disease.py +20 -23
  236. endoreg_db/models/medical/patient/patient_event.py +19 -22
  237. endoreg_db/models/medical/patient/patient_examination.py +48 -54
  238. endoreg_db/models/medical/patient/patient_examination_indication.py +16 -14
  239. endoreg_db/models/medical/patient/patient_finding.py +122 -139
  240. endoreg_db/models/medical/patient/patient_finding_classification.py +44 -49
  241. endoreg_db/models/medical/patient/patient_finding_intervention.py +8 -19
  242. endoreg_db/models/medical/patient/patient_lab_sample.py +28 -23
  243. endoreg_db/models/medical/patient/patient_lab_value.py +82 -89
  244. endoreg_db/models/medical/patient/patient_medication.py +27 -38
  245. endoreg_db/models/medical/patient/patient_medication_schedule.py +28 -36
  246. endoreg_db/models/medical/risk/risk.py +7 -6
  247. endoreg_db/models/medical/risk/risk_type.py +8 -5
  248. endoreg_db/models/metadata/model_meta.py +60 -29
  249. endoreg_db/models/metadata/model_meta_logic.py +125 -18
  250. endoreg_db/models/metadata/pdf_meta.py +19 -24
  251. endoreg_db/models/metadata/sensitive_meta.py +102 -85
  252. endoreg_db/models/metadata/sensitive_meta_logic.py +192 -173
  253. endoreg_db/models/metadata/video_meta.py +51 -31
  254. endoreg_db/models/metadata/video_prediction_logic.py +16 -23
  255. endoreg_db/models/metadata/video_prediction_meta.py +29 -33
  256. endoreg_db/models/other/distribution/date_value_distribution.py +89 -29
  257. endoreg_db/models/other/distribution/multiple_categorical_value_distribution.py +21 -5
  258. endoreg_db/models/other/distribution/numeric_value_distribution.py +114 -53
  259. endoreg_db/models/other/distribution/single_categorical_value_distribution.py +4 -3
  260. endoreg_db/models/other/emission/emission_factor.py +18 -8
  261. endoreg_db/models/other/gender.py +10 -5
  262. endoreg_db/models/other/information_source.py +25 -25
  263. endoreg_db/models/other/material.py +9 -5
  264. endoreg_db/models/other/resource.py +6 -4
  265. endoreg_db/models/other/tag.py +10 -5
  266. endoreg_db/models/other/transport_route.py +13 -8
  267. endoreg_db/models/other/unit.py +10 -6
  268. endoreg_db/models/other/waste.py +6 -5
  269. endoreg_db/models/requirement/requirement.py +580 -272
  270. endoreg_db/models/requirement/requirement_error.py +85 -0
  271. endoreg_db/models/requirement/requirement_evaluation/evaluate_with_dependencies.py +268 -0
  272. endoreg_db/models/requirement/requirement_evaluation/operator_evaluation_models.py +3 -6
  273. endoreg_db/models/requirement/requirement_evaluation/requirement_type_parser.py +90 -64
  274. endoreg_db/models/requirement/requirement_operator.py +36 -33
  275. endoreg_db/models/requirement/requirement_set.py +74 -57
  276. endoreg_db/models/state/__init__.py +4 -4
  277. endoreg_db/models/state/abstract.py +2 -2
  278. endoreg_db/models/state/anonymization.py +12 -0
  279. endoreg_db/models/state/audit_ledger.py +46 -47
  280. endoreg_db/models/state/label_video_segment.py +9 -0
  281. endoreg_db/models/state/raw_pdf.py +40 -46
  282. endoreg_db/models/state/sensitive_meta.py +6 -2
  283. endoreg_db/models/state/video.py +58 -53
  284. endoreg_db/models/upload_job.py +32 -55
  285. endoreg_db/models/utils.py +1 -2
  286. endoreg_db/root_urls.py +21 -2
  287. endoreg_db/serializers/__init__.py +0 -2
  288. endoreg_db/serializers/anonymization.py +18 -10
  289. endoreg_db/serializers/meta/report_meta.py +1 -1
  290. endoreg_db/serializers/meta/sensitive_meta_detail.py +63 -118
  291. endoreg_db/serializers/misc/file_overview.py +11 -99
  292. endoreg_db/serializers/requirements/requirement_sets.py +92 -22
  293. endoreg_db/serializers/video/segmentation.py +2 -1
  294. endoreg_db/serializers/video/video_processing_history.py +20 -5
  295. endoreg_db/services/anonymization.py +75 -73
  296. endoreg_db/services/lookup_service.py +37 -24
  297. endoreg_db/services/pdf_import.py +166 -68
  298. endoreg_db/services/storage_aware_video_processor.py +140 -114
  299. endoreg_db/services/video_import.py +193 -283
  300. endoreg_db/urls/__init__.py +7 -20
  301. endoreg_db/urls/media.py +108 -67
  302. endoreg_db/urls/root_urls.py +29 -0
  303. endoreg_db/utils/__init__.py +15 -5
  304. endoreg_db/utils/ai/multilabel_classification_net.py +116 -20
  305. endoreg_db/utils/case_generator/__init__.py +3 -0
  306. endoreg_db/utils/dataloader.py +88 -16
  307. endoreg_db/utils/defaults/set_default_center.py +32 -0
  308. endoreg_db/utils/names.py +22 -16
  309. endoreg_db/utils/permissions.py +2 -1
  310. endoreg_db/utils/pipelines/process_video_dir.py +1 -1
  311. endoreg_db/utils/requirement_operator_logic/model_evaluators.py +414 -127
  312. endoreg_db/utils/setup_config.py +8 -5
  313. endoreg_db/utils/storage.py +115 -0
  314. endoreg_db/utils/validate_endo_roi.py +8 -2
  315. endoreg_db/utils/video/ffmpeg_wrapper.py +184 -188
  316. endoreg_db/views/__init__.py +0 -10
  317. endoreg_db/views/anonymization/media_management.py +198 -163
  318. endoreg_db/views/anonymization/overview.py +4 -1
  319. endoreg_db/views/anonymization/validate.py +174 -40
  320. endoreg_db/views/media/__init__.py +2 -0
  321. endoreg_db/views/media/pdf_media.py +131 -152
  322. endoreg_db/views/media/sensitive_metadata.py +46 -6
  323. endoreg_db/views/media/video_media.py +89 -82
  324. endoreg_db/views/media/video_segments.py +2 -3
  325. endoreg_db/views/meta/sensitive_meta_detail.py +0 -63
  326. endoreg_db/views/patient/patient.py +5 -4
  327. endoreg_db/views/pdf/pdf_stream.py +20 -21
  328. endoreg_db/views/pdf/reimport.py +11 -32
  329. endoreg_db/views/requirement/evaluate.py +188 -187
  330. endoreg_db/views/requirement/lookup.py +17 -3
  331. endoreg_db/views/requirement/requirement_utils.py +89 -0
  332. endoreg_db/views/video/__init__.py +0 -2
  333. endoreg_db/views/video/correction.py +2 -2
  334. {endoreg_db-0.8.6.1.dist-info → endoreg_db-0.8.8.0.dist-info}/METADATA +7 -3
  335. {endoreg_db-0.8.6.1.dist-info → endoreg_db-0.8.8.0.dist-info}/RECORD +341 -245
  336. endoreg_db/models/administration/permissions/__init__.py +0 -44
  337. endoreg_db/models/media/video/video_file_frames.py +0 -0
  338. endoreg_db/models/metadata/frame_ocr_result.py +0 -0
  339. endoreg_db/models/rule/__init__.py +0 -13
  340. endoreg_db/models/rule/rule.py +0 -27
  341. endoreg_db/models/rule/rule_applicator.py +0 -224
  342. endoreg_db/models/rule/rule_attribute_dtype.py +0 -17
  343. endoreg_db/models/rule/rule_type.py +0 -20
  344. endoreg_db/models/rule/ruleset.py +0 -17
  345. endoreg_db/serializers/video/video_metadata.py +0 -105
  346. endoreg_db/urls/report.py +0 -48
  347. endoreg_db/urls/video.py +0 -61
  348. endoreg_db/utils/case_generator/case_generator.py +0 -159
  349. endoreg_db/utils/case_generator/utils.py +0 -30
  350. endoreg_db/views/report/__init__.py +0 -9
  351. endoreg_db/views/report/report_list.py +0 -112
  352. endoreg_db/views/report/report_with_secure_url.py +0 -28
  353. endoreg_db/views/report/start_examination.py +0 -7
  354. endoreg_db/views.py +0 -0
  355. /endoreg_db/data/{requirement_set → _examples/requirement_set}/endoscopy_bleeding_risk.yaml +0 -0
  356. /endoreg_db/migrations/{0002_add_video_correction_models.py → _old/0002_add_video_correction_models.py} +0 -0
  357. /endoreg_db/migrations/{0003_add_center_display_name.py → _old/0003_add_center_display_name.py} +0 -0
  358. /endoreg_db/{models/media/video/refactor_plan.md → views/pdf/pdf_stream_views.py} +0 -0
  359. {endoreg_db-0.8.6.1.dist-info → endoreg_db-0.8.8.0.dist-info}/WHEEL +0 -0
  360. {endoreg_db-0.8.6.1.dist-info → endoreg_db-0.8.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,32 @@
1
- from django.db import models
2
- from typing import TYPE_CHECKING, Dict, List, Union
3
- from endoreg_db.utils.links.requirement_link import RequirementLinks
4
1
  import logging
5
2
  from subprocess import run
3
+ from typing import TYPE_CHECKING, Dict, List, Union, cast, Tuple
4
+
5
+ from django.db import models
6
+
7
+ from endoreg_db.utils.links.requirement_link import RequirementLinks
8
+
6
9
 
7
10
  logger = logging.getLogger(__name__)
8
11
 
9
12
 
13
+ def _validate_requirement_configuration(instance: "Requirement") -> bool:
14
+ """Ensures requirement fixtures declare both requirement_types and operators."""
15
+ if not instance.requirement_types.exists():
16
+ raise ValueError(
17
+ f"Requirement '{instance.name}' must be associated with at least one RequirementType."
18
+ )
19
+ if not instance.operators.exists():
20
+ raise ValueError(
21
+ f"Requirement '{instance.name}' must be associated with at least one RequirementOperator."
22
+ )
23
+ return True
24
+
25
+
10
26
  QuerySet = models.QuerySet
11
27
 
12
28
  if TYPE_CHECKING:
13
- from endoreg_db.models import (
29
+ from endoreg_db.models import ( # RequirementSet,
14
30
  Disease,
15
31
  DiseaseClassificationChoice,
16
32
  Event,
@@ -19,27 +35,27 @@ if TYPE_CHECKING:
19
35
  Examination,
20
36
  ExaminationIndication,
21
37
  Finding,
22
- FindingIntervention,
23
38
  FindingClassification,
24
39
  FindingClassificationChoice,
25
40
  FindingClassificationType,
41
+ FindingIntervention,
42
+ Gender,
26
43
  LabValue,
27
44
  Medication,
28
45
  MedicationIndication,
29
- MedicationIntakeTime, # Added MedicationIntakeTime
46
+ MedicationIntakeTime, # Added MedicationIntakeTime
30
47
  MedicationSchedule,
31
48
  PatientDisease,
32
49
  PatientEvent,
33
50
  PatientExamination,
34
51
  PatientFinding,
35
- PatientFindingIntervention,
36
52
  PatientFindingClassification,
53
+ PatientFindingIntervention,
37
54
  PatientLabValue,
38
- PatientMedicationSchedule, # Added PatientMedicationSchedule
55
+ PatientMedicationSchedule, # Added PatientMedicationSchedule
39
56
  RequirementOperator,
40
- RequirementSet,
41
- Gender
42
57
  )
58
+
43
59
  # from endoreg_db.utils.links.requirement_link import RequirementLinks # Already imported above
44
60
 
45
61
 
@@ -115,12 +131,12 @@ class Requirement(models.Model):
115
131
  name = models.CharField(max_length=100, unique=True)
116
132
  description = models.TextField(blank=True, null=True)
117
133
 
134
+
118
135
  numeric_value = models.FloatField(
119
136
  blank=True,
120
137
  null=True,
121
138
  help_text="Numeric value for the requirement. If not set, the requirement is not used in calculations.",
122
139
  )
123
-
124
140
  numeric_value_min = models.FloatField(
125
141
  blank=True,
126
142
  null=True,
@@ -131,29 +147,26 @@ class Requirement(models.Model):
131
147
  null=True,
132
148
  help_text="Maximum numeric value for the requirement. If not set, the requirement is not used in calculations.",
133
149
  )
134
-
135
150
  string_value = models.CharField(
136
151
  max_length=100,
137
152
  blank=True,
138
153
  null=True,
139
154
  help_text="String value for the requirement. If not set, the requirement is not used in calculations.",
140
155
  )
141
-
142
156
  string_values = models.TextField(
143
157
  blank=True,
144
158
  null=True,
145
159
  help_text=" ','-separated list of string values for the requirement.If not set, the requirement is not used in calculations.",
146
160
  )
147
-
148
161
  objects = RequirementManager()
149
162
 
150
- requirement_types = models.ManyToManyField( # type: ignore[assignment]
163
+ requirement_types = models.ManyToManyField(
151
164
  "RequirementType",
152
165
  blank=True,
153
166
  related_name="linked_requirements",
154
167
  )
155
168
 
156
- operators = models.ManyToManyField( # type: ignore[assignment]
169
+ operators = models.ManyToManyField(
157
170
  "RequirementOperator",
158
171
  blank=True,
159
172
  related_name="required_in",
@@ -167,120 +180,146 @@ class Requirement(models.Model):
167
180
  null=True,
168
181
  )
169
182
 
170
- examinations = models.ManyToManyField( # type: ignore[assignment]
183
+ examinations = models.ManyToManyField(
171
184
  "Examination",
172
185
  blank=True,
173
186
  related_name="required_in",
174
187
  )
175
188
 
176
- examination_indications = models.ManyToManyField( # type: ignore[assignment]
189
+ examination_indications = models.ManyToManyField(
177
190
  "ExaminationIndication",
178
191
  blank=True,
179
192
  related_name="required_in",
180
193
  )
181
194
 
182
- diseases = models.ManyToManyField( # type: ignore[assignment]
195
+ diseases = models.ManyToManyField(
183
196
  "Disease",
184
197
  blank=True,
185
198
  related_name="required_in",
186
199
  )
187
200
 
188
- disease_classification_choices = models.ManyToManyField( # type: ignore[assignment]
201
+ disease_classification_choices = models.ManyToManyField(
189
202
  "DiseaseClassificationChoice",
190
203
  blank=True,
191
204
  related_name="required_in",
192
205
  )
193
206
 
194
- events = models.ManyToManyField( # type: ignore[assignment]
207
+ events = models.ManyToManyField(
195
208
  "Event",
196
209
  blank=True,
197
210
  related_name="required_in",
198
211
  )
199
212
 
200
- lab_values = models.ManyToManyField( # type: ignore[assignment]
213
+ lab_values = models.ManyToManyField(
201
214
  "LabValue",
202
215
  blank=True,
203
216
  related_name="required_in",
204
217
  )
205
218
 
206
- findings = models.ManyToManyField( # type: ignore[assignment]
219
+ findings = models.ManyToManyField(
207
220
  "Finding",
208
221
  blank=True,
209
222
  related_name="required_in",
210
223
  )
211
224
 
212
- finding_classifications = models.ManyToManyField( # type: ignore[assignment]
225
+ finding_classifications = models.ManyToManyField(
213
226
  "FindingClassification",
214
227
  blank=True,
215
228
  related_name="required_in",
216
229
  )
217
230
 
218
- finding_classification_choices = models.ManyToManyField( # type: ignore[assignment]
231
+ finding_classification_choices = models.ManyToManyField(
219
232
  "FindingClassificationChoice",
220
233
  blank=True,
221
234
  related_name="required_in",
222
235
  )
223
236
 
224
- finding_interventions = models.ManyToManyField( # type: ignore[assignment]
237
+ finding_interventions = models.ManyToManyField(
225
238
  "FindingIntervention",
226
239
  blank=True,
227
240
  related_name="required_in",
228
241
  )
229
242
 
230
- medications = models.ManyToManyField( # type: ignore[assignment]
243
+ medications = models.ManyToManyField(
231
244
  "Medication",
232
245
  blank=True,
233
246
  related_name="required_in",
234
247
  )
235
248
 
236
- medication_indications = models.ManyToManyField( # type: ignore[assignment]
249
+ medication_indications = models.ManyToManyField(
237
250
  "MedicationIndication",
238
251
  blank=True,
239
252
  related_name="required_in",
240
253
  )
241
254
 
242
- medication_intake_times = models.ManyToManyField( # type: ignore[assignment]
255
+ medication_intake_times = models.ManyToManyField(
243
256
  "MedicationIntakeTime",
244
257
  blank=True,
245
258
  related_name="required_in",
246
259
  )
247
260
 
248
- medication_schedules = models.ManyToManyField( # type: ignore[assignment]
261
+ medication_schedules = models.ManyToManyField(
249
262
  "MedicationSchedule",
250
263
  blank=True,
251
264
  related_name="required_in",
252
265
  )
253
266
 
254
- genders = models.ManyToManyField( # type: ignore[assignment]
267
+ genders = models.ManyToManyField(
255
268
  "Gender",
256
269
  blank=True,
257
270
  related_name="required_in",
258
271
  )
259
272
 
260
273
  if TYPE_CHECKING:
261
- requirement_types: models.QuerySet[RequirementType]
262
- operators: models.QuerySet[RequirementOperator]
263
- requirement_sets: models.QuerySet[RequirementSet]
264
- examinations: models.QuerySet[Examination]
265
- examination_indications: models.QuerySet[ExaminationIndication]
266
- lab_values: models.QuerySet[LabValue]
267
- diseases: models.QuerySet[Disease]
268
- disease_classification_choices: models.QuerySet[DiseaseClassificationChoice]
269
- events: models.QuerySet[Event]
270
- findings: models.QuerySet[Finding]
271
- finding_classifications: models.QuerySet[FindingClassification]
272
- finding_classification_choices: models.QuerySet[FindingClassificationChoice]
273
- finding_interventions: models.QuerySet[FindingIntervention]
274
- medications: models.QuerySet[Medication]
275
- medication_indications: models.QuerySet[MedicationIndication]
276
- medication_intake_times: models.QuerySet[MedicationIntakeTime] # Added type hint
277
- medication_schedules: models.QuerySet[MedicationSchedule]
278
- genders: models.QuerySet[Gender]
274
+ requirement_types = cast(
275
+ models.manager.RelatedManager["RequirementType"], requirement_types
276
+ )
277
+ operators = cast(
278
+ models.manager.RelatedManager["RequirementOperator"], operators
279
+ )
280
+ # requirement_sets = cast(models.manager.RelatedManager["RequirementSet"], requirement_sets)
281
+ examinations = cast(models.manager.RelatedManager["Examination"], examinations)
282
+ examination_indications = cast(
283
+ models.manager.RelatedManager["ExaminationIndication"],
284
+ examination_indications,
285
+ )
286
+ lab_values = cast(models.manager.RelatedManager["LabValue"], lab_values)
287
+ diseases = cast(models.manager.RelatedManager["Disease"], diseases)
288
+ disease_classification_choices = cast(
289
+ models.manager.RelatedManager["DiseaseClassificationChoice"],
290
+ disease_classification_choices,
291
+ )
292
+ events = cast(models.manager.RelatedManager["Event"], events)
293
+ findings = cast(models.manager.RelatedManager["Finding"], findings)
294
+ finding_classifications = cast(
295
+ models.manager.RelatedManager["FindingClassification"],
296
+ finding_classifications,
297
+ )
298
+ finding_classification_choices = cast(
299
+ models.manager.RelatedManager["FindingClassificationChoice"],
300
+ finding_classification_choices,
301
+ )
302
+ finding_interventions = cast(
303
+ models.manager.RelatedManager["FindingIntervention"], finding_interventions
304
+ )
305
+ medications = cast(models.manager.RelatedManager["Medication"], medications)
306
+ medication_indications = cast(
307
+ models.manager.RelatedManager["MedicationIndication"],
308
+ medication_indications,
309
+ )
310
+ medication_intake_times = cast(
311
+ models.manager.RelatedManager["MedicationIntakeTime"],
312
+ medication_intake_times,
313
+ )
314
+ medication_schedules = cast(
315
+ models.manager.RelatedManager["MedicationSchedule"], medication_schedules
316
+ )
317
+ genders = cast(models.manager.RelatedManager["Gender"], genders)
279
318
 
280
319
  def natural_key(self):
281
320
  """
282
321
  Returns a tuple containing the instance's name as its natural key.
283
-
322
+
284
323
  This tuple provides a unique identifier for serialization purposes.
285
324
  """
286
325
  return (self.name,)
@@ -289,36 +328,47 @@ class Requirement(models.Model):
289
328
  """Returns the name of the requirement as its string representation."""
290
329
  return str(self.name)
291
330
 
331
+ # override save method to add a validation step; requirements need at least one operator and at least one requirement type
332
+ # def save(self, *args, **kwargs):
333
+ # _valid = _validate_requirement_configuration(
334
+ # self,
335
+ # )
336
+ # super().save(*args, **kwargs)
337
+
292
338
  @property
293
- def expected_models(self) -> List[Union[
294
- "Disease",
295
- "DiseaseClassificationChoice",
296
- "Event",
297
- "EventClassification",
298
- "EventClassificationChoice",
299
- "Examination",
300
- "ExaminationIndication",
301
- "Finding",
302
- "FindingIntervention",
303
- "FindingClassification",
304
- "FindingClassificationChoice",
305
- "FindingClassificationType",
306
- "LabValue",
307
- "Medication",
308
- "MedicationIndication",
309
- "MedicationIntakeTime", # Added MedicationIntakeTime
310
- "PatientDisease",
311
- "PatientEvent",
312
- "PatientExamination",
313
- "PatientFinding",
314
- "PatientFindingIntervention",
315
- "PatientFindingClassification",
316
- "PatientLabValue",
317
- "PatientMedicationSchedule", # Added PatientMedicationSchedule
318
- ]]:
339
+ def expected_models(
340
+ self,
341
+ ) -> List[
342
+ Union[
343
+ "Disease",
344
+ "DiseaseClassificationChoice",
345
+ "Event",
346
+ "EventClassification",
347
+ "EventClassificationChoice",
348
+ "Examination",
349
+ "ExaminationIndication",
350
+ "Finding",
351
+ "FindingIntervention",
352
+ "FindingClassification",
353
+ "FindingClassificationChoice",
354
+ "FindingClassificationType",
355
+ "LabValue",
356
+ "Medication",
357
+ "MedicationIndication",
358
+ "MedicationIntakeTime", # Added MedicationIntakeTime
359
+ "PatientDisease",
360
+ "PatientEvent",
361
+ "PatientExamination",
362
+ "PatientFinding",
363
+ "PatientFindingIntervention",
364
+ "PatientFindingClassification",
365
+ "PatientLabValue",
366
+ "PatientMedicationSchedule", # Added PatientMedicationSchedule
367
+ ]
368
+ ]:
319
369
  """
320
370
  Return the list of model classes that are expected as input for evaluating this requirement.
321
-
371
+
322
372
  The returned models correspond to the requirement types linked to this requirement, mapped via the internal data model dictionary.
323
373
  """
324
374
  req_types = self.requirement_types.all()
@@ -332,66 +382,141 @@ class Requirement(models.Model):
332
382
  def links(self) -> "RequirementLinks":
333
383
  """
334
384
  Return a RequirementLinks object containing all non-null related model instances for this requirement.
335
-
385
+
336
386
  The returned object provides structured access to all associated entities, such as examinations, diseases, findings, classifications, interventions, medications, and related choices, aggregated from the requirement's many-to-many fields.
337
387
  """
338
388
  # requirement_sets is not part of RequirementLinks (avoids circular import); collect other related models
339
389
  models_dict = RequirementLinks(
340
390
  examinations=[_ for _ in self.examinations.all() if _ is not None],
341
- examination_indications=[_ for _ in self.examination_indications.all() if _ is not None],
391
+ examination_indications=[
392
+ _ for _ in self.examination_indications.all() if _ is not None
393
+ ],
342
394
  lab_values=[_ for _ in self.lab_values.all() if _ is not None],
343
395
  diseases=[_ for _ in self.diseases.all() if _ is not None],
344
- disease_classification_choices=[_ for _ in self.disease_classification_choices.all() if _ is not None],
396
+ disease_classification_choices=[
397
+ _ for _ in self.disease_classification_choices.all() if _ is not None
398
+ ],
345
399
  events=[_ for _ in self.events.all() if _ is not None],
346
400
  findings=[_ for _ in self.findings.all() if _ is not None],
347
- finding_classifications=[_ for _ in self.finding_classifications.all() if _ is not None],
348
- finding_classification_choices=[_ for _ in self.finding_classification_choices.all() if _ is not None],
349
- finding_interventions=[_ for _ in self.finding_interventions.all() if _ is not None],
401
+ finding_classifications=[
402
+ _ for _ in self.finding_classifications.all() if _ is not None
403
+ ],
404
+ finding_classification_choices=[
405
+ _ for _ in self.finding_classification_choices.all() if _ is not None
406
+ ],
407
+ finding_interventions=[
408
+ _ for _ in self.finding_interventions.all() if _ is not None
409
+ ],
350
410
  medications=[_ for _ in self.medications.all() if _ is not None],
351
- medication_indications=[_ for _ in self.medication_indications.all() if _ is not None],
352
- medication_intake_times=[_ for _ in self.medication_intake_times.all() if _ is not None],
411
+ medication_indications=[
412
+ _ for _ in self.medication_indications.all() if _ is not None
413
+ ],
414
+ medication_intake_times=[
415
+ _ for _ in self.medication_intake_times.all() if _ is not None
416
+ ],
353
417
  )
354
418
  return models_dict
355
-
419
+
356
420
  @property
357
421
  def data_model_dict(self) -> dict:
358
422
  """
359
423
  Provides a mapping from requirement type names to their corresponding model classes.
360
-
424
+
361
425
  Returns:
362
426
  A dictionary where keys are requirement type names and values are model classes used for requirement evaluation.
363
427
  """
364
428
  from .requirement_evaluation.requirement_type_parser import data_model_dict
429
+
365
430
  return data_model_dict
366
-
431
+
367
432
  @property
368
433
  def active_links(self) -> Dict[str, List]:
369
434
  """Returns a dictionary of linked models containing only non-empty entries.
370
-
435
+
371
436
  The returned dictionary includes only those related model lists that have at least one linked instance.
372
437
  """
373
438
  return self.links.active()
374
-
375
-
376
- def evaluate(self, *args, mode:str, **kwargs):
439
+
440
+ def _parse_string_values(self) -> Dict[str, str]:
441
+ """Parses the optional ``string_values`` field into a dictionary.
442
+
443
+ Values follow a simple ``key=value`` syntax separated by commas. Entries
444
+ without an equals sign are treated as boolean flags (value ``"true"``).
445
+ """
446
+ if not self.string_values:
447
+ return {}
448
+
449
+ parsed: Dict[str, str] = {}
450
+ for raw_entry in self.string_values.split(","):
451
+ entry = raw_entry.strip()
452
+ if not entry:
453
+ continue
454
+ if "=" in entry:
455
+ key, value = entry.split("=", 1)
456
+ parsed[key.strip()] = value.strip()
457
+ else:
458
+ parsed[entry] = "true"
459
+ return parsed
460
+
461
+ def _resolve_queryset_config(self, kwargs: Dict) -> tuple[str, int | None]:
462
+ """Derives queryset evaluation settings from kwargs or ``string_values``.
463
+
464
+ Supported modes:
465
+ - ``any`` (default): at least one item may satisfy the requirement.
466
+ - ``all``: every item in the queryset must satisfy the requirement.
467
+ - ``min_count``/``at_least``/``min``: at least *n* items must satisfy.
468
+ """
469
+ settings = self._parse_string_values()
470
+
471
+ mode_raw = kwargs.get("queryset_mode") or settings.get("qs_mode") or "any"
472
+ mode = str(mode_raw).strip().lower()
473
+ mode_aliases = {
474
+ "min": "min_count",
475
+ "at_least": "min_count",
476
+ "minimum": "min_count",
477
+ }
478
+ mode = mode_aliases.get(mode, mode)
479
+ if mode not in {"any", "all", "min_count"}:
480
+ mode = "any"
481
+
482
+ min_count_raw = kwargs.get("queryset_min_count")
483
+ if min_count_raw is None:
484
+ for candidate_key in ("qs_min_count", "qs_min", "qs_count", "qs_required"):
485
+ if candidate_key in settings:
486
+ min_count_raw = settings[candidate_key]
487
+ break
488
+
489
+ try:
490
+ min_count = int(min_count_raw) if min_count_raw is not None else None
491
+ except (TypeError, ValueError):
492
+ min_count = None
493
+
494
+ return mode, min_count
495
+
496
+ def evaluate(self, *args, mode: str, **kwargs):
377
497
  """
378
498
  Evaluates whether the requirement is satisfied for the given input models using linked operators and gender constraints.
379
-
499
+
380
500
  Args:
381
501
  *args: Instances or QuerySets of expected model classes to be evaluated. Each must have a `.links` property returning a `RequirementLinks` object.
382
502
  mode: Evaluation mode; "strict" requires all operators to pass, "loose" requires any operator to pass.
383
503
  **kwargs: Additional keyword arguments passed to operator evaluations.
384
-
504
+
385
505
  Returns:
386
506
  True if the requirement is satisfied according to the specified mode, linked operators, and gender restrictions; otherwise, False.
387
-
507
+
388
508
  Raises:
389
509
  ValueError: If an invalid mode is provided.
390
510
  TypeError: If an input is not an instance or QuerySet of expected models, or lacks a valid `.links` attribute.
391
-
511
+
392
512
  If the requirement specifies genders, only input containing a patient with a matching gender will be considered valid for evaluation.
393
513
  """
394
- #TODO Review, Optimize or remove
514
+
515
+ try:
516
+ _validate_requirement_configuration(self)
517
+ except Exception as e:
518
+ logger.warning(str(e))
519
+ # TODO Review, Optimize or remove
395
520
  if mode not in ["strict", "loose"]:
396
521
  raise ValueError(f"Invalid mode: {mode}. Use 'strict' or 'loose'.")
397
522
 
@@ -400,6 +525,11 @@ class Requirement(models.Model):
400
525
  requirement_req_links = self.links
401
526
  expected_models = self.expected_models
402
527
 
528
+ operators = list(self.operators.all())
529
+ has_operators = bool(operators)
530
+ requirement_has_conditions = bool(requirement_req_links.active())
531
+ queryset_mode, queryset_min_count = self._resolve_queryset_config(kwargs)
532
+
403
533
  # helpers to avoid passing a complex tuple to isinstance/issubclass which confuses type checkers
404
534
  def _is_expected_instance(obj) -> bool:
405
535
  for cls in expected_models:
@@ -413,7 +543,7 @@ class Requirement(models.Model):
413
543
  return False
414
544
 
415
545
  def _is_queryset_of_expected(qs) -> bool:
416
- if not isinstance(qs, models.QuerySet) or not hasattr(qs, 'model'):
546
+ if not isinstance(qs, models.QuerySet) or not hasattr(qs, "model"):
417
547
  return False
418
548
  for cls in expected_models:
419
549
  if isinstance(cls, type):
@@ -433,76 +563,122 @@ class Requirement(models.Model):
433
563
  if not _is_expected_instance(_input):
434
564
  # Allow QuerySets of expected models
435
565
  if _is_queryset_of_expected(_input):
436
- # For QuerySets, evaluate each item individually and return True if any matches
437
- if not _input.exists(): # Skip empty querysets
566
+ if not _input.exists():
567
+ # Empty queryset: enforce stricter modes immediately
568
+ if queryset_mode == "all":
569
+ return False
570
+ if queryset_mode == "min_count":
571
+ required = (
572
+ queryset_min_count
573
+ if queryset_min_count is not None
574
+ else 1
575
+ )
576
+ if required > 0:
577
+ return False
438
578
  continue
439
-
440
- queryset_results = []
579
+
580
+ queryset_results: List[bool] = []
581
+ queryset_true_count = 0
582
+ queryset_item_count = 0
583
+
441
584
  for item in _input:
442
- if not hasattr(item, 'links') or not isinstance(item.links, RequirementLinks):
585
+ if not hasattr(item, "links") or not isinstance(
586
+ item.links, RequirementLinks
587
+ ):
443
588
  raise TypeError(
444
589
  f"Item {item} of type {type(item)} in QuerySet does not have a valid .links attribute of type RequirementLinks."
445
590
  )
446
-
447
- # Evaluate this single item against the requirement
448
- item_input_links = RequirementLinks(**item.links.active())
449
-
450
- # Evaluate all operators for this single item
451
- item_operator_results = []
452
- for operator in self.operators.all():
453
- try:
454
- operator_result = operator.evaluate(
455
- requirement_links=requirement_req_links,
456
- input_links=item_input_links,
457
- requirement=self,
458
- original_input_args=args,
459
- **kwargs
460
- )
461
- item_operator_results.append(operator_result)
462
- except Exception as e:
463
- logger.debug(f"Operator {operator.name} evaluation failed for item {item}: {e}")
464
- item_operator_results.append(False)
465
-
466
- # Apply evaluation mode for this single item
467
- item_result = evaluate_result_list_func(item_operator_results) if item_operator_results else True
591
+
592
+ item_active_links = item.links.active()
593
+ item_input_links = RequirementLinks(**item_active_links)
594
+
595
+ for link_key, link_list in item_active_links.items():
596
+ if link_key not in aggregated_input_links_data:
597
+ aggregated_input_links_data[link_key] = []
598
+ aggregated_input_links_data[link_key].extend(link_list)
599
+
600
+ per_item_args = tuple(
601
+ item if arg is _input else arg for arg in args
602
+ )
603
+ op_kwargs = kwargs.copy()
604
+ op_kwargs["requirement"] = self
605
+ op_kwargs["original_input_args"] = per_item_args
606
+
607
+ if has_operators:
608
+ item_operator_results: List[bool] = []
609
+ for operator in operators:
610
+ try:
611
+ operator_result = operator.evaluate(
612
+ requirement_links=requirement_req_links,
613
+ input_links=item_input_links,
614
+ **op_kwargs,
615
+ )
616
+ item_operator_results.append(operator_result)
617
+ except Exception as exc:
618
+ logger.debug(
619
+ f"Operator {operator.name} evaluation failed for item {item}: {exc}"
620
+ )
621
+ item_operator_results.append(False)
622
+ item_result = (
623
+ evaluate_result_list_func(item_operator_results)
624
+ if item_operator_results
625
+ else True
626
+ )
627
+ else:
628
+ item_result = not requirement_has_conditions
629
+
468
630
  queryset_results.append(item_result)
631
+ if item_result:
632
+ queryset_true_count += 1
633
+ queryset_item_count += 1
469
634
  processed_inputs_count += 1
470
-
471
- # If any item in the QuerySet matches, return True for the whole QuerySet evaluation
472
- if any(queryset_results):
473
- return True
474
- continue # Move to the next arg after processing queryset
635
+
636
+ if queryset_mode == "all":
637
+ if queryset_item_count == 0 or not all(queryset_results):
638
+ return False
639
+ elif queryset_mode == "min_count":
640
+ required = (
641
+ queryset_min_count if queryset_min_count is not None else 1
642
+ )
643
+ if queryset_true_count < max(required, 0):
644
+ return False
645
+ # queryset_mode == "any" imposes no extra constraint here
646
+ continue # Move to the next arg after processing queryset
475
647
  else:
476
648
  raise TypeError(
477
- f"Input type {type(_input)} is not among expected models: {self.expected_models} "
478
- f"nor a QuerySet of expected models."
649
+ f"Input type {type(_input)} is not among expected models: {self.expected_models} nor a QuerySet of expected models."
479
650
  )
480
651
 
481
652
  # Process single model instance
482
- if not hasattr(_input, 'links') or not isinstance(_input.links, RequirementLinks):
653
+ if not hasattr(_input, "links") or not isinstance(
654
+ _input.links, RequirementLinks
655
+ ):
483
656
  raise TypeError(
484
657
  f"Input {_input} of type {type(_input)} does not have a valid .links attribute of type RequirementLinks."
485
658
  )
486
-
487
- active_input_links = _input.links.active() # Get dict of non-empty lists
659
+
660
+ active_input_links = _input.links.active() # Get dict of non-empty lists
488
661
  for link_key, link_list in active_input_links.items():
489
662
  if link_key not in aggregated_input_links_data:
490
663
  aggregated_input_links_data[link_key] = []
491
664
  aggregated_input_links_data[link_key].extend(link_list)
492
665
  processed_inputs_count += 1
493
666
 
494
- if not processed_inputs_count and args: # If args were provided but none were processable (e.g. all empty querysets)
495
- # This situation implies no relevant data was provided for evaluation against the requirement.
496
- # Depending on operator logic (e.g., "requires at least one matching item"), this might lead to False.
497
- # For "models_match_any", an empty input_links will likely result in False if requirement_req_links is not empty.
498
- pass
499
-
667
+ if (
668
+ not processed_inputs_count and args
669
+ ): # If args were provided but none were processable (e.g. all empty querysets)
670
+ # This situation implies no relevant data was provided for evaluation against the requirement.
671
+ # Depending on operator logic (e.g., "requires at least one matching item"), this might lead to False.
672
+ # For "models_match_any", an empty input_links will likely result in False if requirement_req_links is not empty.
673
+ pass
500
674
 
501
675
  # Deduplicate items within each list after aggregation
502
676
  for key in aggregated_input_links_data:
503
677
  try:
504
678
  # Using dict.fromkeys to preserve order and remove duplicates for hashable items
505
- aggregated_input_links_data[key] = list(dict.fromkeys(aggregated_input_links_data[key]))
679
+ aggregated_input_links_data[key] = list(
680
+ dict.fromkeys(aggregated_input_links_data[key])
681
+ )
506
682
  except TypeError:
507
683
  # Fallback for non-hashable items (though Django models are hashable)
508
684
  temp_list = []
@@ -510,14 +686,15 @@ class Requirement(models.Model):
510
686
  if item not in temp_list:
511
687
  temp_list.append(item)
512
688
  aggregated_input_links_data[key] = temp_list
513
-
689
+
514
690
  final_input_links = RequirementLinks(**aggregated_input_links_data)
515
-
691
+
516
692
  # Gender strict check: if this requirement has genders, only pass if patient.gender is in the set
517
693
  genders_exist = self.genders.exists()
518
694
  if genders_exist:
519
695
  # Import here to avoid circular import
520
696
  from endoreg_db.models.administration.person.patient import Patient
697
+
521
698
  patient = None
522
699
  for arg in args:
523
700
  if isinstance(arg, Patient):
@@ -528,62 +705,81 @@ class Requirement(models.Model):
528
705
  if not self.genders.filter(pk=patient.gender.pk).exists():
529
706
  return False
530
707
 
531
- operators = self.operators.all()
532
- if not operators.exists(): # If a requirement has no operators, its evaluation is ambiguous.
533
- # Consider if this should be True, False, or an error.
534
- # For now, if no operators, and mode is strict, it's vacuously true. If loose, vacuously false.
535
- # However, typically a requirement implies some condition.
536
- # Let's assume if no operators, it cannot be satisfied unless it also has no specific links.
537
- # This behavior might need further refinement based on business logic.
538
- if not requirement_req_links.active(): # No conditions in requirement
539
- return True # Vacuously true if requirement itself is empty
540
- return False # Cannot be satisfied if requirement has conditions but no operators to check them
541
-
708
+ if (
709
+ not has_operators
710
+ ): # If a requirement has no operators, its evaluation is ambiguous.
711
+ if not requirement_has_conditions: # No conditions in requirement
712
+ return True # Vacuously true if requirement itself is empty
713
+ return False # Cannot be satisfied if requirement has conditions but no operators to check them
542
714
 
543
715
  operator_results = []
544
716
  for operator in operators:
545
717
  # Prepare kwargs for the operator, including the current Requirement instance
546
- op_kwargs = kwargs.copy() # Start with kwargs passed to Requirement.evaluate
547
- op_kwargs['requirement'] = self # Add the Requirement instance itself
548
- op_kwargs['original_input_args'] = args # Add the original input arguments for operators that need them (e.g., age operators)
549
- operator_results.append(operator.evaluate(
550
- requirement_links=requirement_req_links,
551
- input_links=final_input_links,
552
- **op_kwargs
553
- ))
718
+ op_kwargs = (
719
+ kwargs.copy()
720
+ ) # Start with kwargs passed to Requirement.evaluate
721
+ op_kwargs["requirement"] = self # Add the Requirement instance itself
722
+ op_kwargs["original_input_args"] = (
723
+ args # Add the original input arguments for operators that need them (e.g., age operators)
724
+ )
725
+ operator_results.append(
726
+ operator.evaluate(
727
+ requirement_links=requirement_req_links,
728
+ input_links=final_input_links,
729
+ **op_kwargs,
730
+ )
731
+ )
554
732
 
555
733
  is_valid = evaluate_result_list_func(operator_results)
556
734
 
557
735
  return is_valid
558
736
 
559
- def evaluate_with_details(self, *args, mode:str, **kwargs):
737
+
738
+ def evaluate_with_details(self, *args, mode: str, **kwargs) -> Tuple[bool, str]:
560
739
  """
561
740
  Evaluates whether the requirement is satisfied for the given input models using linked operators and gender constraints.
562
-
741
+
563
742
  Args:
564
743
  *args: Instances or QuerySets of expected model classes to be evaluated. Each must have a `.links` property returning a `RequirementLinks` object.
565
744
  mode: Evaluation mode; "strict" requires all operators to pass, "loose" requires any operator to pass.
566
745
  **kwargs: Additional keyword arguments passed to operator evaluations.
567
-
746
+
568
747
  Returns:
569
- True if the requirement is satisfied according to the specified mode, linked operators, and gender restrictions; otherwise, False.
570
-
748
+ (met, details):
749
+ met: True/False, ob die Voraussetzung erfüllt ist
750
+ details: menschenlesbare Erklärung (für UI geeignet)
751
+
571
752
  Raises:
572
- ValueError: If an invalid mode is provided.
573
- TypeError: If an input is not an instance or QuerySet of expected models, or lacks a valid `.links` attribute.
574
-
575
- If the requirement specifies genders, only input containing a patient with a matching gender will be considered valid for evaluation.
753
+ RequirementEvaluationError:
754
+ - bei ungültigem Modus
755
+ - bei komplett falschen Input-Typen / fehlender .links-Struktur
576
756
  """
577
- #TODO Review, Optimize or remove
757
+ from endoreg_db.models.requirement.requirement_error import RequirementEvaluationError
758
+
759
+ # --- Mode validieren -------------------------------------------------
578
760
  if mode not in ["strict", "loose"]:
579
- raise ValueError(f"Invalid mode: {mode}. Use 'strict' or 'loose'.")
761
+ raise RequirementEvaluationError(
762
+ requirement=self,
763
+ code="INVALID_MODE",
764
+ technical_message=f"Invalid mode: {mode}. Use 'strict' or 'loose'.",
765
+ user_message=(
766
+ "Diese Voraussetzung ist intern mit einem ungültigen "
767
+ "Bewertungsmodus konfiguriert und kann aktuell nicht "
768
+ "korrekt geprüft werden."
769
+ ),
770
+ )
580
771
 
581
772
  evaluate_result_list_func = all if mode == "strict" else any
582
773
 
583
774
  requirement_req_links = self.links
584
775
  expected_models = self.expected_models
585
776
 
586
- # helpers to avoid passing a complex tuple to isinstance/issubclass which confuses type checkers
777
+ operators = list(self.operators.all())
778
+ has_operators = bool(operators)
779
+ requirement_has_conditions = bool(requirement_req_links.active())
780
+ queryset_mode, queryset_min_count = self._resolve_queryset_config(kwargs)
781
+
782
+ # --- Helper für Typprüfung ------------------------------------------
587
783
  def _is_expected_instance(obj) -> bool:
588
784
  for cls in expected_models:
589
785
  if isinstance(cls, type):
@@ -591,12 +787,12 @@ class Requirement(models.Model):
591
787
  if isinstance(obj, cls):
592
788
  return True
593
789
  except Exception:
594
- # cls might not be a runtime type
790
+ # cls might nicht runtime-kompatibel sein
595
791
  continue
596
792
  return False
597
793
 
598
794
  def _is_queryset_of_expected(qs) -> bool:
599
- if not isinstance(qs, models.QuerySet) or not hasattr(qs, 'model'):
795
+ if not isinstance(qs, models.QuerySet) or not hasattr(qs, "model"):
600
796
  return False
601
797
  for cls in expected_models:
602
798
  if isinstance(cls, type):
@@ -607,161 +803,273 @@ class Requirement(models.Model):
607
803
  continue
608
804
  return False
609
805
 
610
- # Aggregate RequirementLinks from all input arguments
611
- aggregated_input_links_data = {}
806
+ # --- RequirementLinks aus allen Inputs aggregieren -------------------
807
+ aggregated_input_links_data: dict = {}
612
808
  processed_inputs_count = 0
613
809
 
614
810
  for _input in args:
615
- # Check if the input is an instance of any of the expected model types
616
811
  if not _is_expected_instance(_input):
617
- # Allow QuerySets of expected models
812
+ # QuerySet von erwarteten Typen erlauben
618
813
  if _is_queryset_of_expected(_input):
619
- # For QuerySets, evaluate each item individually and return True if any matches
620
- if not _input.exists(): # Skip empty querysets
814
+ if not _input.exists():
815
+ # leeres QS -> je nach QS-Mode sofort nicht erfüllt
816
+ if queryset_mode == "all":
817
+ return (
818
+ False,
819
+ "Für diese Voraussetzung müssen alle passenden Einträge vorliegen, "
820
+ "aber es wurden keine entsprechenden Datensätze gefunden.",
821
+ )
822
+ if queryset_mode == "min_count":
823
+ required = (
824
+ queryset_min_count
825
+ if queryset_min_count is not None
826
+ else 1
827
+ )
828
+ if required > 0:
829
+ return (
830
+ False,
831
+ f"Für diese Voraussetzung werden mindestens {required} passende "
832
+ "Einträge benötigt, es wurden jedoch keine gefunden.",
833
+ )
834
+ # queryset_mode == "any" bei leerem QS -> neutral (keine zusätzliche Einschränkung)
621
835
  continue
622
-
623
- queryset_results = []
836
+
837
+ queryset_results: List[bool] = []
838
+ queryset_true_count = 0
839
+ queryset_item_count = 0
840
+
624
841
  for item in _input:
625
- if not hasattr(item, 'links') or not isinstance(item.links, RequirementLinks):
626
- raise TypeError(
627
- f"Item {item} of type {type(item)} in QuerySet does not have a valid .links attribute of type RequirementLinks."
842
+ if not hasattr(item, "links") or not isinstance(
843
+ item.links, RequirementLinks
844
+ ):
845
+ raise RequirementEvaluationError(
846
+ requirement=self,
847
+ code="MISSING_LINKS_ATTR",
848
+ technical_message=(
849
+ f"Item {item} of type {type(item)} in QuerySet does not "
850
+ f"have a valid .links attribute of type RequirementLinks."
851
+ ),
852
+ user_message=(
853
+ "Für einen Datensatz fehlen die intern benötigten Verknüpfungen, "
854
+ "sodass diese Voraussetzung nicht korrekt geprüft werden kann."
855
+ ),
856
+ meta={"item_type": str(type(item))},
628
857
  )
629
-
630
- # Evaluate this single item against the requirement
631
- item_input_links = RequirementLinks(**item.links.active())
632
-
633
- # Evaluate all operators for this single item
634
- item_operator_results = []
635
- for operator in self.operators.all():
636
- try:
637
- operator_result = operator.evaluate(
638
- requirement_links=requirement_req_links,
639
- input_links=item_input_links,
640
- requirement=self,
641
- original_input_args=args,
642
- **kwargs
643
- )
644
- item_operator_results.append(operator_result)
645
- except Exception as e:
646
- logger.debug(f"Operator {operator.name} evaluation failed for item {item}: {e}")
647
- item_operator_results.append(False)
648
-
649
- # Apply evaluation mode for this single item
650
- item_result = evaluate_result_list_func(item_operator_results) if item_operator_results else True
858
+
859
+ item_active_links = item.links.active()
860
+ item_input_links = RequirementLinks(**item_active_links)
861
+
862
+ # Links sammeln
863
+ for link_key, link_list in item_active_links.items():
864
+ if link_key not in aggregated_input_links_data:
865
+ aggregated_input_links_data[link_key] = []
866
+ aggregated_input_links_data[link_key].extend(link_list)
867
+
868
+ per_item_args = tuple(
869
+ item if arg is _input else arg for arg in args
870
+ )
871
+ op_kwargs = kwargs.copy()
872
+ op_kwargs["requirement"] = self
873
+ op_kwargs["original_input_args"] = per_item_args
874
+
875
+ if has_operators:
876
+ item_operator_results: List[bool] = []
877
+ for operator in operators:
878
+ try:
879
+ operator_result = operator.evaluate(
880
+ requirement_links=requirement_req_links,
881
+ input_links=item_input_links,
882
+ **op_kwargs,
883
+ )
884
+ item_operator_results.append(operator_result)
885
+ except Exception as exc:
886
+ logger.debug(
887
+ "Operator %s evaluation failed for item %s: %s",
888
+ getattr(operator, "name", "unknown"),
889
+ item,
890
+ exc,
891
+ )
892
+ item_operator_results.append(False)
893
+ item_result = (
894
+ evaluate_result_list_func(item_operator_results)
895
+ if item_operator_results
896
+ else True
897
+ )
898
+ else:
899
+ # keine Operatoren -> Bedingung erfüllt, wenn Requirement selbst keine Bedingungen hat
900
+ item_result = not requirement_has_conditions
901
+
651
902
  queryset_results.append(item_result)
903
+ if item_result:
904
+ queryset_true_count += 1
905
+ queryset_item_count += 1
652
906
  processed_inputs_count += 1
653
-
654
- # If any item in the QuerySet matches, return True for the whole QuerySet evaluation
655
- if any(queryset_results):
656
- return True
657
- continue # Move to the next arg after processing queryset
658
- else:
659
- raise TypeError(
660
- f"Input type {type(_input)} is not among expected models: {self.expected_models} "
661
- f"nor a QuerySet of expected models."
662
- )
663
907
 
664
- # Process single model instance
665
- if not hasattr(_input, 'links') or not isinstance(_input.links, RequirementLinks):
666
- raise TypeError(
667
- f"Input {_input} of type {type(_input)} does not have a valid .links attribute of type RequirementLinks."
908
+ # QS-Modus nach Auswertung anwenden
909
+ if queryset_mode == "all":
910
+ if queryset_item_count == 0 or not all(queryset_results):
911
+ return (
912
+ False,
913
+ "Für diese Voraussetzung müssen alle relevanten Einträge die Bedingung erfüllen.",
914
+ )
915
+ elif queryset_mode == "min_count":
916
+ required = (
917
+ queryset_min_count if queryset_min_count is not None else 1
918
+ )
919
+ if queryset_true_count < max(required, 0):
920
+ return (
921
+ False,
922
+ f"Für diese Voraussetzung werden mindestens {max(required, 0)} "
923
+ f"passende Einträge benötigt (gefunden: {queryset_true_count}).",
924
+ )
925
+ # queryset_mode == "any": keine zusätzliche Einschränkung
926
+ continue
927
+
928
+ # Weder Instanz noch QS eines erwarteten Modells -> Konfig-/Aufruf-Fehler
929
+ raise RequirementEvaluationError(
930
+ requirement=self,
931
+ code="INVALID_INPUT_TYPE",
932
+ technical_message=(
933
+ f"Input type {type(_input)} is not among expected models: "
934
+ f"{self.expected_models} nor a QuerySet of expected models."
935
+ ),
936
+ user_message=(
937
+ "Diese Voraussetzung wurde mit einem nicht passenden Datentyp "
938
+ "aufgerufen und kann aktuell nicht korrekt geprüft werden."
939
+ ),
940
+ meta={"input_type": str(type(_input))},
941
+ )
942
+
943
+ # Einzelinstanz erwarteten Typs
944
+ if not hasattr(_input, "links") or not isinstance(
945
+ _input.links, RequirementLinks
946
+ ):
947
+ raise RequirementEvaluationError(
948
+ requirement=self,
949
+ code="MISSING_LINKS_ATTR",
950
+ technical_message=(
951
+ f"Input {_input} of type {type(_input)} does not have a valid "
952
+ f".links attribute of type RequirementLinks."
953
+ ),
954
+ user_message=(
955
+ "Für die Auswertung dieser Voraussetzung fehlen die intern "
956
+ "benötigten Verknüpfungsinformationen."
957
+ ),
958
+ meta={"input_type": str(type(_input))},
668
959
  )
669
-
670
- active_input_links = _input.links.active() # Get dict of non-empty lists
960
+
961
+ active_input_links = _input.links.active()
671
962
  for link_key, link_list in active_input_links.items():
672
963
  if link_key not in aggregated_input_links_data:
673
964
  aggregated_input_links_data[link_key] = []
674
965
  aggregated_input_links_data[link_key].extend(link_list)
675
966
  processed_inputs_count += 1
676
967
 
677
- if not processed_inputs_count and args: # If args were provided but none were processable (e.g. all empty querysets)
678
- # This situation implies no relevant data was provided for evaluation against the requirement.
679
- # Depending on operator logic (e.g., "requires at least one matching item"), this might lead to False.
680
- # For "models_match_any", an empty input_links will likely result in False if requirement_req_links is not empty.
681
- pass
682
-
968
+ # Wenn es zwar *args gibt, aber alles leer/irrelevant war, lassen wir das weiterlaufen.
969
+ # Operatoren sehen dann ggf. ein leeres final_input_links.
683
970
 
684
- # Deduplicate items within each list after aggregation
971
+ # Deduplizieren der aggregierten Links
685
972
  for key in aggregated_input_links_data:
686
973
  try:
687
- # Using dict.fromkeys to preserve order and remove duplicates for hashable items
688
- aggregated_input_links_data[key] = list(dict.fromkeys(aggregated_input_links_data[key]))
974
+ aggregated_input_links_data[key] = list(
975
+ dict.fromkeys(aggregated_input_links_data[key])
976
+ )
689
977
  except TypeError:
690
- # Fallback for non-hashable items (though Django models are hashable)
691
- temp_list = []
978
+ # Fallback für nicht-hashbare Items
979
+ tmp: list = []
692
980
  for item in aggregated_input_links_data[key]:
693
- if item not in temp_list:
694
- temp_list.append(item)
695
- aggregated_input_links_data[key] = temp_list
696
-
981
+ if item not in tmp:
982
+ tmp.append(item)
983
+ aggregated_input_links_data[key] = tmp
984
+
697
985
  final_input_links = RequirementLinks(**aggregated_input_links_data)
698
-
699
- # Gender strict check: if this requirement has genders, only pass if patient.gender is in the set
986
+
987
+ # --- Gender-Check ----------------------------------------------------
700
988
  genders_exist = self.genders.exists()
701
989
  if genders_exist:
702
- # Import here to avoid circular import
703
990
  from endoreg_db.models.administration.person.patient import Patient
991
+
704
992
  patient = None
705
993
  for arg in args:
706
994
  if isinstance(arg, Patient):
707
995
  patient = arg
708
996
  break
997
+
709
998
  if patient is None or patient.gender is None:
710
- return False
999
+ return (
1000
+ False,
1001
+ "Für diese Voraussetzung ist ein hinterlegtes Geschlecht des Patienten erforderlich.",
1002
+ )
1003
+
711
1004
  if not self.genders.filter(pk=patient.gender.pk).exists():
712
- return False
1005
+ return (
1006
+ False,
1007
+ "Diese Voraussetzung gilt nur für bestimmte Geschlechter und ist "
1008
+ "für diesen Patienten nicht erfüllt.",
1009
+ )
713
1010
 
714
- operators = self.operators.all()
715
- if not operators.exists(): # If a requirement has no operators, its evaluation is ambiguous.
716
- # Consider if this should be True, False, or an error.
717
- # For now, if no operators, and mode is strict, it's vacuously true. If loose, vacuously false.
718
- # However, typically a requirement implies some condition.
719
- # Let's assume if no operators, it cannot be satisfied unless it also has no specific links.
720
- # This behavior might need further refinement based on business logic.
721
- if not requirement_req_links.active(): # No conditions in requirement
722
- return True # Vacuously true if requirement itself is empty
723
- return False # Cannot be satisfied if requirement has conditions but no operators to check them
1011
+ # --- Fall: keine Operatoren -----------------------------------------
1012
+ if not has_operators:
1013
+ if not requirement_has_conditions:
1014
+ return True, "Keine Operatoren für die Bewertung erforderlich."
1015
+ return (
1016
+ False,
1017
+ "Die Voraussetzung besitzt Bedingungen, aber keinen Operator zur Auswertung.",
1018
+ )
724
1019
 
1020
+ # --- Operatoren anwenden --------------------------------------------
1021
+ operator_results: List[bool] = []
1022
+ operator_details: List[str] = []
725
1023
 
726
- operator_results = []
727
- operator_details = []
728
1024
  for operator in operators:
729
- # Prepare kwargs for the operator, including the current Requirement instance
730
- op_kwargs = kwargs.copy() # Start with kwargs passed to Requirement.evaluate
731
- op_kwargs['requirement'] = self # Add the Requirement instance itself
732
- op_kwargs['original_input_args'] = args # Add the original input arguments for operators that need them (e.g., age operators)
1025
+ op_kwargs = kwargs.copy()
1026
+ op_kwargs["requirement"] = self
1027
+ op_kwargs["original_input_args"] = args
1028
+
733
1029
  try:
734
1030
  operator_result = operator.evaluate(
735
1031
  requirement_links=requirement_req_links,
736
1032
  input_links=final_input_links,
737
- **op_kwargs
1033
+ **op_kwargs,
738
1034
  )
739
1035
  operator_results.append(operator_result)
740
- operator_details.append(f"{operator.name}: {'Passed' if operator_result else 'Failed'}")
1036
+ operator_details.append(
1037
+ f"{operator.name}: {'erfüllt' if operator_result else 'nicht erfüllt'}"
1038
+ )
741
1039
  except Exception as e:
742
1040
  operator_results.append(False)
743
- operator_details.append(f"{operator.name}: {str(e)}")
1041
+ operator_details.append(f"{operator.name}: technischer Fehler ({e})")
1042
+ logger.debug(
1043
+ "Operator %s evaluation failed for requirement %s: %s",
1044
+ getattr(operator, "name", "unknown"),
1045
+ getattr(self, "name", "unknown"),
1046
+ e,
1047
+ )
744
1048
 
745
1049
  is_valid = evaluate_result_list_func(operator_results)
746
1050
 
747
- # Create detailed feedback
1051
+ # --- Detailtext bauen -----------------------------------------------
748
1052
  if not operator_results:
749
- details = "Keine Operatoren für die Bewertung verfügbar"
1053
+ details = "Keine Operatoren für die Bewertung verfügbar."
750
1054
  elif len(operator_results) == 1:
751
1055
  details = operator_details[0]
752
1056
  else:
753
- failed_details = [detail for detail, result in zip(operator_details, operator_results) if not result]
1057
+ failed_details = [
1058
+ detail
1059
+ for detail, result in zip(operator_details, operator_results)
1060
+ if not result
1061
+ ]
754
1062
  if failed_details:
755
1063
  details = "; ".join(failed_details)
756
1064
  else:
757
- details = "Alle Operatoren erfolgreich"
1065
+ details = "Alle verknüpften Bedingungen sind erfüllt."
758
1066
 
759
- # Append working directory for debugging convenience
1067
+ # Arbeitsverzeichnis als Debug-Helfer anhängen (optional)
760
1068
  try:
761
1069
  cwd = run("pwd", capture_output=True, text=True).stdout.strip()
762
1070
  details = f"{details}\ncwd: {cwd}"
763
1071
  except Exception:
764
- # non-fatal: ignore if subprocess fails
1072
+ # nicht kritisch
765
1073
  pass
766
1074
 
767
- return is_valid, details
1075
+ return bool(is_valid), details