monkeybrain-runtime 1.0.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.
Files changed (838) hide show
  1. monkeybrain_runtime-1.0.0.dist-info/METADATA +76 -0
  2. monkeybrain_runtime-1.0.0.dist-info/RECORD +838 -0
  3. monkeybrain_runtime-1.0.0.dist-info/WHEEL +5 -0
  4. monkeybrain_runtime-1.0.0.dist-info/entry_points.txt +3 -0
  5. monkeybrain_runtime-1.0.0.dist-info/top_level.txt +2 -0
  6. services/__init__.py +8 -0
  7. services/agentos/__init__.py +0 -0
  8. services/agentos/main.py +1 -0
  9. services/assets/helpers/__init__.py +12 -0
  10. services/assets/helpers/device.py +59 -0
  11. services/assets/helpers/equipment.py +179 -0
  12. services/assets/helpers/instruments.py +72 -0
  13. services/assets/helpers/machines.py +183 -0
  14. services/assets/helpers/materials.py +76 -0
  15. services/assets/helpers/parts.py +116 -0
  16. services/assets/helpers/plc.py +134 -0
  17. services/assets/helpers/tags.py +108 -0
  18. services/assets/helpers/tools.py +101 -0
  19. services/assets/main.py +75 -0
  20. services/assets/models/__init__.py +12 -0
  21. services/assets/models/device.py +79 -0
  22. services/assets/models/equipment.py +222 -0
  23. services/assets/models/instruments.py +85 -0
  24. services/assets/models/machines.py +230 -0
  25. services/assets/models/material.py +266 -0
  26. services/assets/models/parts.py +96 -0
  27. services/assets/models/plc.py +264 -0
  28. services/assets/models/tags.py +76 -0
  29. services/assets/models/tools.py +179 -0
  30. services/assets/routers/__init__.py +12 -0
  31. services/assets/routers/classes.py +65 -0
  32. services/assets/routers/device.py +86 -0
  33. services/assets/routers/equipment.py +145 -0
  34. services/assets/routers/families.py +61 -0
  35. services/assets/routers/instruments.py +70 -0
  36. services/assets/routers/machines.py +136 -0
  37. services/assets/routers/materials.py +105 -0
  38. services/assets/routers/parts.py +130 -0
  39. services/assets/routers/plc.py +94 -0
  40. services/assets/routers/subclasses.py +68 -0
  41. services/assets/routers/tags.py +138 -0
  42. services/assets/routers/tools.py +113 -0
  43. services/auth/helpers/__init__.py +13 -0
  44. services/auth/helpers/approval_decisions.py +261 -0
  45. services/auth/helpers/audit_elasticsearch_sync.py +350 -0
  46. services/auth/helpers/departments.py +53 -0
  47. services/auth/helpers/graph_store.py +848 -0
  48. services/auth/helpers/influx_store.py +280 -0
  49. services/auth/helpers/me.py +33 -0
  50. services/auth/helpers/nats_consumer.py +618 -0
  51. services/auth/helpers/nats_store.py +242 -0
  52. services/auth/helpers/permissions.py +62 -0
  53. services/auth/helpers/roles.py +87 -0
  54. services/auth/helpers/store.py +54 -0
  55. services/auth/helpers/team_members.py +155 -0
  56. services/auth/helpers/teams.py +87 -0
  57. services/auth/helpers/tokens.py +71 -0
  58. services/auth/helpers/users.py +119 -0
  59. services/auth/helpers/websocket_broadcast.py +41 -0
  60. services/auth/main.py +88 -0
  61. services/auth/models/__init__.py +12 -0
  62. services/auth/models/departments.py +55 -0
  63. services/auth/models/login.py +20 -0
  64. services/auth/models/permissions.py +61 -0
  65. services/auth/models/roles.py +53 -0
  66. services/auth/models/session.py +26 -0
  67. services/auth/models/teamMembers.py +59 -0
  68. services/auth/models/teams.py +56 -0
  69. services/auth/models/users.py +77 -0
  70. services/auth/routers/__init__.py +14 -0
  71. services/auth/routers/auth.py +3839 -0
  72. services/auth/routers/departments.py +84 -0
  73. services/auth/routers/integration_config.py +703 -0
  74. services/auth/routers/me.py +28 -0
  75. services/auth/routers/permissions.py +96 -0
  76. services/auth/routers/roles.py +139 -0
  77. services/auth/routers/session.py +224 -0
  78. services/auth/routers/team_members.py +152 -0
  79. services/auth/routers/teams.py +112 -0
  80. services/auth/routers/users.py +131 -0
  81. services/auth/routers/websocket_events.py +247 -0
  82. services/batch_execution/__init__.py +19 -0
  83. services/batch_execution/pipeline_triggers/__init__.py +53 -0
  84. services/batch_execution/pipeline_triggers/pipeline_parameter_adjuster.py +440 -0
  85. services/batch_execution/pipeline_triggers/pipeline_trigger_engine.py +445 -0
  86. services/batch_execution/pipeline_triggers/re_evaluation_queue.py +341 -0
  87. services/batch_execution/pipeline_triggers/test_phase3_implementation.py +553 -0
  88. services/batch_execution/pipeline_triggers/workflow_reevaluator.py +367 -0
  89. services/batch_execution/smoke_test_e2e_feedback_loop.py +704 -0
  90. services/batch_execution/workflow_executor.py +478 -0
  91. services/changeover/helpers/__init__.py +11 -0
  92. services/changeover/helpers/changeover.py +87 -0
  93. services/changeover/helpers/changeover_common.py +55 -0
  94. services/changeover/helpers/changeover_events.py +66 -0
  95. services/changeover/helpers/changeover_kpis.py +33 -0
  96. services/changeover/helpers/changeover_matrix.py +60 -0
  97. services/changeover/helpers/changeover_procedures.py +164 -0
  98. services/changeover/helpers/changeover_windows.py +96 -0
  99. services/changeover/main.py +52 -0
  100. services/changeover/models/__init__.py +11 -0
  101. services/changeover/models/changeover.py +73 -0
  102. services/changeover/models/changeover_events.py +142 -0
  103. services/changeover/models/changeover_kpis.py +75 -0
  104. services/changeover/models/changeover_matrix.py +63 -0
  105. services/changeover/models/changeover_procedures.py +108 -0
  106. services/changeover/models/changeover_tasks.py +87 -0
  107. services/changeover/models/changeover_windows.py +72 -0
  108. services/changeover/routers/__init__.py +9 -0
  109. services/changeover/routers/changeover_events.py +127 -0
  110. services/changeover/routers/changeover_kpis.py +80 -0
  111. services/changeover/routers/changeover_matrix.py +80 -0
  112. services/changeover/routers/changeover_procedures.py +118 -0
  113. services/changeover/routers/changeover_windows.py +98 -0
  114. services/common/__init__.py +2 -0
  115. services/common/approval_chains.py +648 -0
  116. services/common/auth.py +56 -0
  117. services/common/cdc.py +52 -0
  118. services/common/compat.py +217 -0
  119. services/common/compliance.py +562 -0
  120. services/common/config.py +134 -0
  121. services/common/cors.py +17 -0
  122. services/common/data_transformation.py +195 -0
  123. services/common/db.py +577 -0
  124. services/common/embeddings.py +97 -0
  125. services/common/event_reducers.py +194 -0
  126. services/common/event_types.py +51 -0
  127. services/common/integration_ingestion.py +169 -0
  128. services/common/logging.py +204 -0
  129. services/common/models/__init__.py +2 -0
  130. services/common/models/databricks.py +25 -0
  131. services/common/models/enums.py +64 -0
  132. services/common/module_control.py +422 -0
  133. services/common/mongo_cdc_watcher.py +106 -0
  134. services/common/n8n_auth.py +22 -0
  135. services/common/neo4j_mirror.py +1087 -0
  136. services/common/ontology_registry.py +110 -0
  137. services/common/reasoning_traces.py +52 -0
  138. services/common/supply_chain_cdc.py +555 -0
  139. services/common/tracing.py +159 -0
  140. services/common/utils.py +30 -0
  141. services/customers/helpers/__init__.py +8 -0
  142. services/customers/helpers/customer_details.py +64 -0
  143. services/customers/helpers/customer_metadata.py +64 -0
  144. services/customers/helpers/customer_order_metrics.py +67 -0
  145. services/customers/helpers/customer_payment_data.py +67 -0
  146. services/customers/main.py +50 -0
  147. services/customers/models/__init__.py +8 -0
  148. services/customers/models/customer_details.py +42 -0
  149. services/customers/models/customer_metadata.py +97 -0
  150. services/customers/models/customer_order_metrics.py +86 -0
  151. services/customers/models/customer_payment_data.py +60 -0
  152. services/customers/routers/__init__.py +8 -0
  153. services/customers/routers/customer_details.py +88 -0
  154. services/customers/routers/customer_metadata.py +88 -0
  155. services/customers/routers/customer_order_metrics.py +88 -0
  156. services/customers/routers/customer_payment_data.py +88 -0
  157. services/documents/__init__.py +1 -0
  158. services/documents/helpers/__init__.py +6 -0
  159. services/documents/helpers/document_metadata.py +569 -0
  160. services/documents/helpers/document_workflows.py +215 -0
  161. services/documents/helpers/report_templates.py +113 -0
  162. services/documents/main.py +49 -0
  163. services/documents/models/__init__.py +6 -0
  164. services/documents/models/document_metadata.py +215 -0
  165. services/documents/models/document_workflows.py +136 -0
  166. services/documents/models/report_templates.py +132 -0
  167. services/documents/routers/__init__.py +6 -0
  168. services/documents/routers/document_metadata.py +654 -0
  169. services/documents/routers/document_workflows.py +146 -0
  170. services/documents/routers/report_templates.py +86 -0
  171. services/events/helpers/__init__.py +5 -0
  172. services/events/helpers/events.py +394 -0
  173. services/events/main.py +40 -0
  174. services/events/models/__init__.py +5 -0
  175. services/events/models/events.py +50 -0
  176. services/events/routers/__init__.py +6 -0
  177. services/events/routers/count_events.py +109 -0
  178. services/events/routers/events.py +75 -0
  179. services/events/seed_events.py +196 -0
  180. services/facilities/helpers/__init__.py +8 -0
  181. services/facilities/helpers/lines.py +74 -0
  182. services/facilities/helpers/locations.py +231 -0
  183. services/facilities/helpers/plants.py +59 -0
  184. services/facilities/helpers/stages.py +110 -0
  185. services/facilities/helpers/workstation.py +213 -0
  186. services/facilities/main.py +60 -0
  187. services/facilities/models/__init__.py +10 -0
  188. services/facilities/models/industrialLine.py +72 -0
  189. services/facilities/models/industrialPlant.py +164 -0
  190. services/facilities/models/locations.py +74 -0
  191. services/facilities/models/stages.py +92 -0
  192. services/facilities/models/worker.py +73 -0
  193. services/facilities/models/workstation.py +117 -0
  194. services/facilities/models/workstation_live_state.py +59 -0
  195. services/facilities/routers/__init__.py +8 -0
  196. services/facilities/routers/bays.py +81 -0
  197. services/facilities/routers/buildings.py +92 -0
  198. services/facilities/routers/floors.py +81 -0
  199. services/facilities/routers/lines.py +154 -0
  200. services/facilities/routers/locations.py +208 -0
  201. services/facilities/routers/plant.py +203 -0
  202. services/facilities/routers/rooms.py +81 -0
  203. services/facilities/routers/stages.py +152 -0
  204. services/facilities/routers/workstation.py +173 -0
  205. services/file/backup.py +71 -0
  206. services/file/main.py +45 -0
  207. services/file/recieve.py +54 -0
  208. services/file/send.py +55 -0
  209. services/file/src/core/config.py +90 -0
  210. services/file/src/core/keycloak.py +152 -0
  211. services/file/src/core/logging_config.py +9 -0
  212. services/file/src/core/security.py +33 -0
  213. services/file/src/helpers/cad_conversion.py +331 -0
  214. services/file/src/helpers/helpers.py +825 -0
  215. services/file/src/routes/cad_conversion.py +26 -0
  216. services/file/src/routes/files.py +136 -0
  217. services/file/src/routes/presigned.py +154 -0
  218. services/file/src/services/websocket.py +293 -0
  219. services/floor_layout/helpers/__init__.py +8 -0
  220. services/floor_layout/helpers/bays.py +92 -0
  221. services/floor_layout/helpers/buildings.py +54 -0
  222. services/floor_layout/helpers/floors.py +65 -0
  223. services/floor_layout/helpers/rooms.py +76 -0
  224. services/floor_layout/main.py +52 -0
  225. services/floor_layout/models/__init__.py +8 -0
  226. services/floor_layout/models/bays.py +65 -0
  227. services/floor_layout/models/buildings.py +52 -0
  228. services/floor_layout/models/floors.py +45 -0
  229. services/floor_layout/models/rooms.py +61 -0
  230. services/floor_layout/routers/__init__.py +9 -0
  231. services/floor_layout/routers/bays.py +143 -0
  232. services/floor_layout/routers/buildings.py +116 -0
  233. services/floor_layout/routers/floors.py +89 -0
  234. services/floor_layout/routers/locations.py +80 -0
  235. services/floor_layout/routers/rooms.py +134 -0
  236. services/inventory/helpers/__init__.py +13 -0
  237. services/inventory/helpers/cycle_counts.py +124 -0
  238. services/inventory/helpers/inventory_allocations.py +134 -0
  239. services/inventory/helpers/inventory_item_counts.py +114 -0
  240. services/inventory/helpers/inventory_item_quantities.py +114 -0
  241. services/inventory/helpers/inventory_items.py +103 -0
  242. services/inventory/helpers/inventory_stage_outputs.py +134 -0
  243. services/inventory/helpers/inventory_transactions.py +112 -0
  244. services/inventory/helpers/stock_adjustment_requests.py +101 -0
  245. services/inventory/helpers/warehouse_cycle_counts.py +133 -0
  246. services/inventory/helpers/warehouse_locations.py +213 -0
  247. services/inventory/helpers/warehouse_regulated_records.py +123 -0
  248. services/inventory/main.py +62 -0
  249. services/inventory/models/__init__.py +17 -0
  250. services/inventory/models/cycle_counts.py +99 -0
  251. services/inventory/models/inventory_allocations.py +121 -0
  252. services/inventory/models/inventory_common.py +65 -0
  253. services/inventory/models/inventory_enums.py +21 -0
  254. services/inventory/models/inventory_item_count.py +65 -0
  255. services/inventory/models/inventory_item_quantity.py +82 -0
  256. services/inventory/models/inventory_items.py +168 -0
  257. services/inventory/models/inventory_responses.py +44 -0
  258. services/inventory/models/inventory_stage_outputs.py +96 -0
  259. services/inventory/models/inventory_state.py +15 -0
  260. services/inventory/models/inventory_transactions.py +80 -0
  261. services/inventory/models/stock_adjustment_requests.py +109 -0
  262. services/inventory/models/warehouse_cycle_counts.py +119 -0
  263. services/inventory/models/warehouse_location_models.py +708 -0
  264. services/inventory/models/warehouse_regulated_records.py +358 -0
  265. services/inventory/routers/__init__.py +13 -0
  266. services/inventory/routers/cycle_counts.py +106 -0
  267. services/inventory/routers/inventory_allocations.py +125 -0
  268. services/inventory/routers/inventory_item_counts.py +105 -0
  269. services/inventory/routers/inventory_item_quantities.py +105 -0
  270. services/inventory/routers/inventory_items.py +109 -0
  271. services/inventory/routers/inventory_stage_outputs.py +122 -0
  272. services/inventory/routers/inventory_transactions.py +96 -0
  273. services/inventory/routers/stock_adjustment_requests.py +124 -0
  274. services/inventory/routers/warehouse_cycle_counts.py +124 -0
  275. services/inventory/routers/warehouse_locations.py +426 -0
  276. services/inventory/routers/warehouse_regulated_records.py +273 -0
  277. services/iot/helpers/__init__.py +8 -0
  278. services/iot/helpers/ble_device.py +87 -0
  279. services/iot/helpers/mqtt_bridge.py +115 -0
  280. services/iot/helpers/sensor_readings.py +63 -0
  281. services/iot/helpers/sensors.py +77 -0
  282. services/iot/helpers/servers.py +72 -0
  283. services/iot/helpers/uwb_device.py +95 -0
  284. services/iot/main.py +53 -0
  285. services/iot/models/__init__.py +8 -0
  286. services/iot/models/ble_device.py +118 -0
  287. services/iot/models/sensors.py +256 -0
  288. services/iot/models/servers.py +206 -0
  289. services/iot/models/uwb_device.py +106 -0
  290. services/iot/routers/__init__.py +8 -0
  291. services/iot/routers/ble_device.py +110 -0
  292. services/iot/routers/sensors.py +144 -0
  293. services/iot/routers/servers.py +141 -0
  294. services/iot/routers/uwb_device.py +148 -0
  295. services/module_control/__init__.py +1 -0
  296. services/module_control/helpers/__init__.py +1 -0
  297. services/module_control/helpers/integration_config.py +243 -0
  298. services/module_control/helpers/security.py +104 -0
  299. services/module_control/main.py +44 -0
  300. services/module_control/models/__init__.py +1 -0
  301. services/module_control/models/module_control.py +65 -0
  302. services/module_control/routers/__init__.py +1 -0
  303. services/module_control/routers/module_control.py +219 -0
  304. services/orders/helpers/__init__.py +11 -0
  305. services/orders/helpers/invoices.py +123 -0
  306. services/orders/helpers/order_customer_metrics.py +61 -0
  307. services/orders/helpers/order_details.py +71 -0
  308. services/orders/helpers/order_metadata.py +61 -0
  309. services/orders/helpers/order_payment_metadata.py +74 -0
  310. services/orders/helpers/orders.py +119 -0
  311. services/orders/helpers/sales_orders.py +136 -0
  312. services/orders/main.py +56 -0
  313. services/orders/models/__init__.py +11 -0
  314. services/orders/models/invoices.py +415 -0
  315. services/orders/models/order_customer_metrics.py +78 -0
  316. services/orders/models/order_details.py +46 -0
  317. services/orders/models/order_metadata.py +60 -0
  318. services/orders/models/order_payment_metadata.py +63 -0
  319. services/orders/models/orders.py +64 -0
  320. services/orders/models/sales_orders.py +130 -0
  321. services/orders/routers/__init__.py +11 -0
  322. services/orders/routers/invoices.py +111 -0
  323. services/orders/routers/order_customer_metrics.py +87 -0
  324. services/orders/routers/order_details.py +87 -0
  325. services/orders/routers/order_metadata.py +87 -0
  326. services/orders/routers/order_payment_metadata.py +87 -0
  327. services/orders/routers/orders.py +74 -0
  328. services/orders/routers/sales_orders.py +111 -0
  329. services/pm/helpers/__init__.py +14 -0
  330. services/pm/helpers/calendar_bookings.py +114 -0
  331. services/pm/helpers/calibration_point.py +110 -0
  332. services/pm/helpers/calibrations.py +196 -0
  333. services/pm/helpers/checklists.py +318 -0
  334. services/pm/helpers/cleaning.py +333 -0
  335. services/pm/helpers/downtime.py +376 -0
  336. services/pm/helpers/kanban_boards.py +186 -0
  337. services/pm/helpers/maintainance.py +177 -0
  338. services/pm/helpers/sop.py +1155 -0
  339. services/pm/helpers/sop_cdc.py +324 -0
  340. services/pm/helpers/weekly_schedules.py +79 -0
  341. services/pm/main.py +62 -0
  342. services/pm/models/__init__.py +14 -0
  343. services/pm/models/calendar_booking.py +82 -0
  344. services/pm/models/calibration_point.py +44 -0
  345. services/pm/models/calibrations.py +167 -0
  346. services/pm/models/checklists.py +117 -0
  347. services/pm/models/cleaning.py +203 -0
  348. services/pm/models/downtime.py +109 -0
  349. services/pm/models/kanban_board.py +178 -0
  350. services/pm/models/maintainanceLog.py +148 -0
  351. services/pm/models/sop.py +152 -0
  352. services/pm/models/weekly_schedule.py +91 -0
  353. services/pm/routers/__init__.py +14 -0
  354. services/pm/routers/calendar_bookings.py +143 -0
  355. services/pm/routers/calibration_point.py +94 -0
  356. services/pm/routers/calibrations.py +232 -0
  357. services/pm/routers/checklists.py +188 -0
  358. services/pm/routers/cleaning.py +127 -0
  359. services/pm/routers/downtime.py +143 -0
  360. services/pm/routers/kanban_boards.py +283 -0
  361. services/pm/routers/maintainance.py +241 -0
  362. services/pm/routers/sop.py +437 -0
  363. services/pm/routers/weekly_schedules.py +108 -0
  364. services/process_definitions/helpers/__init__.py +11 -0
  365. services/process_definitions/helpers/cpp_cqa_registry.py +120 -0
  366. services/process_definitions/helpers/mbmr_templates.py +107 -0
  367. services/process_definitions/helpers/packing_instructions.py +113 -0
  368. services/process_definitions/helpers/process_constraints.py +495 -0
  369. services/process_definitions/helpers/process_corrections.py +279 -0
  370. services/process_definitions/helpers/process_definition.py +996 -0
  371. services/process_definitions/helpers/process_node_catalog.py +786 -0
  372. services/process_definitions/helpers/process_post_checks.py +441 -0
  373. services/process_definitions/helpers/process_pre_checks.py +351 -0
  374. services/process_definitions/helpers/process_steps.py +220 -0
  375. services/process_definitions/main.py +71 -0
  376. services/process_definitions/models/__init__.py +13 -0
  377. services/process_definitions/models/cpp_cqa_registry.py +145 -0
  378. services/process_definitions/models/gxp_change_controls.py +38 -0
  379. services/process_definitions/models/gxp_risk_assessments.py +30 -0
  380. services/process_definitions/models/gxp_validation_evidence.py +33 -0
  381. services/process_definitions/models/mbmr_templates.py +173 -0
  382. services/process_definitions/models/packing_instructions.py +176 -0
  383. services/process_definitions/models/process_constraints.py +159 -0
  384. services/process_definitions/models/process_corrections.py +118 -0
  385. services/process_definitions/models/process_definition.py +685 -0
  386. services/process_definitions/models/process_definition_common.py +48 -0
  387. services/process_definitions/models/process_node_catalog.py +25 -0
  388. services/process_definitions/models/process_post_checks.py +171 -0
  389. services/process_definitions/models/process_pre_checks.py +168 -0
  390. services/process_definitions/models/process_steps.py +170 -0
  391. services/process_definitions/node_services/__init__.py +8 -0
  392. services/process_definitions/node_services/common.py +95 -0
  393. services/process_definitions/node_services/executor.py +499 -0
  394. services/process_definitions/node_services/flow_simulator.py +733 -0
  395. services/process_definitions/node_services/functions.py +193 -0
  396. services/process_definitions/node_services/messaging.py +44 -0
  397. services/process_definitions/node_services/models.py +221 -0
  398. services/process_definitions/node_services/network.py +161 -0
  399. services/process_definitions/node_services/parsers.py +87 -0
  400. services/process_definitions/node_services/sequence.py +95 -0
  401. services/process_definitions/node_services/storage.py +50 -0
  402. services/process_definitions/node_services/webhooks.py +52 -0
  403. services/process_definitions/routers/__init__.py +10 -0
  404. services/process_definitions/routers/cpp_cqa_registry.py +86 -0
  405. services/process_definitions/routers/mbmr_templates.py +84 -0
  406. services/process_definitions/routers/packing_instructions.py +84 -0
  407. services/process_definitions/routers/process_constraints.py +564 -0
  408. services/process_definitions/routers/process_corrections.py +343 -0
  409. services/process_definitions/routers/process_definition.py +992 -0
  410. services/process_definitions/routers/process_post_checks.py +529 -0
  411. services/process_definitions/routers/process_pre_checks.py +435 -0
  412. services/process_definitions/routers/process_steps.py +274 -0
  413. services/procurement/helpers/__init__.py +9 -0
  414. services/procurement/helpers/goods_receipts.py +240 -0
  415. services/procurement/helpers/purchase_order_shipping_information.py +85 -0
  416. services/procurement/helpers/purchase_orders.py +68 -0
  417. services/procurement/helpers/quality_control.py +235 -0
  418. services/procurement/helpers/sampling.py +404 -0
  419. services/procurement/main.py +52 -0
  420. services/procurement/models/__init__.py +9 -0
  421. services/procurement/models/goods_receipts.py +165 -0
  422. services/procurement/models/purchase_orders.py +54 -0
  423. services/procurement/models/quality_control.py +464 -0
  424. services/procurement/models/reinspection_records.py +28 -0
  425. services/procurement/models/sampling.py +262 -0
  426. services/procurement/models/shipping_information.py +51 -0
  427. services/procurement/routers/__init__.py +9 -0
  428. services/procurement/routers/goods_receipts.py +201 -0
  429. services/procurement/routers/purchase_orders.py +106 -0
  430. services/procurement/routers/quality_control.py +386 -0
  431. services/procurement/routers/sampling.py +296 -0
  432. services/procurement/routers/shipping_information.py +97 -0
  433. services/production/__init__.py +1 -0
  434. services/production/agents/__init__.py +5 -0
  435. services/production/agents/batch_planning_agent.py +815 -0
  436. services/production/models/__init__.py +25 -0
  437. services/production/models/batch.py +253 -0
  438. services/products/helpers/__init__.py +10 -0
  439. services/products/helpers/boms.py +100 -0
  440. services/products/helpers/drug_research.py +644 -0
  441. services/products/helpers/product_component.py +168 -0
  442. services/products/helpers/product_inventory.py +221 -0
  443. services/products/helpers/product_pricing.py +123 -0
  444. services/products/helpers/product_utils.py +32 -0
  445. services/products/helpers/products.py +81 -0
  446. services/products/main.py +59 -0
  447. services/products/models/__init__.py +9 -0
  448. services/products/models/drug_research.py +138 -0
  449. services/products/models/product_common.py +60 -0
  450. services/products/models/product_component.py +1028 -0
  451. services/products/models/product_inventory.py +118 -0
  452. services/products/models/product_pricing.py +73 -0
  453. services/products/models/products.py +151 -0
  454. services/products/routers/__init__.py +9 -0
  455. services/products/routers/boms.py +116 -0
  456. services/products/routers/drug_research.py +115 -0
  457. services/products/routers/product_components.py +123 -0
  458. services/products/routers/product_inventory.py +185 -0
  459. services/products/routers/product_pricing.py +136 -0
  460. services/products/routers/products.py +165 -0
  461. services/replenishment/__init__.py +1 -0
  462. services/replenishment/main.py +46 -0
  463. services/replenishment/routers/__init__.py +1 -0
  464. services/replenishment/routers/replenishment.py +20 -0
  465. services/shifts/helpers/__init__.py +7 -0
  466. services/shifts/helpers/shift_templates.py +124 -0
  467. services/shifts/helpers/shifts.py +79 -0
  468. services/shifts/helpers/timesheets.py +137 -0
  469. services/shifts/main.py +48 -0
  470. services/shifts/models/__init__.py +8 -0
  471. services/shifts/models/shift.py +62 -0
  472. services/shifts/models/shift_template.py +82 -0
  473. services/shifts/models/time_range.py +31 -0
  474. services/shifts/models/timesheet.py +196 -0
  475. services/shifts/routers/__init__.py +7 -0
  476. services/shifts/routers/shift_templates.py +97 -0
  477. services/shifts/routers/shifts.py +117 -0
  478. services/shifts/routers/timesheets.py +117 -0
  479. services/shipping/helpers/__init__.py +15 -0
  480. services/shipping/helpers/carrier.py +78 -0
  481. services/shipping/helpers/customs_declaration.py +104 -0
  482. services/shipping/helpers/delivery_note.py +99 -0
  483. services/shipping/helpers/package.py +95 -0
  484. services/shipping/helpers/pallet.py +85 -0
  485. services/shipping/helpers/route.py +93 -0
  486. services/shipping/helpers/shipping_information.py +82 -0
  487. services/shipping/helpers/shipping_provider_details.py +59 -0
  488. services/shipping/helpers/shipping_provider_metadata.py +59 -0
  489. services/shipping/helpers/vehicle.py +85 -0
  490. services/shipping/helpers/waybill.py +86 -0
  491. services/shipping/main.py +64 -0
  492. services/shipping/models/__init__.py +15 -0
  493. services/shipping/models/carrier.py +97 -0
  494. services/shipping/models/customs_declaration.py +138 -0
  495. services/shipping/models/delivery_note.py +163 -0
  496. services/shipping/models/package.py +152 -0
  497. services/shipping/models/pallet.py +137 -0
  498. services/shipping/models/route.py +120 -0
  499. services/shipping/models/shipping_information.py +55 -0
  500. services/shipping/models/shipping_provider_details.py +42 -0
  501. services/shipping/models/shipping_provider_metadata.py +54 -0
  502. services/shipping/models/vehicle.py +129 -0
  503. services/shipping/models/waybill.py +189 -0
  504. services/shipping/routers/__init__.py +15 -0
  505. services/shipping/routers/carrier.py +99 -0
  506. services/shipping/routers/customs_declaration.py +132 -0
  507. services/shipping/routers/delivery_note.py +150 -0
  508. services/shipping/routers/package.py +141 -0
  509. services/shipping/routers/pallet.py +108 -0
  510. services/shipping/routers/route.py +128 -0
  511. services/shipping/routers/shipping_information.py +97 -0
  512. services/shipping/routers/shipping_provider_details.py +80 -0
  513. services/shipping/routers/shipping_provider_metadata.py +80 -0
  514. services/shipping/routers/vehicle.py +117 -0
  515. services/shipping/routers/waybill.py +119 -0
  516. services/suppliers/helpers/__init__.py +13 -0
  517. services/suppliers/helpers/supplier_capabilities.py +58 -0
  518. services/suppliers/helpers/supplier_certifications.py +67 -0
  519. services/suppliers/helpers/supplier_details.py +58 -0
  520. services/suppliers/helpers/supplier_financials.py +58 -0
  521. services/suppliers/helpers/supplier_inventory.py +74 -0
  522. services/suppliers/helpers/supplier_locations.py +60 -0
  523. services/suppliers/helpers/supplier_pricing.py +69 -0
  524. services/suppliers/helpers/supplier_quality.py +69 -0
  525. services/suppliers/helpers/supplier_shipping.py +69 -0
  526. services/suppliers/main.py +60 -0
  527. services/suppliers/models/__init__.py +13 -0
  528. services/suppliers/models/supplier_capabilities.py +70 -0
  529. services/suppliers/models/supplier_certifications.py +64 -0
  530. services/suppliers/models/supplier_details.py +75 -0
  531. services/suppliers/models/supplier_financials.py +69 -0
  532. services/suppliers/models/supplier_inventory.py +76 -0
  533. services/suppliers/models/supplier_locations.py +70 -0
  534. services/suppliers/models/supplier_pricing.py +74 -0
  535. services/suppliers/models/supplier_quality.py +74 -0
  536. services/suppliers/models/supplier_shipping.py +76 -0
  537. services/suppliers/routers/__init__.py +13 -0
  538. services/suppliers/routers/supplier_capabilities.py +88 -0
  539. services/suppliers/routers/supplier_certifications.py +87 -0
  540. services/suppliers/routers/supplier_details.py +83 -0
  541. services/suppliers/routers/supplier_financials.py +83 -0
  542. services/suppliers/routers/supplier_inventory.py +105 -0
  543. services/suppliers/routers/supplier_locations.py +89 -0
  544. services/suppliers/routers/supplier_pricing.py +96 -0
  545. services/suppliers/routers/supplier_quality.py +96 -0
  546. services/suppliers/routers/supplier_shipping.py +96 -0
  547. services/supply_allocation/main.py +46 -0
  548. services/supply_allocation/routers/__init__.py +1 -0
  549. services/supply_allocation/routers/allocation.py +20 -0
  550. services/taxonomy/helpers/__init__.py +7 -0
  551. services/taxonomy/helpers/classes.py +48 -0
  552. services/taxonomy/helpers/family.py +53 -0
  553. services/taxonomy/helpers/subclass.py +58 -0
  554. services/taxonomy/main.py +48 -0
  555. services/taxonomy/models/__init__.py +7 -0
  556. services/taxonomy/models/classes.py +52 -0
  557. services/taxonomy/models/family.py +60 -0
  558. services/taxonomy/models/subclass.py +50 -0
  559. services/taxonomy/routers/__init__.py +7 -0
  560. services/taxonomy/routers/classes.py +78 -0
  561. services/taxonomy/routers/family.py +77 -0
  562. services/taxonomy/routers/subclass.py +82 -0
  563. services/warehouse_execution/__init__.py +1 -0
  564. services/warehouse_execution/main.py +46 -0
  565. services/warehouse_execution/routers/__init__.py +1 -0
  566. services/warehouse_execution/routers/execution.py +21 -0
  567. services/work_order_agent/__init__.py +17 -0
  568. services/work_order_agent/agent/__init__.py +17 -0
  569. services/work_order_agent/agent/work_order_agent.py +658 -0
  570. services/work_order_agent/tracking/__init__.py +101 -0
  571. services/work_order_agent/tracking/event_system.py +182 -0
  572. services/work_order_agent/tracking/state_machine.py +163 -0
  573. services/work_order_agent/tracking/state_machine_integrator.py +295 -0
  574. services/work_order_agent/tracking/test_phase2_implementation.py +302 -0
  575. services/work_order_agent/tracking/time_analysis.py +301 -0
  576. services/work_order_agent/tracking/tracked_work_order.py +255 -0
  577. services/work_order_agent/tracking/work_order_adapter.py +367 -0
  578. services/work_order_agent/tracking/work_order_batch_manager.py +406 -0
  579. services/work_order_agent/tracking/work_order_repository.py +431 -0
  580. services/workorders/helpers/__init__.py +5 -0
  581. services/workorders/helpers/area_room_usage_ledger.py +139 -0
  582. services/workorders/helpers/batch_execution_records.py +265 -0
  583. services/workorders/helpers/batch_release_workflows.py +158 -0
  584. services/workorders/helpers/batch_step_executions.py +145 -0
  585. services/workorders/helpers/equipment_usage_ledger.py +209 -0
  586. services/workorders/helpers/executed_bmr_records.py +170 -0
  587. services/workorders/helpers/executed_bpr_records.py +170 -0
  588. services/workorders/helpers/executed_instruction_evidence.py +155 -0
  589. services/workorders/helpers/ipc_result_records.py +134 -0
  590. services/workorders/helpers/production_batches.py +117 -0
  591. services/workorders/helpers/work_orders.py +367 -0
  592. services/workorders/helpers/yield_reconciliation_records.py +158 -0
  593. services/workorders/main.py +110 -0
  594. services/workorders/models/__init__.py +5 -0
  595. services/workorders/models/area_room_usage_ledger.py +154 -0
  596. services/workorders/models/batch_execution_records.py +575 -0
  597. services/workorders/models/batch_release_workflows.py +190 -0
  598. services/workorders/models/batch_step_executions.py +142 -0
  599. services/workorders/models/equipment_usage_ledger.py +144 -0
  600. services/workorders/models/executed_bmr_records.py +220 -0
  601. services/workorders/models/executed_bpr_records.py +220 -0
  602. services/workorders/models/executed_instruction_evidence.py +128 -0
  603. services/workorders/models/ipc_result_records.py +164 -0
  604. services/workorders/models/production_batches.py +181 -0
  605. services/workorders/models/work_orders.py +255 -0
  606. services/workorders/models/yield_reconciliation_records.py +175 -0
  607. services/workorders/routers/__init__.py +5 -0
  608. services/workorders/routers/area_room_usage_ledger.py +117 -0
  609. services/workorders/routers/batch_execution_records.py +103 -0
  610. services/workorders/routers/batch_release_workflows.py +86 -0
  611. services/workorders/routers/batch_step_executions.py +88 -0
  612. services/workorders/routers/equipment_usage_ledger.py +115 -0
  613. services/workorders/routers/executed_bmr_records.py +86 -0
  614. services/workorders/routers/executed_bpr_records.py +86 -0
  615. services/workorders/routers/executed_instruction_evidence.py +86 -0
  616. services/workorders/routers/ipc_result_records.py +86 -0
  617. services/workorders/routers/production_batches.py +86 -0
  618. services/workorders/routers/work_orders.py +257 -0
  619. services/workorders/routers/yield_reconciliation_records.py +86 -0
  620. src/broca/__init__.py +5 -0
  621. src/broca/agent.py +201 -0
  622. src/cerebellum/__init__.py +0 -0
  623. src/cerebellum/adapter.py +84 -0
  624. src/cerebellum/capabilities/__init__.py +0 -0
  625. src/cerebellum/capabilities/agent/__init__.py +0 -0
  626. src/cerebellum/capabilities/agent/agents.py +65 -0
  627. src/cerebellum/capabilities/ai/__init__.py +0 -0
  628. src/cerebellum/capabilities/ai/providers.py +106 -0
  629. src/cerebellum/capabilities/api/__init__.py +0 -0
  630. src/cerebellum/capabilities/api/graphql.py +35 -0
  631. src/cerebellum/capabilities/api/rest_api.py +45 -0
  632. src/cerebellum/capabilities/api/webhook.py +30 -0
  633. src/cerebellum/capabilities/browser/__init__.py +0 -0
  634. src/cerebellum/capabilities/browser/browsers.py +27 -0
  635. src/cerebellum/capabilities/cloud/__init__.py +0 -0
  636. src/cerebellum/capabilities/cloud/cloud.py +46 -0
  637. src/cerebellum/capabilities/communication/__init__.py +0 -0
  638. src/cerebellum/capabilities/communication/communication.py +62 -0
  639. src/cerebellum/capabilities/database/__init__.py +0 -0
  640. src/cerebellum/capabilities/database/elasticsearch.py +40 -0
  641. src/cerebellum/capabilities/database/influxdb.py +37 -0
  642. src/cerebellum/capabilities/database/mongodb.py +50 -0
  643. src/cerebellum/capabilities/database/neo4j.py +32 -0
  644. src/cerebellum/capabilities/database/redis.py +44 -0
  645. src/cerebellum/capabilities/enterprise/__init__.py +0 -0
  646. src/cerebellum/capabilities/enterprise/opensource.py +180 -0
  647. src/cerebellum/capabilities/enterprise/proprietary.py +313 -0
  648. src/cerebellum/capabilities/event_streaming/__init__.py +0 -0
  649. src/cerebellum/capabilities/event_streaming/streaming.py +38 -0
  650. src/cerebellum/capabilities/infrastructure/__init__.py +0 -0
  651. src/cerebellum/capabilities/infrastructure/infrastructure.py +30 -0
  652. src/cerebellum/capabilities/productivity/__init__.py +0 -0
  653. src/cerebellum/capabilities/productivity/productivity.py +158 -0
  654. src/cerebellum/capabilities/robotics/__init__.py +0 -0
  655. src/cerebellum/capabilities/robotics/robotics.py +124 -0
  656. src/cerebellum/capabilities/runtime/__init__.py +0 -0
  657. src/cerebellum/capabilities/runtime/runtimes.py +92 -0
  658. src/cerebellum/capabilities/search/__init__.py +0 -0
  659. src/cerebellum/capabilities/search/search.py +63 -0
  660. src/cerebellum/capabilities/source_control/__init__.py +0 -0
  661. src/cerebellum/capabilities/source_control/source_control.py +113 -0
  662. src/cerebellum/capabilities/storage/__init__.py +0 -0
  663. src/cerebellum/capabilities/storage/storage.py +94 -0
  664. src/cerebellum/capabilities/workflow/__init__.py +0 -0
  665. src/cerebellum/capabilities/workflow/workflows.py +49 -0
  666. src/cerebellum/capability.py +108 -0
  667. src/cerebellum/config.py +157 -0
  668. src/cerebellum/fallback.py +147 -0
  669. src/cerebellum/fallback_engine.py +121 -0
  670. src/cerebellum/key_manager.py +129 -0
  671. src/cerebellum/keystore.py +179 -0
  672. src/cerebellum/lifecycle.py +54 -0
  673. src/cerebellum/metadata.py +61 -0
  674. src/cerebellum/operator/base.py +25 -0
  675. src/cerebellum/peripheral.py +92 -0
  676. src/cerebellum/registry.py +98 -0
  677. src/cerebellum/resolve_entity_capability.py +259 -0
  678. src/cingulate/benchmark/__init__.py +23 -0
  679. src/cingulate/benchmark/reporter.py +102 -0
  680. src/cingulate/benchmark/runner.py +159 -0
  681. src/cingulate/benchmark/scenario_runner.py +150 -0
  682. src/cingulate/benchmark/validator.py +102 -0
  683. src/cingulate/governance/__init__.py +21 -0
  684. src/cingulate/governance/architecture_validator.py +194 -0
  685. src/cingulate/governance/compliance.py +104 -0
  686. src/cingulate/governance/governance.py +77 -0
  687. src/cingulate/governance/policy_registry.py +91 -0
  688. src/cortex/__init__.py +33 -0
  689. src/cortex/cost.py +71 -0
  690. src/cortex/counterfactual.py +162 -0
  691. src/cortex/digital_twin.py +90 -0
  692. src/cortex/experience.py +83 -0
  693. src/cortex/feedback.py +144 -0
  694. src/cortex/loss.py +116 -0
  695. src/cortex/prediction.py +142 -0
  696. src/cortex/replay.py +130 -0
  697. src/cortex/reward.py +113 -0
  698. src/cortex/simulator.py +102 -0
  699. src/cortex/world_model.py +180 -0
  700. src/cortex/world_model_simulation.py +1591 -0
  701. src/cortex/world_state.py +121 -0
  702. src/cortex/xavier.py +250 -0
  703. src/deepdive/__init__.py +29 -0
  704. src/deepdive/aggregation.py +113 -0
  705. src/deepdive/digital_twin_aggregator.py +128 -0
  706. src/deepdive/elasticsearch_adapter.py +110 -0
  707. src/deepdive/fleet_analytics.py +131 -0
  708. src/deepdive/knowledge_aggregator.py +130 -0
  709. src/homeostasis/__init__.py +19 -0
  710. src/homeostasis/control_plane.py +159 -0
  711. src/introspection/__init__.py +38 -0
  712. src/introspection/alerting.py +142 -0
  713. src/introspection/health.py +101 -0
  714. src/introspection/lemon.py +243 -0
  715. src/introspection/logging.py +147 -0
  716. src/introspection/metrics.py +106 -0
  717. src/introspection/tracing.py +162 -0
  718. src/monkey_brain/__init__.py +1 -0
  719. src/monkey_brain/api/main.py +148 -0
  720. src/monkey_brain/api/models.py +81 -0
  721. src/monkey_brain/api/routes/routes/keys.py +106 -0
  722. src/monkey_brain/api/routes/routes/run.py +169 -0
  723. src/monkey_brain/api/routes/routes/simulate.py +485 -0
  724. src/monkey_brain/dlm/__init__.py +44 -0
  725. src/monkey_brain/dlm/dlm.py +139 -0
  726. src/monkey_brain/dlm/gc.py +115 -0
  727. src/monkey_brain/dlm/lifecycle.py +149 -0
  728. src/monkey_brain/dlm/orphans.py +99 -0
  729. src/monkey_brain/dlm/storage.py +149 -0
  730. src/monkey_brain/dlm/ttl.py +140 -0
  731. src/monkey_brain/documents/__init__.py +0 -0
  732. src/monkey_brain/documents/document_ocr.py +6 -0
  733. src/monkey_brain/kernel/__init__.py +53 -0
  734. src/monkey_brain/kernel/capability_interface.py +144 -0
  735. src/monkey_brain/kernel/classifier/__init__.py +1 -0
  736. src/monkey_brain/kernel/classifier/embed_classifier.py +125 -0
  737. src/monkey_brain/kernel/classifier/intent_examples.py +106 -0
  738. src/monkey_brain/kernel/dag.py +23 -0
  739. src/monkey_brain/kernel/execution_state.py +257 -0
  740. src/monkey_brain/kernel/goal_planner.py +85 -0
  741. src/monkey_brain/kernel/goal_router.py +20 -0
  742. src/monkey_brain/kernel/goals/__init__.py +1 -0
  743. src/monkey_brain/kernel/goals/goal.py +130 -0
  744. src/monkey_brain/kernel/goals/goal_bootstrap.py +38 -0
  745. src/monkey_brain/kernel/goals/goal_classifier.py +132 -0
  746. src/monkey_brain/kernel/goals/goal_registry.py +75 -0
  747. src/monkey_brain/kernel/intents/__init__.py +1 -0
  748. src/monkey_brain/kernel/intents/event_adapter.py +246 -0
  749. src/monkey_brain/kernel/intents/helpers.py +13 -0
  750. src/monkey_brain/kernel/intents/intent_registry.py +705 -0
  751. src/monkey_brain/kernel/intents/intent_router.py +102 -0
  752. src/monkey_brain/kernel/intents/predicates/approval_create.py +9 -0
  753. src/monkey_brain/kernel/intents/predicates/approval_decision.py +9 -0
  754. src/monkey_brain/kernel/intents/predicates/approval_hold.py +9 -0
  755. src/monkey_brain/kernel/intents/predicates/approval_query.py +9 -0
  756. src/monkey_brain/kernel/intents/predicates/batch_close.py +9 -0
  757. src/monkey_brain/kernel/intents/predicates/batch_creation.py +9 -0
  758. src/monkey_brain/kernel/intents/predicates/batch_delete.py +9 -0
  759. src/monkey_brain/kernel/intents/predicates/batch_hold.py +9 -0
  760. src/monkey_brain/kernel/intents/predicates/batch_record.py +9 -0
  761. src/monkey_brain/kernel/intents/predicates/batch_update.py +9 -0
  762. src/monkey_brain/kernel/intents/predicates/change_control.py +49 -0
  763. src/monkey_brain/kernel/intents/predicates/compliance_audit.py +14 -0
  764. src/monkey_brain/kernel/intents/predicates/decision_intelligence.py +9 -0
  765. src/monkey_brain/kernel/intents/predicates/drug_research.py +9 -0
  766. src/monkey_brain/kernel/intents/predicates/fuzzy_match.py +19 -0
  767. src/monkey_brain/kernel/intents/predicates/production_kpi.py +9 -0
  768. src/monkey_brain/kernel/intents/predicates/sop_create.py +9 -0
  769. src/monkey_brain/kernel/intents/predicates/sop_query.py +9 -0
  770. src/monkey_brain/kernel/intents/predicates/sop_update.py +9 -0
  771. src/monkey_brain/kernel/intents/predicates/warehouse_shipping.py +9 -0
  772. src/monkey_brain/kernel/intents/predicates/work_order_create.py +9 -0
  773. src/monkey_brain/kernel/intents/predicates/work_order_delete.py +9 -0
  774. src/monkey_brain/kernel/intents/predicates/work_order_hold.py +9 -0
  775. src/monkey_brain/kernel/intents/predicates/work_order_query.py +9 -0
  776. src/monkey_brain/kernel/intents/predicates/work_order_status.py +9 -0
  777. src/monkey_brain/kernel/intents/predicates/work_order_update.py +9 -0
  778. src/monkey_brain/kernel/intents/predicates/worker.py +9 -0
  779. src/monkey_brain/kernel/intents/telemetry_adapter.py +274 -0
  780. src/monkey_brain/kernel/intents/utils.py +68 -0
  781. src/monkey_brain/kernel/learning.py +98 -0
  782. src/monkey_brain/kernel/llm_explorer.py +188 -0
  783. src/monkey_brain/kernel/loss.py +81 -0
  784. src/monkey_brain/kernel/nlp/__init__.py +1 -0
  785. src/monkey_brain/kernel/nlp/compat.py +23 -0
  786. src/monkey_brain/kernel/nlp/models.py +10 -0
  787. src/monkey_brain/kernel/nlp/question_analyzer.py +203 -0
  788. src/monkey_brain/kernel/nlp/spacy_parser.py +53 -0
  789. src/monkey_brain/kernel/observer.py +97 -0
  790. src/monkey_brain/kernel/parser/__init__.py +3 -0
  791. src/monkey_brain/kernel/parser/ast.py +28 -0
  792. src/monkey_brain/kernel/parser/extractors/__init__.py +11 -0
  793. src/monkey_brain/kernel/parser/extractors/entities.py +21 -0
  794. src/monkey_brain/kernel/parser/extractors/filters.py +16 -0
  795. src/monkey_brain/kernel/parser/extractors/projections.py +36 -0
  796. src/monkey_brain/kernel/parser/extractors/verbs.py +31 -0
  797. src/monkey_brain/kernel/parser/parser.py +57 -0
  798. src/monkey_brain/kernel/parser/rules.py +75 -0
  799. src/monkey_brain/kernel/pipeline.py +44 -0
  800. src/monkey_brain/kernel/planner.py +57 -0
  801. src/monkey_brain/kernel/rl/__init__.py +33 -0
  802. src/monkey_brain/kernel/rl/learner.py +98 -0
  803. src/monkey_brain/kernel/rl/policy.py +254 -0
  804. src/monkey_brain/kernel/rl/reward.py +117 -0
  805. src/monkey_brain/kernel/rl/transition.py +112 -0
  806. src/monkey_brain/persistence/__init__.py +47 -0
  807. src/monkey_brain/persistence/adapters.py +49 -0
  808. src/monkey_brain/persistence/events.py +105 -0
  809. src/monkey_brain/persistence/manager.py +124 -0
  810. src/monkey_brain/persistence/mongodb_adapter.py +91 -0
  811. src/monkey_brain/persistence/redis_adapter.py +93 -0
  812. src/monkey_brain/persistence/reducer.py +111 -0
  813. src/monkey_brain/runtime/__init__.py +49 -0
  814. src/monkey_brain/runtime/depedencies.py +8 -0
  815. src/monkey_brain/runtime/engine.py +183 -0
  816. src/monkey_brain/runtime/message_bus.py +82 -0
  817. src/monkey_brain/runtime/process.py +144 -0
  818. src/monkey_brain/runtime/resource_manager.py +100 -0
  819. src/monkey_brain/runtime/routers.py +8 -0
  820. src/monkey_brain/runtime/runtime.py +199 -0
  821. src/monkey_brain/runtime/scheduler.py +165 -0
  822. src/monkey_brain/runtime/supervisor.py +133 -0
  823. src/monkey_brain/runtime/worker_pool.py +111 -0
  824. src/plasticity/seed/__init__.py +30 -0
  825. src/plasticity/seed/benchmark_generator.py +105 -0
  826. src/plasticity/seed/event_generator.py +122 -0
  827. src/plasticity/seed/scenario_builder.py +134 -0
  828. src/plasticity/seed/seed_data.py +206 -0
  829. src/plasticity/seed/seeder.py +122 -0
  830. src/plasticity/testing/__init__.py +28 -0
  831. src/plasticity/testing/performance.py +131 -0
  832. src/plasticity/testing/profiler.py +255 -0
  833. src/plasticity/testing/reporter.py +84 -0
  834. src/plasticity/testing/runner.py +209 -0
  835. src/sync/__init__.py +12 -0
  836. src/sync/cloud_aggregator.py +63 -0
  837. src/sync/edge_node.py +51 -0
  838. src/sync/sync_manager.py +74 -0
@@ -0,0 +1,3839 @@
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import hmac
9
+ import secrets
10
+ import struct
11
+ import time
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from urllib.parse import quote
15
+ from uuid import uuid4
16
+ from bson import ObjectId
17
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, Response, Cookie, status
18
+ from fastapi.encoders import jsonable_encoder
19
+ from fastapi.responses import HTMLResponse
20
+ import httpx
21
+ from jose import JWTError
22
+ from pydantic import BaseModel, Field
23
+
24
+ from services.auth.helpers.permissions import get_by_permission_id
25
+ from services.auth.helpers.roles import get_by_name as get_role_by_name, get_by_id as get_role_by_id # ← import get_by_id
26
+ from services.auth.models.login import LoginRequest, RefreshTokenRequest, TokenResponse
27
+ from services.auth.helpers.tokens import (
28
+ create_access_token,
29
+ create_mfa_challenge_token,
30
+ create_refresh_token,
31
+ decode_access_token,
32
+ decode_mfa_challenge_token,
33
+ decode_refresh_token,
34
+ )
35
+
36
+ from services.common.auth import get_current_user
37
+ from services.common.compliance import (
38
+ ACCESS_REVIEW_COLLECTION,
39
+ AUDIT_COLLECTION,
40
+ append_audit_entry,
41
+ CHANGE_CONTROL_COLLECTION,
42
+ entity_compliance_reference,
43
+ compliance_metadata,
44
+ DATA_INTEGRITY_REVIEW_COLLECTION,
45
+ EXPORT_COLLECTION,
46
+ LOGIN_EVENT_COLLECTION,
47
+ RISK_ASSESSMENT_COLLECTION,
48
+ compliance_readiness,
49
+ RECORD_ID_KEYS,
50
+ RETENTION_COLLECTION,
51
+ SIGNATURE_COLLECTION,
52
+ TRACEABILITY_MATRIX_COLLECTION,
53
+ USER_REQUIREMENT_COLLECTION,
54
+ VALIDATION_EVIDENCE_COLLECTION,
55
+ VERSION_COLLECTION,
56
+ sha256_hex,
57
+ signature_payload_hash,
58
+ utc_now,
59
+ )
60
+ from services.common.config import settings
61
+ from services.common.approval_chains import (
62
+ approval_notification_event,
63
+ approval_graph_relationships,
64
+ approval_step_email,
65
+ create_approval_tracking_and_notifications,
66
+ resolve_named_approval_chain,
67
+ validate_named_approval_chain,
68
+ )
69
+ from services.common.neo4j_mirror import mirror_document, safe_mirror
70
+ from services.common.n8n_auth import n8n_webhook_auth_headers
71
+ from services.auth.helpers.store import revoke_refresh_token, save_refresh_token, token_exists
72
+ from services.auth.helpers import nats_store
73
+ from services.auth.helpers.approval_decisions import (
74
+ ApprovalAlreadyDecidedError,
75
+ PROPOSED_CHANGE_COLLECTION,
76
+ record_approval_decision,
77
+ )
78
+ from services.auth.helpers.users import get_by_email, verify_password, get_by_id
79
+ from motor.motor_asyncio import AsyncIOMotorDatabase
80
+ from services.common.db import get_database
81
+
82
+ router = APIRouter()
83
+
84
+ COOKIE_OPTIONS = dict(
85
+ key=settings.REFRESH_COOKIE_NAME,
86
+ httponly=True,
87
+ secure=False, # set True in production (HTTPS only)
88
+ samesite="strict",
89
+ max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
90
+ )
91
+
92
+
93
+ def _as_aware_utc(value: datetime | str | None) -> datetime | None:
94
+ if value is None:
95
+ return None
96
+ if isinstance(value, str):
97
+ try:
98
+ value = datetime.fromisoformat(value.replace("Z", "+00:00"))
99
+ except ValueError:
100
+ return None
101
+ if not isinstance(value, datetime):
102
+ return None
103
+ if value.tzinfo is None:
104
+ return value.replace(tzinfo=timezone.utc)
105
+ return value.astimezone(timezone.utc)
106
+
107
+
108
+ def _bson_safe(value):
109
+ return jsonable_encoder(value, custom_encoder={ObjectId: str})
110
+
111
+
112
+ class ElectronicSignatureRequest(BaseModel):
113
+ password: str = Field(..., min_length=1)
114
+ meaning: str = Field(..., min_length=1, max_length=255)
115
+ collection: str = Field(..., min_length=1, max_length=128)
116
+ record_id: str = Field(..., min_length=1, max_length=255)
117
+ action: str = Field(default="approve", min_length=1, max_length=128)
118
+
119
+
120
+ class ManualAuditTrailRequest(BaseModel):
121
+ collection: str = Field(..., min_length=1, max_length=128)
122
+ record_id: str = Field(..., min_length=1, max_length=255)
123
+ action: str = Field(..., min_length=1, max_length=128)
124
+ reason: str = Field(..., min_length=1, max_length=1000)
125
+ detail: str | None = Field(default=None, max_length=4000)
126
+
127
+
128
+
129
+
130
+ class ComplianceTestExecutionRequest(BaseModel):
131
+ scope: str = Field(default="all", pattern="^(all|iq|oq|pq|gqa|test-execution-reports)$")
132
+
133
+
134
+ class ComplianceAgentRunRequest(BaseModel):
135
+ scopes: list[str] = Field(default_factory=lambda: ["iq", "oq", "pq", "gqa"])
136
+ continue_on_failure: bool = False
137
+
138
+
139
+ class ComplianceAgentReportRequest(BaseModel):
140
+ summary: dict = Field(default_factory=dict)
141
+ reports: list[dict] = Field(default_factory=list)
142
+ failed_tests: list[dict] = Field(default_factory=list)
143
+ scope_results: list[dict] = Field(default_factory=list)
144
+ message: str | None = Field(default=None, max_length=2000)
145
+ source: str = Field(default="n8n", max_length=128)
146
+ raw_response: dict = Field(default_factory=dict)
147
+
148
+
149
+ class ElectronicSignatureResponse(BaseModel):
150
+ signature_id: str
151
+ user_id: str
152
+ collection: str
153
+ record_id: str
154
+ action: str
155
+ meaning: str
156
+ record_hash: str | None = None
157
+ signature_hash: str | None = None
158
+ signed_at: str
159
+
160
+
161
+ def _audit_elasticsearch_auth():
162
+ if settings.AUDIT_ELASTICSEARCH_USERNAME:
163
+ return (settings.AUDIT_ELASTICSEARCH_USERNAME, settings.AUDIT_ELASTICSEARCH_PASSWORD)
164
+ return None
165
+
166
+
167
+ def _audit_elasticsearch_headers() -> dict[str, str]:
168
+ headers = {"Content-Type": "application/json"}
169
+ if settings.AUDIT_ELASTICSEARCH_API_KEY:
170
+ headers["Authorization"] = f"ApiKey {settings.AUDIT_ELASTICSEARCH_API_KEY}"
171
+ return headers
172
+
173
+
174
+ async def _search_audit_trail_elasticsearch(
175
+ *,
176
+ q: str | None,
177
+ collection: str | None,
178
+ record_id: str | None,
179
+ page: int,
180
+ page_size: int,
181
+ ) -> dict | None:
182
+ if not settings.AUDIT_ELASTICSEARCH_ENABLED:
183
+ return None
184
+
185
+ must: list[dict] = []
186
+ filters: list[dict] = []
187
+ if q:
188
+ must.append(
189
+ {
190
+ "multi_match": {
191
+ "query": q,
192
+ "fields": [
193
+ "search_text",
194
+ "audit_id",
195
+ "collection",
196
+ "record_id",
197
+ "action",
198
+ "actor_user_id",
199
+ "actor_email",
200
+ "change_control_id",
201
+ "change_reason",
202
+ ],
203
+ "lenient": True,
204
+ }
205
+ }
206
+ )
207
+ else:
208
+ must.append({"match_all": {}})
209
+ if collection:
210
+ filters.append({"term": {"collection": collection}})
211
+ if record_id:
212
+ filters.append({"term": {"record_id": record_id}})
213
+
214
+ payload = {
215
+ "from": (page - 1) * page_size,
216
+ "size": page_size,
217
+ "query": {"bool": {"must": must, "filter": filters}},
218
+ "sort": [{"timestamp": {"order": "desc", "unmapped_type": "date"}}],
219
+ }
220
+ try:
221
+ async with httpx.AsyncClient(
222
+ base_url=settings.AUDIT_ELASTICSEARCH_URL.rstrip("/"),
223
+ timeout=10.0,
224
+ auth=_audit_elasticsearch_auth(),
225
+ headers=_audit_elasticsearch_headers(),
226
+ ) as client:
227
+ response = await client.post(f"/{settings.AUDIT_ELASTICSEARCH_INDEX}/_search", json=payload)
228
+ if response.status_code == 404:
229
+ return None
230
+ response.raise_for_status()
231
+ data = response.json()
232
+ except httpx.HTTPError:
233
+ return None
234
+
235
+ total_value = data.get("hits", {}).get("total", 0)
236
+ total = total_value.get("value", 0) if isinstance(total_value, dict) else int(total_value or 0)
237
+ return {
238
+ "total": total,
239
+ "page": page,
240
+ "page_size": page_size,
241
+ "source": "elasticsearch",
242
+ "index": settings.AUDIT_ELASTICSEARCH_INDEX,
243
+ "results": [hit.get("_source", {}) for hit in data.get("hits", {}).get("hits", [])],
244
+ }
245
+
246
+
247
+ class RetentionPolicyRequest(BaseModel):
248
+ collection: str = Field(..., min_length=1, max_length=128)
249
+ minimum_retention_days: int = Field(default=0, ge=0)
250
+ allow_delete_before_retention_expiry: bool = False
251
+ enabled: bool = True
252
+ reason: str | None = Field(default=None, max_length=500)
253
+
254
+
255
+ class GxpEvidenceRequest(BaseModel):
256
+ title: str = Field(..., min_length=1, max_length=255)
257
+ standard_id: str | None = Field(default=None, max_length=128)
258
+ scope: str | None = Field(default=None, max_length=255)
259
+ status: str = Field(default="Open", min_length=1, max_length=80)
260
+ owner: str | None = Field(default=None, max_length=255)
261
+ summary: str | None = Field(default=None, max_length=4000)
262
+ evidence_refs: list[str] = Field(default_factory=list)
263
+ reviewed_by: str | None = Field(default=None, max_length=255)
264
+ review_due_at: datetime | None = None
265
+
266
+
267
+ class RiskAssessmentRequest(BaseModel):
268
+ title: str = Field(..., min_length=1, max_length=255)
269
+ process: str | None = Field(default=None, max_length=255)
270
+ hazard: str = Field(..., min_length=1, max_length=1000)
271
+ impact: str | None = Field(default=None, max_length=1000)
272
+ severity: int = Field(..., ge=1, le=5)
273
+ occurrence: int = Field(..., ge=1, le=5)
274
+ detectability: int = Field(..., ge=1, le=5)
275
+ mitigation: str | None = Field(default=None, max_length=2000)
276
+ owner: str | None = Field(default=None, max_length=255)
277
+ status: str = Field(default="Open", min_length=1, max_length=80)
278
+
279
+
280
+ class UserRequirementInput(BaseModel):
281
+ requirement_id: str | None = Field(default=None, max_length=128)
282
+ title: str = Field(..., min_length=1, max_length=255)
283
+ description: str = Field(..., min_length=1, max_length=4000)
284
+ category: str = Field(default="Compliance", min_length=1, max_length=128)
285
+ priority: str = Field(default="High", pattern=r"^(Critical|High|Medium|Low)$")
286
+ source: str = Field(default="User Requirement Specification", min_length=1, max_length=255)
287
+ acceptance_criteria: list[str] = Field(default_factory=list)
288
+ linked_controls: list[str] = Field(default_factory=list)
289
+ linked_tests: list[str] = Field(default_factory=list)
290
+ linked_evidence: list[str] = Field(default_factory=list)
291
+ owner: str | None = Field(default=None, max_length=255)
292
+ status: str = Field(default="Draft", min_length=1, max_length=80)
293
+
294
+
295
+ class UserRequirementsAssessmentRequest(BaseModel):
296
+ scope: str = Field(default="pharma-compliance-proof", min_length=1, max_length=255)
297
+ requirements: list[UserRequirementInput] = Field(default_factory=list)
298
+
299
+
300
+ class TraceabilityMatrixRequest(BaseModel):
301
+ scope: str = Field(default="pharma-compliance-proof", min_length=1, max_length=255)
302
+ requirement_ids: list[str] = Field(default_factory=list)
303
+
304
+
305
+ class ChangeControlRequest(BaseModel):
306
+ title: str = Field(..., min_length=1, max_length=255)
307
+ description: str | None = Field(default=None, max_length=4000)
308
+ change_type: str = Field(..., pattern=r"^(Major|Minor|Emergency|Temporary)$")
309
+ reason: str = Field(..., min_length=1, max_length=2000)
310
+ justification: str | None = Field(default=None, max_length=2000)
311
+ initiating_facility: str | None = Field(default=None, max_length=255)
312
+ affected_entity_class: str | None = Field(default=None, max_length=128)
313
+ affected_entities: list[dict] = Field(default_factory=list)
314
+ impact_assessment: str | None = Field(default=None, max_length=8000)
315
+ graph_impact_summary: dict | None = None
316
+ affected_collections: list[str] = Field(default_factory=list)
317
+ proposed_values: dict = Field(default_factory=dict)
318
+ proposed_update: dict = Field(default_factory=dict)
319
+ proposed_changes: list[dict] = Field(default_factory=list)
320
+ attachments: list[dict] = Field(default_factory=list)
321
+ attachment_ids: list[str] = Field(default_factory=list)
322
+ risk_assessment_id: str | None = Field(default=None, max_length=255)
323
+ risk_score: int | None = Field(default=None, ge=1, le=125)
324
+ approval_tier: str | None = Field(default=None, max_length=128)
325
+ approval_mode: str | None = Field(default=None, max_length=128)
326
+ approval_deadline_at: datetime | None = None
327
+ validation_required: bool = True
328
+ revalidation_required: bool | None = None
329
+ requalification_required: bool | None = None
330
+ sop_revision_required: bool | None = None
331
+ retraining_required: bool | None = None
332
+ regulatory_notification_required: bool | None = None
333
+ stability_study_required: bool | None = None
334
+ implementation_tasks: list[dict] = Field(default_factory=list)
335
+ training_plan: list[dict] = Field(default_factory=list)
336
+ capa_references: list[str] = Field(default_factory=list)
337
+ deviation_references: list[str] = Field(default_factory=list)
338
+ approval_chain: list[dict] = Field(default_factory=list)
339
+ manual_approval_chain: list[dict] = Field(default_factory=list)
340
+ escalation_policy: str | None = Field(default=None, max_length=2000)
341
+ effectiveness_check_days: int | None = Field(default=None, ge=0, le=3650)
342
+ status: str = Field(default="Open", min_length=1, max_length=80)
343
+
344
+
345
+ class ExternalApprovalDecisionRequest(BaseModel):
346
+ token: str = Field(..., min_length=1)
347
+ decision: str = Field(..., min_length=1, max_length=80)
348
+ reason: str | None = Field(default=None, max_length=2000)
349
+ signature_meaning: str | None = Field(default=None, max_length=255)
350
+ approver_email: str | None = Field(default=None, max_length=255)
351
+ approver_user_id: str | None = Field(default=None, max_length=255)
352
+ source: str = Field(default="external_approval_form", max_length=128)
353
+
354
+
355
+ class ReviewRequest(BaseModel):
356
+ scope: str = Field(..., min_length=1, max_length=255)
357
+ outcome: str = Field(..., min_length=1, max_length=255)
358
+ findings: list[str] = Field(default_factory=list)
359
+ corrective_actions: list[str] = Field(default_factory=list)
360
+ next_review_due_at: datetime | None = None
361
+
362
+
363
+ class MfaEnrollRequest(BaseModel):
364
+ password: str = Field(..., min_length=1)
365
+
366
+
367
+ class MfaEnableRequest(BaseModel):
368
+ code: str = Field(..., min_length=6, max_length=32)
369
+
370
+
371
+ class MfaDisableRequest(BaseModel):
372
+ password: str = Field(..., min_length=1)
373
+ code: str = Field(..., min_length=6, max_length=32)
374
+
375
+
376
+ class MfaVerifyRequest(BaseModel):
377
+ mfa_challenge_token: str = Field(..., min_length=1)
378
+ code: str = Field(..., min_length=6, max_length=32)
379
+
380
+
381
+ class MfaEnrollResponse(BaseModel):
382
+ secret: str
383
+ otpauth_uri: str
384
+ backup_codes: list[str]
385
+
386
+
387
+ async def _find_signed_record(db, collection: str, record_id: str) -> dict | None:
388
+ query = {"$or": [{key: record_id} for key in RECORD_ID_KEYS]}
389
+ try:
390
+ if ObjectId.is_valid(record_id):
391
+ query["$or"].append({"_id": ObjectId(record_id)})
392
+ except Exception:
393
+ pass
394
+ return await db[collection].find_one(query)
395
+
396
+
397
+ def _record_lookup_query(record_id: str) -> dict:
398
+ query = {"$or": [{key: record_id} for key in RECORD_ID_KEYS]}
399
+ try:
400
+ if ObjectId.is_valid(record_id):
401
+ query["$or"].append({"_id": ObjectId(record_id)})
402
+ except Exception:
403
+ pass
404
+ return query
405
+
406
+
407
+ async def _link_signature_to_entity(db, signature: dict) -> None:
408
+ collection = signature.get("collection")
409
+ record_id = signature.get("record_id")
410
+ if not collection or not record_id:
411
+ return
412
+ reference = entity_compliance_reference(signature, reference_type="signature")
413
+ await db[collection].update_one(
414
+ _record_lookup_query(str(record_id)),
415
+ {
416
+ "$set": {
417
+ "compliance.signature_enabled": True,
418
+ "compliance.last_signature_id": signature.get("signature_id"),
419
+ "compliance.last_signed_at": signature.get("signed_at"),
420
+ },
421
+ "$addToSet": {
422
+ "compliance.signatures": reference,
423
+ "electronic_signatures": reference,
424
+ },
425
+ },
426
+ )
427
+
428
+
429
+ async def _link_change_control_to_entities(db, change_control: dict) -> None:
430
+ reference = entity_compliance_reference(change_control, reference_type="change_control")
431
+ for entity in change_control.get("affected_entities") or []:
432
+ collection = entity.get("collection") or _entity_collection(entity)
433
+ record_id = entity.get("entity_id") or _entity_id(entity)
434
+ if not collection or not record_id:
435
+ continue
436
+ await db[collection].update_one(
437
+ _record_lookup_query(str(record_id)),
438
+ {
439
+ "$set": {
440
+ "compliance.change_management_enabled": True,
441
+ "compliance.last_change_control_id": change_control.get("change_control_id"),
442
+ "compliance.last_change_control_status": change_control.get("status"),
443
+ },
444
+ "$addToSet": {
445
+ "compliance.change_controls": reference,
446
+ "change_controls": reference,
447
+ },
448
+ },
449
+ )
450
+
451
+
452
+ def _proposed_values_from_payload(payload: dict) -> dict:
453
+ proposed_values = payload.get("proposed_values")
454
+ if isinstance(proposed_values, dict) and proposed_values:
455
+ return dict(proposed_values)
456
+ proposed_update = payload.get("proposed_update")
457
+ if isinstance(proposed_update, dict):
458
+ set_values = proposed_update.get("$set")
459
+ if isinstance(set_values, dict) and set_values:
460
+ return dict(set_values)
461
+ if proposed_update and not any(str(key).startswith("$") for key in proposed_update):
462
+ return dict(proposed_update)
463
+ return {}
464
+
465
+
466
+ def _record_key_values(record: dict) -> dict:
467
+ keys = [
468
+ "_id",
469
+ *RECORD_ID_KEYS,
470
+ ]
471
+ return {key: record.get(key) for key in keys if record.get(key) is not None}
472
+
473
+
474
+ async def _create_proposed_change_nodes(db, change_control: dict, current_user: dict) -> list[dict]:
475
+ proposed_nodes: list[dict] = []
476
+ now = utc_now()
477
+ requested_changes = change_control.get("proposed_changes")
478
+ proposed_values = _proposed_values_from_payload(change_control)
479
+ affected_entities = change_control.get("affected_entities") or []
480
+ if isinstance(requested_changes, list) and requested_changes:
481
+ proposed_specs = requested_changes
482
+ else:
483
+ proposed_specs = [
484
+ {
485
+ "collection": entity.get("collection"),
486
+ "entity_id": entity.get("entity_id"),
487
+ "entity_name": entity.get("name"),
488
+ "proposed_values": proposed_values,
489
+ }
490
+ for entity in affected_entities
491
+ if entity.get("collection") and entity.get("entity_id")
492
+ ]
493
+
494
+ for index, spec in enumerate(proposed_specs, start=1):
495
+ if not isinstance(spec, dict):
496
+ continue
497
+ collection = spec.get("collection")
498
+ entity_id = spec.get("entity_id") or spec.get("id") or spec.get("record_id")
499
+ if not collection or not entity_id:
500
+ continue
501
+ live_record = await db[str(collection)].find_one(_record_lookup_query(str(entity_id)), {"_id": 0, "embedding": 0})
502
+ pending_values = spec.get("proposed_values") if isinstance(spec.get("proposed_values"), dict) else proposed_values
503
+ node = {
504
+ "proposed_change_id": f"proposed_change-{uuid4().hex}",
505
+ "change_control_id": change_control.get("change_control_id"),
506
+ "change_control_number": change_control.get("change_control_number"),
507
+ "sequence": index,
508
+ "status": "Pending Approval",
509
+ "lifecycle_state": "proposed",
510
+ "source_collection": collection,
511
+ "source_id": str(entity_id),
512
+ "source_name": spec.get("entity_name") or (live_record or {}).get("name") or (live_record or {}).get("title") or str(entity_id),
513
+ "source_key": _record_key_values(live_record or {"id": entity_id}),
514
+ "source_snapshot": live_record or {},
515
+ "pending_properties": dict(pending_values or {}),
516
+ "proposed_values": dict(pending_values or {}),
517
+ "archived": False,
518
+ "rolled_back": False,
519
+ "live_node_touched": False,
520
+ "approval_chain": change_control.get("approval_chain") or [],
521
+ "approval_assignment_basis": change_control.get("approval_assignment_basis"),
522
+ "created_at": now,
523
+ "updated_at": now,
524
+ "created_by": _current_user_id(current_user),
525
+ "updated_by": _current_user_id(current_user),
526
+ "effective_at": None,
527
+ }
528
+ node["record_hash"] = sha256_hex(node)
529
+ await db[PROPOSED_CHANGE_COLLECTION].insert_one(node)
530
+ proposed_nodes.append(_bson_safe(node))
531
+ return proposed_nodes
532
+
533
+
534
+ def _request_metadata(request: Request | None) -> dict:
535
+ if request is None:
536
+ return {}
537
+ return {
538
+ "ip_address": request.client.host if request.client else None,
539
+ "user_agent": request.headers.get("user-agent"),
540
+ }
541
+
542
+
543
+ async def _record_login_event(
544
+ db,
545
+ *,
546
+ email: str,
547
+ status_value: str,
548
+ request: Request | None = None,
549
+ user_id: str | None = None,
550
+ reason: str | None = None,
551
+ ) -> None:
552
+ event = {
553
+ "event_id": f"login-{uuid4().hex}",
554
+ "standard": "21 CFR Part 11",
555
+ "standards": compliance_readiness()["standards"],
556
+ "email": email.strip().lower(),
557
+ "user_id": user_id,
558
+ "status": status_value,
559
+ "reason": reason,
560
+ "timestamp": utc_now(),
561
+ **_request_metadata(request),
562
+ }
563
+ await db[LOGIN_EVENT_COLLECTION].insert_one(event)
564
+
565
+
566
+ def _current_user_id(current_user: dict) -> str:
567
+ return current_user.get("user_id") or current_user.get("sub")
568
+
569
+
570
+ async def _insert_gxp_record(db, collection: str, prefix: str, payload: dict, current_user: dict) -> dict:
571
+ now = utc_now()
572
+ record_id = f"{prefix}-{uuid4().hex}"
573
+ record = {
574
+ f"{prefix}_id": record_id,
575
+ "standard": "GxP",
576
+ "standards": compliance_readiness()["standards"],
577
+ **payload,
578
+ "created_at": now,
579
+ "updated_at": now,
580
+ "created_by": _current_user_id(current_user),
581
+ "updated_by": _current_user_id(current_user),
582
+ }
583
+ record["record_hash"] = sha256_hex(record)
584
+ await db[collection].insert_one(record)
585
+ return jsonable_encoder(record)
586
+
587
+
588
+ async def _list_gxp_records(db, collection: str, page: int, page_size: int, status_value: str | None = None) -> dict:
589
+ query = {"status": status_value} if status_value else {}
590
+ total = await db[collection].count_documents(query)
591
+ cursor = db[collection].find(query, {"_id": 0}).sort("_id", -1).skip((page - 1) * page_size).limit(page_size)
592
+ return {"total": total, "page": page, "page_size": page_size, "results": [item async for item in cursor]}
593
+
594
+
595
+ OPEN_CHANGE_STATUSES = {"Open", "Draft", "Submitted", "Under Review", "Approved", "Implementation", "Verification"}
596
+
597
+
598
+ def _change_control_number(now: datetime, sequence: int) -> str:
599
+ return f"SX-CHG-{now:%Y}-{sequence:04d}"
600
+
601
+
602
+ def _entity_collection(entity: dict) -> str | None:
603
+ collection = entity.get("collection") or entity.get("entity_collection")
604
+ if collection:
605
+ collection_name = str(collection)
606
+ return {
607
+ "stages": "industrial_stages",
608
+ "stage": "industrial_stages",
609
+ "lines": "industrial_lines",
610
+ "line": "industrial_lines",
611
+ "plants": "industrial_plants",
612
+ "plant": "industrial_plants",
613
+ "machines": "pharmaceutical_machines",
614
+ "equipment": "pharmaceutical_equipment",
615
+ }.get(collection_name, collection_name)
616
+ entity_type = str(entity.get("type") or entity.get("entity_type") or "").strip().lower()
617
+ return {
618
+ "machine": "pharmaceutical_machines",
619
+ "equipment": "pharmaceutical_equipment",
620
+ "workstation": "workstations",
621
+ "sop": "sops",
622
+ "process": "industrial_stages",
623
+ "stage": "industrial_stages",
624
+ "line": "industrial_lines",
625
+ "plant": "industrial_plants",
626
+ "material": "inventory_items",
627
+ "recipe": "recipe_routes",
628
+ }.get(entity_type)
629
+
630
+
631
+ def _entity_id(entity: dict) -> str | None:
632
+ for key in ("entity_id", "id", "machine_id", "equipment_id", "workstation_id", "sop_id", "stage_id", "line_id", "plant_id", "recipe_id"):
633
+ value = entity.get(key)
634
+ if value:
635
+ return str(value)
636
+ return None
637
+
638
+
639
+ async def _open_change_for_affected_entities(db, affected_entities: list[dict]) -> dict | None:
640
+ entity_ids = [_entity_id(entity) for entity in affected_entities]
641
+ entity_ids = [entity_id for entity_id in entity_ids if entity_id]
642
+ if not entity_ids:
643
+ return None
644
+ return await db[CHANGE_CONTROL_COLLECTION].find_one(
645
+ {
646
+ "status": {"$in": list(OPEN_CHANGE_STATUSES)},
647
+ "affected_entities.entity_id": {"$in": entity_ids},
648
+ },
649
+ {"_id": 0},
650
+ )
651
+
652
+
653
+ async def _resolve_affected_entities(db, affected_entities: list[dict]) -> list[dict]:
654
+ resolved: list[dict] = []
655
+ for entity in affected_entities:
656
+ entity_id = _entity_id(entity)
657
+ collection = _entity_collection(entity)
658
+ record = None
659
+ if collection and entity_id:
660
+ entity_name = str(entity.get("name") or "").strip()
661
+ name_query = []
662
+ if entity_name:
663
+ name_query = [
664
+ {"name": {"$regex": f"^{re.escape(entity_name)}$", "$options": "i"}},
665
+ {"title": {"$regex": f"^{re.escape(entity_name)}$", "$options": "i"}},
666
+ ]
667
+ record = await db[collection].find_one(
668
+ {
669
+ "$or": [
670
+ {"id": entity_id},
671
+ {"entity_id": entity_id},
672
+ {"machine_id": entity_id},
673
+ {"equipment_id": entity_id},
674
+ {"workstation_id": entity_id},
675
+ {"sop_id": entity_id},
676
+ {"stage_id": entity_id},
677
+ {"line_id": entity_id},
678
+ {"plant_id": entity_id},
679
+ ] + name_query
680
+ },
681
+ {"_id": 0, "embedding": 0},
682
+ )
683
+ if record:
684
+ entity_id = (
685
+ record.get("id")
686
+ or record.get("entity_id")
687
+ or record.get("machine_id")
688
+ or record.get("equipment_id")
689
+ or record.get("workstation_id")
690
+ or record.get("sop_id")
691
+ or record.get("stage_id")
692
+ or record.get("line_id")
693
+ or record.get("plant_id")
694
+ or entity_id
695
+ )
696
+ resolved.append(
697
+ {
698
+ **entity,
699
+ "entity_id": entity_id,
700
+ "collection": collection,
701
+ "name": entity.get("name") or (record or {}).get("name") or (record or {}).get("title") or entity_id,
702
+ "status": entity.get("status") or (record or {}).get("status"),
703
+ "resolved": bool(record),
704
+ }
705
+ )
706
+ return resolved
707
+
708
+
709
+ async def _change_impact_assessment(db, affected_entities: list[dict]) -> dict:
710
+ entity_ids = [str(entity.get("entity_id")) for entity in affected_entities if entity.get("entity_id")]
711
+ machine_ids = [entity["entity_id"] for entity in affected_entities if entity.get("collection") == "pharmaceutical_machines" and entity.get("entity_id")]
712
+ equipment_ids = [entity["entity_id"] for entity in affected_entities if entity.get("collection") == "pharmaceutical_equipment" and entity.get("entity_id")]
713
+ workstation_ids = [entity["entity_id"] for entity in affected_entities if entity.get("collection") == "workstations" and entity.get("entity_id")]
714
+ stage_ids = [entity["entity_id"] for entity in affected_entities if entity.get("collection") == "industrial_stages" and entity.get("entity_id")]
715
+
716
+ sops = await db["sops"].find(
717
+ {
718
+ "$or": [
719
+ {"entity_id": {"$in": entity_ids}},
720
+ {"machine_id": {"$in": machine_ids}},
721
+ {"equipment_id": {"$in": equipment_ids}},
722
+ {"workstation_id": {"$in": workstation_ids}},
723
+ {"stage_id": {"$in": stage_ids}},
724
+ ]
725
+ },
726
+ {"_id": 0, "id": 1, "sop_id": 1, "title": 1, "name": 1, "entity_name": 1, "workstation_id": 1, "stage_id": 1, "line_id": 1, "plant_id": 1},
727
+ ).to_list(length=100)
728
+ work_orders = await db["work_orders"].find(
729
+ {
730
+ "$or": [
731
+ {"machine_id": {"$in": machine_ids}},
732
+ {"equipment_id": {"$in": equipment_ids}},
733
+ {"workstation_id": {"$in": workstation_ids}},
734
+ {"status": {"$in": ["Open", "In Progress", "Scheduled"]}},
735
+ ]
736
+ },
737
+ {"_id": 0, "work_order_id": 1, "title": 1, "status": 1},
738
+ ).to_list(length=100)
739
+ operators = await db["users"].find(
740
+ {
741
+ "$or": [
742
+ {"workstation_id": {"$in": workstation_ids}},
743
+ {"stage_id": {"$in": stage_ids}},
744
+ ],
745
+ "title": "Production Worker",
746
+ },
747
+ {"_id": 0, "user_id": 1, "name": 1, "workstation_id": 1, "stage_id": 1},
748
+ ).to_list(length=100)
749
+ open_batches = await db["batch_records"].find(
750
+ {"status": {"$in": ["Open", "In Progress", "Released"]}},
751
+ {"_id": 0, "batch_id": 1, "status": 1},
752
+ ).to_list(length=100)
753
+
754
+ return {
755
+ "directly_affected_entities": affected_entities,
756
+ "connected_sops": sops,
757
+ "linked_work_orders": work_orders,
758
+ "qualified_operators": operators,
759
+ "open_batches": open_batches,
760
+ "downstream_compliance_records": [],
761
+ "flags": {
762
+ "validated_system_review_required": any(entity.get("collection") in {"pharmaceutical_machines", "pharmaceutical_equipment", "sops"} for entity in affected_entities),
763
+ "quality_manager_signoff_required": True,
764
+ "asset_hold_required": bool(machine_ids or equipment_ids),
765
+ },
766
+ }
767
+
768
+
769
+ def _change_decisions(payload: dict, impact: dict) -> dict:
770
+ change_type = payload.get("change_type")
771
+ risk_score = payload.get("risk_score") or 0
772
+ major_or_risky = change_type in {"Major", "Emergency"} or risk_score >= 40
773
+ sop_related = any(entity.get("collection") == "sops" for entity in payload.get("affected_entities") or [])
774
+ process_related = any(entity.get("collection") in {"industrial_stages", "recipe_routes"} for entity in payload.get("affected_entities") or [])
775
+ return {
776
+ "revalidation_required": payload.get("revalidation_required") if payload.get("revalidation_required") is not None else major_or_risky,
777
+ "requalification_required": payload.get("requalification_required") if payload.get("requalification_required") is not None else impact["flags"]["asset_hold_required"],
778
+ "sop_revision_required": payload.get("sop_revision_required") if payload.get("sop_revision_required") is not None else sop_related or process_related,
779
+ "retraining_required": payload.get("retraining_required") if payload.get("retraining_required") is not None else sop_related or process_related,
780
+ "regulatory_notification_required": payload.get("regulatory_notification_required") if payload.get("regulatory_notification_required") is not None else change_type == "Major" and risk_score >= 60,
781
+ "stability_study_required": payload.get("stability_study_required") if payload.get("stability_study_required") is not None else False,
782
+ }
783
+
784
+
785
+ def _implementation_tasks(payload: dict, impact: dict, decisions: dict) -> list[dict]:
786
+ tasks = [
787
+ {"task_id": f"task-{uuid4().hex}", "title": "Review graph-generated impact assessment", "owner_role": "Reviewer", "status": "Pending", "evidence_required": False},
788
+ {"task_id": f"task-{uuid4().hex}", "title": "Implement approved change", "owner_role": "Implementer", "status": "Pending", "evidence_required": True},
789
+ {"task_id": f"task-{uuid4().hex}", "title": "Verify implementation effectiveness", "owner_role": "Verifier", "status": "Pending", "evidence_required": True},
790
+ ]
791
+ if decisions.get("sop_revision_required"):
792
+ tasks.append({"task_id": f"task-{uuid4().hex}", "title": "Revise affected SOP/document", "owner_role": "Document Owner", "status": "Pending", "evidence_required": True})
793
+ if decisions.get("retraining_required"):
794
+ tasks.append({"task_id": f"task-{uuid4().hex}", "title": "Complete affected-operator retraining", "owner_role": "Training Coordinator", "status": "Pending", "evidence_required": True})
795
+ if decisions.get("requalification_required"):
796
+ tasks.append({"task_id": f"task-{uuid4().hex}", "title": "Complete equipment requalification or calibration evidence", "owner_role": "Engineering", "status": "Pending", "evidence_required": True})
797
+ return tasks
798
+
799
+
800
+ SYSTEM_APPROVAL_USERS = {
801
+ "document_controller": {
802
+ "user_id": "USER-AHM-DOCUMENT-CONTROLLER-001",
803
+ "employee_id": "EMP-AHM-DOC-CTRL-001",
804
+ "name": "Dev Mehta",
805
+ "title": "Document Controller",
806
+ "role": "document-controller",
807
+ "department": "DEPT-QA-AHM",
808
+ "team": "TEAM-AHM-DOC-CONTROL",
809
+ "email": "dev.mehta@ahmedabad.example",
810
+ "plant_id": "PLT-IND-AHM-004",
811
+ },
812
+ "quality_control": {
813
+ "user_id": "USER-AHM-QUALITY-CONTROL-001",
814
+ "employee_id": "EMP-AHM-QC-001",
815
+ "name": "Meera Nair",
816
+ "title": "Quality Control",
817
+ "role": "quality-control",
818
+ "department": "DEPT-QC-AHM",
819
+ "team": "TEAM-AHM-QC",
820
+ "email": "meera.nair@ahmedabad.example",
821
+ "plant_id": "PLT-IND-AHM-004",
822
+ },
823
+ "change_control": {
824
+ "user_id": "USER-AHM-CHANGE-CONTROL-001",
825
+ "employee_id": "EMP-AHM-CC-001",
826
+ "name": "Sanjay Rao",
827
+ "title": "Change Control",
828
+ "role": "change-control",
829
+ "department": "DEPT-QA-AHM",
830
+ "team": "TEAM-AHM-CHANGE-CONTROL",
831
+ "email": "sanjay.rao@ahmedabad.example",
832
+ "plant_id": "PLT-IND-AHM-004",
833
+ },
834
+ }
835
+
836
+
837
+ async def _ensure_system_approval_user(db, key: str) -> dict:
838
+ base = dict(SYSTEM_APPROVAL_USERS[key])
839
+ existing = await db["users"].find_one({"user_id": base["user_id"]}, {"embedding": 0})
840
+ now = utc_now()
841
+ document = {
842
+ **base,
843
+ "is_active": True,
844
+ "created_at": (existing or {}).get("created_at") or now,
845
+ "updated_at": now,
846
+ }
847
+ await db["users"].update_one({"user_id": document["user_id"]}, {"$set": document}, upsert=True)
848
+ stored = await db["users"].find_one({"user_id": document["user_id"]}, {"_id": 0, "embedding": 0})
849
+ await safe_mirror(mirror_document("users", stored or document, "update"))
850
+ return stored or document
851
+
852
+
853
+ def _approval_user_summary(user: dict | None, fallback_role: str) -> dict:
854
+ if not user:
855
+ return {
856
+ "user_id": None,
857
+ "name": None,
858
+ "title": fallback_role,
859
+ "role": fallback_role,
860
+ "email": None,
861
+ }
862
+ fallback_email = f"{str(user.get('user_id')).lower()}@internal.example" if user.get("user_id") else None
863
+ return {
864
+ "user_id": user.get("user_id"),
865
+ "name": user.get("name") or user.get("email") or user.get("user_id"),
866
+ "title": user.get("title") or fallback_role,
867
+ "role": user.get("role") or fallback_role,
868
+ "email": user.get("email") or fallback_email,
869
+ }
870
+
871
+
872
+ async def _approval_user_by_id(db, user_id: str | None) -> dict | None:
873
+ if not user_id:
874
+ return None
875
+ return await db["users"].find_one({"user_id": user_id}, {"_id": 0, "embedding": 0})
876
+
877
+
878
+ async def _approval_user_by_dialog_step(db, step: dict) -> dict | None:
879
+ assigned_user = step.get("assigned_user") if isinstance(step.get("assigned_user"), dict) else {}
880
+ user_id = (
881
+ step.get("assigned_user_id")
882
+ or step.get("user_id")
883
+ or step.get("approver_id")
884
+ or assigned_user.get("user_id")
885
+ )
886
+ user = await _approval_user_by_id(db, str(user_id) if user_id else None)
887
+ if user:
888
+ return user
889
+ email = step.get("assigned_user_email") or step.get("email") or assigned_user.get("email")
890
+ name = step.get("assigned_user_name") or step.get("name") or step.get("approver_name") or assigned_user.get("name")
891
+ clauses = []
892
+ if email:
893
+ clauses.append({"email": {"$regex": f"^{re.escape(str(email).strip())}$", "$options": "i"}})
894
+ if name:
895
+ clauses.append({"name": {"$regex": f"^{re.escape(str(name).strip())}$", "$options": "i"}})
896
+ if not clauses:
897
+ return None
898
+ return await db["users"].find_one({"$or": clauses}, {"_id": 0, "embedding": 0})
899
+
900
+
901
+ def _manual_chain_from_payload(payload: dict) -> list[dict]:
902
+ chain = payload.get("approval_chain") if isinstance(payload.get("approval_chain"), list) else []
903
+ if not chain:
904
+ chain = payload.get("manual_approval_chain") if isinstance(payload.get("manual_approval_chain"), list) else []
905
+ return chain
906
+
907
+
908
+ async def _resolve_manual_approval_chain(db, chain: list[dict], basis: dict) -> list[dict]:
909
+ normalized: list[dict] = []
910
+ for index, step in enumerate(chain, start=1):
911
+ if not isinstance(step, dict):
912
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Approval step {index} must be an object.")
913
+ user = await _approval_user_by_dialog_step(db, step)
914
+ if not user:
915
+ label = step.get("stage") or step.get("step") or step.get("role") or index
916
+ raise HTTPException(
917
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
918
+ detail=f"Approval step '{label}' must resolve to an existing named worker node.",
919
+ )
920
+ role = (
921
+ step.get("role")
922
+ or step.get("required_role")
923
+ or step.get("assigned_user_title")
924
+ or user.get("title")
925
+ or "Approver"
926
+ )
927
+ stage = step.get("stage") or step.get("step") or f"{role} Approval"
928
+ assigned_user = _approval_user_summary(user, str(role))
929
+ normalized.append(
930
+ {
931
+ **step,
932
+ "sequence": int(step.get("sequence") or index),
933
+ "stage": stage,
934
+ "step": stage,
935
+ "status": step.get("status") or "Pending",
936
+ "role": role,
937
+ "required_role": step.get("required_role") or role,
938
+ "required_roles": step.get("required_roles") or [role],
939
+ "assigned_user": assigned_user,
940
+ "assigned_user_id": assigned_user.get("user_id"),
941
+ "assigned_user_name": assigned_user.get("name"),
942
+ "assigned_user_title": assigned_user.get("title"),
943
+ "assigned_user_email": assigned_user.get("email") or step.get("assigned_user_email"),
944
+ "minimum_approvals": step.get("minimum_approvals") or 1,
945
+ "routing": step.get("routing") or "sequential",
946
+ "source": step.get("source") or "manual_dialog",
947
+ "context": step.get("context") or basis,
948
+ }
949
+ )
950
+ normalized.sort(key=lambda item: int(item.get("sequence") or 0))
951
+ for index, step in enumerate(normalized, start=1):
952
+ step["sequence"] = index
953
+ validate_named_approval_chain(normalized)
954
+ missing_email = [step.get("assigned_user_name") or step.get("assigned_user_id") for step in normalized if not approval_step_email(step)]
955
+ if missing_email:
956
+ raise HTTPException(
957
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
958
+ detail=f"Approval step approvers must have email addresses: {', '.join(str(item) for item in missing_email)}",
959
+ )
960
+ return normalized
961
+
962
+
963
+ def _approval_email_payload(notification: dict) -> dict:
964
+ approval_url = notification.get("approval_form_url")
965
+ approval_id = notification.get("approval_id")
966
+ external_decision_url = (
967
+ f"{settings.APPROVAL_FORM_BASE_URL.rstrip('/')}/{approval_id}/external-decision"
968
+ if approval_id
969
+ else None
970
+ )
971
+ source_title = notification.get("source_title") or notification.get("source_id") or "Approval request"
972
+ stage = notification.get("stage") or "Approval"
973
+ role = notification.get("role") or "Approver"
974
+ approver = notification.get("assigned_user_name") or notification.get("assigned_user_id") or "Assigned approver"
975
+ expires_at = notification.get("approval_token_expires_at")
976
+ lines = [
977
+ notification.get("body") or f"Approval is pending for {source_title}.",
978
+ "",
979
+ f"Approval Stage: {stage}",
980
+ f"Role: {role}",
981
+ f"Approver: {approver}",
982
+ f"Source: {source_title}",
983
+ f"Link Expires: {expires_at}" if expires_at else "Link Expires: 24 hours from issue",
984
+ f"Approval Link: {approval_url}" if approval_url else "",
985
+ ]
986
+ body = "\n".join(line for line in lines if line is not None).strip()
987
+ safe_url = _escape_html(approval_url)
988
+ html = f"""
989
+ <div style="margin:0;padding:0;background:#f6f8fb;color:#172033;font-family:Inter,Segoe UI,Arial,sans-serif;">
990
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;background:#f6f8fb;padding:24px 0;">
991
+ <tr>
992
+ <td align="center" style="padding:24px 12px;">
993
+ <table role="presentation" width="640" cellpadding="0" cellspacing="0" style="width:100%;max-width:640px;border-collapse:collapse;background:#ffffff;border:1px solid #d9e2ec;border-radius:8px;overflow:hidden;">
994
+ <tr>
995
+ <td style="padding:18px 24px;border-bottom:1px solid #d9e2ec;background:#ffffff;">
996
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
997
+ <tr>
998
+ <td style="font-size:18px;font-weight:700;color:#172033;">Sentinel X</td>
999
+ <td align="right" style="font-size:13px;color:#667085;">GxP Approval</td>
1000
+ </tr>
1001
+ </table>
1002
+ </td>
1003
+ </tr>
1004
+ <tr>
1005
+ <td style="padding:26px 24px 10px;">
1006
+ <h1 style="margin:0;font-size:22px;line-height:1.3;color:#172033;font-weight:700;">Approval Required</h1>
1007
+ <p style="margin:8px 0 0;font-size:14px;line-height:1.5;color:#667085;">A controlled change request is waiting for your review and electronic decision.</p>
1008
+ </td>
1009
+ </tr>
1010
+ <tr>
1011
+ <td style="padding:12px 24px 8px;">
1012
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:separate;border-spacing:0 10px;">
1013
+ <tr>
1014
+ <td style="padding:12px;border:1px solid #d9e2ec;border-radius:6px;background:#fbfcfe;">
1015
+ <div style="font-size:12px;color:#667085;margin-bottom:4px;">Record</div>
1016
+ <div style="font-size:14px;font-weight:700;color:#172033;">{_escape_html(source_title)}</div>
1017
+ </td>
1018
+ </tr>
1019
+ <tr>
1020
+ <td style="padding:12px;border:1px solid #d9e2ec;border-radius:6px;background:#fbfcfe;">
1021
+ <div style="font-size:12px;color:#667085;margin-bottom:4px;">Approval Stage</div>
1022
+ <div style="font-size:14px;font-weight:700;color:#172033;">{_escape_html(stage)}</div>
1023
+ </td>
1024
+ </tr>
1025
+ <tr>
1026
+ <td style="padding:12px;border:1px solid #d9e2ec;border-radius:6px;background:#fbfcfe;">
1027
+ <div style="font-size:12px;color:#667085;margin-bottom:4px;">Assigned Approver</div>
1028
+ <div style="font-size:14px;font-weight:700;color:#172033;">{_escape_html(approver)} · {_escape_html(role)}</div>
1029
+ </td>
1030
+ </tr>
1031
+ </table>
1032
+ </td>
1033
+ </tr>
1034
+ <tr>
1035
+ <td style="padding:18px 24px 6px;">
1036
+ <a href="{safe_url}" style="display:inline-block;background:#1457d9;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;border-radius:6px;padding:12px 18px;">Open Approval Form</a>
1037
+ </td>
1038
+ </tr>
1039
+ <tr>
1040
+ <td style="padding:10px 24px 24px;">
1041
+ <p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:#667085;">This link is unique to this notification and expires in 24 hours. You will be asked to sign in before approving or rejecting.</p>
1042
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#667085;word-break:break-all;">{safe_url}</p>
1043
+ </td>
1044
+ </tr>
1045
+ </table>
1046
+ </td>
1047
+ </tr>
1048
+ </table>
1049
+ </div>
1050
+ """
1051
+ return {
1052
+ "to": notification.get("assigned_user_email"),
1053
+ "subject": notification.get("subject") or f"Approval required: {source_title}",
1054
+ "body": body,
1055
+ "text": body,
1056
+ "html": html,
1057
+ "type": "approval_request",
1058
+ "source": "change_control_approval",
1059
+ "notification_id": notification.get("notification_id"),
1060
+ "approval_id": notification.get("approval_id"),
1061
+ "approval_form_url": approval_url,
1062
+ "external_decision_url": external_decision_url,
1063
+ "source_collection": notification.get("source_collection"),
1064
+ "source_id": notification.get("source_id"),
1065
+ "source_title": notification.get("source_title"),
1066
+ "assigned_user_id": notification.get("assigned_user_id"),
1067
+ "assigned_user_name": notification.get("assigned_user_name"),
1068
+ "assigned_user_email": notification.get("assigned_user_email"),
1069
+ }
1070
+
1071
+
1072
+ async def _post_approval_notifications_to_n8n(event: dict) -> dict:
1073
+ webhook_url = str(settings.N8N_EMAIL_WEBHOOK_URL or "").strip()
1074
+ notifications = [
1075
+ item
1076
+ for item in (event.get("notifications") or [])
1077
+ if isinstance(item, dict) and item.get("assigned_user_email")
1078
+ ]
1079
+ status_payload = {
1080
+ "enabled": bool(webhook_url),
1081
+ "webhook_url_configured": bool(webhook_url),
1082
+ "attempted": 0,
1083
+ "queued": 0,
1084
+ "failed": 0,
1085
+ "recipients": [item.get("assigned_user_email") for item in notifications],
1086
+ "errors": [],
1087
+ }
1088
+ if not webhook_url or not notifications:
1089
+ return status_payload
1090
+ async with httpx.AsyncClient(timeout=10.0) as client:
1091
+ for notification in notifications:
1092
+ status_payload["attempted"] += 1
1093
+ try:
1094
+ response = await client.post(webhook_url, json=_approval_email_payload(notification), headers=n8n_webhook_auth_headers())
1095
+ if 200 <= response.status_code < 300:
1096
+ status_payload["queued"] += 1
1097
+ else:
1098
+ status_payload["failed"] += 1
1099
+ status_payload["errors"].append(
1100
+ {
1101
+ "recipient": notification.get("assigned_user_email"),
1102
+ "status_code": response.status_code,
1103
+ "response": response.text[:500],
1104
+ }
1105
+ )
1106
+ except httpx.HTTPError as exc:
1107
+ status_payload["failed"] += 1
1108
+ status_payload["errors"].append({"recipient": notification.get("assigned_user_email"), "error": str(exc)})
1109
+ return status_payload
1110
+
1111
+
1112
+ async def _workstation_context_for_change(db, payload: dict, impact: dict) -> tuple[dict | None, dict | None, dict | None]:
1113
+ entity_ids = [str(entity.get("entity_id")) for entity in payload.get("affected_entities") or [] if entity.get("entity_id")]
1114
+ connected_sops = impact.get("connected_sops") or []
1115
+ connected_sop_ids = [sop.get("id") or sop.get("sop_id") for sop in connected_sops if sop.get("id") or sop.get("sop_id")]
1116
+ sop_query = {
1117
+ "$or": [
1118
+ {"id": {"$in": entity_ids}},
1119
+ {"sop_id": {"$in": entity_ids}},
1120
+ {"id": {"$in": connected_sop_ids}},
1121
+ {"sop_id": {"$in": connected_sop_ids}},
1122
+ ]
1123
+ }
1124
+ sop = await db["sops"].find_one(sop_query, {"_id": 0, "embedding": 0}) if entity_ids or connected_sop_ids else None
1125
+ sop_workstation_ids = [str(sop.get("workstation_id"))] if sop and sop.get("workstation_id") else []
1126
+ workstation_ids = [
1127
+ str(entity.get("entity_id"))
1128
+ for entity in payload.get("affected_entities") or []
1129
+ if entity.get("collection") == "workstations" and entity.get("entity_id")
1130
+ ] + sop_workstation_ids
1131
+ stage_ids = [
1132
+ str(entity.get("entity_id"))
1133
+ for entity in payload.get("affected_entities") or []
1134
+ if entity.get("collection") == "industrial_stages" and entity.get("entity_id")
1135
+ ]
1136
+ workstation = await db["workstations"].find_one(
1137
+ {"$or": [{"id": {"$in": workstation_ids}}, {"workstation_id": {"$in": workstation_ids}}]},
1138
+ {"_id": 0, "embedding": 0},
1139
+ ) if workstation_ids else None
1140
+ if not workstation and sop and sop.get("workstation_id"):
1141
+ workstation = await db["workstations"].find_one(
1142
+ {"$or": [{"id": sop.get("workstation_id")}, {"workstation_id": sop.get("workstation_id")}]},
1143
+ {"_id": 0, "embedding": 0},
1144
+ )
1145
+ if not workstation and stage_ids:
1146
+ workstation = await db["workstations"].find_one({"stage_id": {"$in": stage_ids}}, {"_id": 0, "embedding": 0})
1147
+ operator = None
1148
+ if workstation:
1149
+ operator = await db["users"].find_one(
1150
+ {
1151
+ "$or": [
1152
+ {"user_id": workstation.get("operator_id")},
1153
+ {"workstation_id": workstation.get("id")},
1154
+ ],
1155
+ "title": "Production Worker",
1156
+ },
1157
+ {"_id": 0, "embedding": 0},
1158
+ )
1159
+ if not operator and impact.get("qualified_operators"):
1160
+ operator = impact["qualified_operators"][0]
1161
+ return sop, workstation, operator
1162
+
1163
+
1164
+ async def _approval_workflow(db, payload: dict, impact: dict) -> tuple[list[dict], dict]:
1165
+ document_controller = await _ensure_system_approval_user(db, "document_controller")
1166
+ quality_control = await _ensure_system_approval_user(db, "quality_control")
1167
+ change_control = await _ensure_system_approval_user(db, "change_control")
1168
+ sop, workstation, operator = await _workstation_context_for_change(db, payload, impact)
1169
+ line_manager = await _approval_user_by_id(db, (workstation or {}).get("line_manager_id"))
1170
+ plant_manager = await _approval_user_by_id(db, (workstation or {}).get("site_manager_id"))
1171
+
1172
+ context = {
1173
+ "sop_id": (sop or {}).get("id") or (sop or {}).get("sop_id"),
1174
+ "sop_title": (sop or {}).get("title") or (sop or {}).get("name"),
1175
+ "workstation_id": (workstation or {}).get("id"),
1176
+ "workstation_name": (workstation or {}).get("name"),
1177
+ "line_id": (workstation or {}).get("line_id"),
1178
+ "stage_id": (workstation or {}).get("stage_id"),
1179
+ "plant_id": (workstation or {}).get("plant_id") or (sop or {}).get("plant_id"),
1180
+ }
1181
+ steps = [
1182
+ ("Document Review", "Document Controller", document_controller, "document_control"),
1183
+ ("Workstation Impact Review", "Production Worker", operator, "workstation"),
1184
+ ("Quality Control Review", "Quality Control", quality_control, "quality_control"),
1185
+ ("Change Control Approval", "Change Control", change_control, "change_control"),
1186
+ ("Line Manager Approval", "Line Manager", line_manager, "line"),
1187
+ ("Plant Manager Approval", "Plant Manager", plant_manager, "plant"),
1188
+ ]
1189
+ workflow: list[dict] = []
1190
+ for sequence, (stage, required_role, user, source) in enumerate(steps, start=1):
1191
+ assigned_user = _approval_user_summary(user, required_role)
1192
+ workflow.append(
1193
+ {
1194
+ "sequence": sequence,
1195
+ "stage": stage,
1196
+ "step": stage,
1197
+ "status": "Pending",
1198
+ "role": required_role,
1199
+ "required_role": required_role,
1200
+ "required_roles": [required_role],
1201
+ "assigned_user": assigned_user,
1202
+ "assigned_user_id": assigned_user.get("user_id"),
1203
+ "assigned_user_name": assigned_user.get("name"),
1204
+ "assigned_user_title": assigned_user.get("title"),
1205
+ "minimum_approvals": 1,
1206
+ "routing": "sequential",
1207
+ "source": source,
1208
+ "context": context,
1209
+ }
1210
+ )
1211
+ assignment_basis = {
1212
+ "mode": "document_and_process_controls",
1213
+ "sop_id": context.get("sop_id"),
1214
+ "sop_title": context.get("sop_title"),
1215
+ "document_node_collection": "sops" if context.get("sop_id") else None,
1216
+ "document_node_id": context.get("sop_id"),
1217
+ "workstation_id": context.get("workstation_id"),
1218
+ "workstation_name": context.get("workstation_name"),
1219
+ "line_id": context.get("line_id"),
1220
+ "stage_id": context.get("stage_id"),
1221
+ "plant_id": context.get("plant_id"),
1222
+ "uses_actual_worker_nodes": True,
1223
+ }
1224
+ return workflow, assignment_basis
1225
+
1226
+
1227
+ def _change_control_graph_relationships(resolved_entities: list[dict], workflow: list[dict], basis: dict) -> list[dict]:
1228
+ relationships = [
1229
+ {"type": "AFFECTS", "target_collection": entity.get("collection"), "target_id": entity.get("entity_id")}
1230
+ for entity in resolved_entities
1231
+ if entity.get("entity_id")
1232
+ ]
1233
+ relationships.extend(approval_graph_relationships(workflow, basis))
1234
+ return relationships
1235
+
1236
+
1237
+ async def _build_change_control_record(db, payload: dict, current_user: dict) -> dict:
1238
+ now = utc_now()
1239
+ manual_chain = _manual_chain_from_payload(payload)
1240
+ existing = await _open_change_for_affected_entities(db, payload.get("affected_entities") or [])
1241
+ if existing:
1242
+ raise HTTPException(
1243
+ status_code=status.HTTP_409_CONFLICT,
1244
+ detail=f"Open change already exists for an affected entity: {existing.get('change_control_number') or existing.get('change_control_id')}",
1245
+ )
1246
+ resolved_entities = await _resolve_affected_entities(db, payload.get("affected_entities") or [])
1247
+ payload["affected_entities"] = resolved_entities
1248
+ impact = await _change_impact_assessment(db, resolved_entities)
1249
+ decisions = _change_decisions(payload, impact)
1250
+ sequence = await db[CHANGE_CONTROL_COLLECTION].count_documents({}) + 1
1251
+ record_id = f"change_control-{uuid4().hex}"
1252
+ approval_workflow, approval_assignment_basis = await resolve_named_approval_chain(
1253
+ db,
1254
+ source_collection=CHANGE_CONTROL_COLLECTION,
1255
+ source_id=record_id,
1256
+ source_document={**payload, "change_control_id": record_id},
1257
+ affected_entities=resolved_entities,
1258
+ current_user_id=_current_user_id(current_user),
1259
+ )
1260
+ if manual_chain:
1261
+ approval_workflow = await _resolve_manual_approval_chain(db, manual_chain, approval_assignment_basis)
1262
+ approval_assignment_basis = {
1263
+ **approval_assignment_basis,
1264
+ "mode": "manual_dialog_assignment",
1265
+ "manual_dialog_assignment": True,
1266
+ "auto_chain_available": True,
1267
+ "manual_chain_count": len(approval_workflow),
1268
+ }
1269
+ record = {
1270
+ "change_control_id": record_id,
1271
+ "change_control_number": _change_control_number(now, sequence),
1272
+ "standard": "GxP",
1273
+ "standards": compliance_readiness()["standards"],
1274
+ "standard_id": "ich-q10",
1275
+ **payload,
1276
+ **decisions,
1277
+ "initiating_date": now,
1278
+ "initiating_user": _current_user_id(current_user),
1279
+ "impact_assessment_generated": impact,
1280
+ "implementation_tasks": _implementation_tasks(payload, impact, decisions),
1281
+ "training_records": [
1282
+ {
1283
+ "training_record_id": f"training-{uuid4().hex}",
1284
+ "worker_id": operator.get("user_id"),
1285
+ "worker_name": operator.get("name"),
1286
+ "status": "Pending",
1287
+ }
1288
+ for operator in impact.get("qualified_operators", [])
1289
+ ],
1290
+ "approval_chain": approval_workflow,
1291
+ "approval_workflow": approval_workflow,
1292
+ "approval_process_definition": approval_workflow,
1293
+ "approval_assignment_basis": approval_assignment_basis,
1294
+ "approval_chain_resolved": True,
1295
+ "approval_chain_resolved_at": now,
1296
+ "electronic_signatures": [],
1297
+ "effectiveness_check": {
1298
+ "required": True,
1299
+ "window_days": payload.get("effectiveness_check_days") or (30 if payload.get("change_type") == "Emergency" else 14),
1300
+ "status": "Pending",
1301
+ },
1302
+ "graph_relationships": _change_control_graph_relationships(resolved_entities, approval_workflow, approval_assignment_basis),
1303
+ "status": payload.get("status") or "Open",
1304
+ "created_at": now,
1305
+ "updated_at": now,
1306
+ "created_by": _current_user_id(current_user),
1307
+ "updated_by": _current_user_id(current_user),
1308
+ }
1309
+ record["record_hash"] = sha256_hex(record)
1310
+ return record
1311
+
1312
+
1313
+ def _new_mfa_secret() -> str:
1314
+ return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
1315
+
1316
+
1317
+ def _normalize_mfa_code(code: str) -> str:
1318
+ return "".join(character for character in code.strip().replace(" ", "").replace("-", "") if character.isalnum())
1319
+
1320
+
1321
+ def _totp(secret: str, counter: int, digits: int = 6) -> str:
1322
+ padded = secret + "=" * ((8 - len(secret) % 8) % 8)
1323
+ key = base64.b32decode(padded, casefold=True)
1324
+ digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
1325
+ offset = digest[-1] & 0x0F
1326
+ code = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
1327
+ return str(code % (10**digits)).zfill(digits)
1328
+
1329
+
1330
+ def _verify_totp(secret: str, code: str, window: int = 1) -> bool:
1331
+ normalized = _normalize_mfa_code(code)
1332
+ if not normalized.isdigit():
1333
+ return False
1334
+ counter = int(time.time() // 30)
1335
+ return any(hmac.compare_digest(_totp(secret, counter + drift), normalized) for drift in range(-window, window + 1))
1336
+
1337
+
1338
+ def _new_backup_codes(count: int = 10) -> list[str]:
1339
+ return [f"{secrets.token_hex(4)}-{secrets.token_hex(4)}" for _ in range(count)]
1340
+
1341
+
1342
+ def _hash_backup_code(code: str) -> str:
1343
+ return hashlib.sha256(_normalize_mfa_code(code).lower().encode("utf-8")).hexdigest()
1344
+
1345
+
1346
+ def _otpauth_uri(user: dict, secret: str) -> str:
1347
+ issuer = "Sentinel X"
1348
+ label = quote(f"{issuer}:{user.get('email') or user.get('user_id')}")
1349
+ return f"otpauth://totp/{label}?secret={secret}&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30"
1350
+
1351
+
1352
+ def _escape_html(value) -> str:
1353
+ return (
1354
+ str(value or "")
1355
+ .replace("&", "&amp;")
1356
+ .replace("<", "&lt;")
1357
+ .replace(">", "&gt;")
1358
+ .replace('"', "&quot;")
1359
+ .replace("'", "&#x27;")
1360
+ )
1361
+
1362
+
1363
+ APPROVAL_ACCESS_COOKIE_NAME = "indus_approval_access"
1364
+
1365
+
1366
+ def _approval_page_shell(title: str, body: str) -> str:
1367
+ return f"""
1368
+ <!doctype html>
1369
+ <html lang="en">
1370
+ <head>
1371
+ <meta charset="utf-8" />
1372
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1373
+ <title>{_escape_html(title)}</title>
1374
+ <style>
1375
+ :root {{
1376
+ color-scheme: light;
1377
+ --bg: #f6f8fb;
1378
+ --panel: #ffffff;
1379
+ --ink: #172033;
1380
+ --muted: #667085;
1381
+ --line: #d9e2ec;
1382
+ --field: #f9fbfd;
1383
+ --primary: #1457d9;
1384
+ --primary-dark: #0f3f9e;
1385
+ --danger: #b42318;
1386
+ --danger-bg: #fff1f0;
1387
+ --ok: #067647;
1388
+ --ok-bg: #ecfdf3;
1389
+ }}
1390
+ * {{ box-sizing: border-box; }}
1391
+ body {{
1392
+ margin: 0;
1393
+ min-height: 100vh;
1394
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1395
+ background: var(--bg);
1396
+ color: var(--ink);
1397
+ }}
1398
+ .topbar {{
1399
+ height: 56px;
1400
+ display: flex;
1401
+ align-items: center;
1402
+ justify-content: space-between;
1403
+ padding: 0 24px;
1404
+ background: #ffffff;
1405
+ border-bottom: 1px solid var(--line);
1406
+ }}
1407
+ .brand {{
1408
+ font-weight: 700;
1409
+ letter-spacing: 0;
1410
+ }}
1411
+ .eyebrow {{
1412
+ color: var(--muted);
1413
+ font-size: 13px;
1414
+ }}
1415
+ main {{
1416
+ width: min(860px, calc(100vw - 32px));
1417
+ margin: 32px auto;
1418
+ }}
1419
+ .panel {{
1420
+ background: var(--panel);
1421
+ border: 1px solid var(--line);
1422
+ border-radius: 8px;
1423
+ overflow: hidden;
1424
+ box-shadow: 0 12px 32px rgba(16, 24, 40, 0.08);
1425
+ }}
1426
+ .panel-header {{
1427
+ padding: 24px 28px 18px;
1428
+ border-bottom: 1px solid var(--line);
1429
+ }}
1430
+ h1 {{
1431
+ margin: 0;
1432
+ font-size: 24px;
1433
+ line-height: 1.25;
1434
+ letter-spacing: 0;
1435
+ }}
1436
+ .subtitle {{
1437
+ margin: 8px 0 0;
1438
+ color: var(--muted);
1439
+ font-size: 14px;
1440
+ line-height: 1.5;
1441
+ }}
1442
+ .content {{
1443
+ padding: 24px 28px 28px;
1444
+ }}
1445
+ .meta {{
1446
+ display: grid;
1447
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1448
+ gap: 12px;
1449
+ margin-bottom: 24px;
1450
+ }}
1451
+ .meta-item {{
1452
+ border: 1px solid var(--line);
1453
+ border-radius: 6px;
1454
+ padding: 12px;
1455
+ background: #fbfcfe;
1456
+ }}
1457
+ .meta-label {{
1458
+ display: block;
1459
+ color: var(--muted);
1460
+ font-size: 12px;
1461
+ margin-bottom: 4px;
1462
+ }}
1463
+ .meta-value {{
1464
+ display: block;
1465
+ font-size: 14px;
1466
+ font-weight: 650;
1467
+ overflow-wrap: anywhere;
1468
+ }}
1469
+ label {{
1470
+ display: block;
1471
+ margin: 14px 0 6px;
1472
+ font-size: 13px;
1473
+ font-weight: 650;
1474
+ }}
1475
+ input, select, textarea {{
1476
+ width: 100%;
1477
+ min-height: 40px;
1478
+ border: 1px solid #c7d2df;
1479
+ border-radius: 6px;
1480
+ background: var(--field);
1481
+ color: var(--ink);
1482
+ font: inherit;
1483
+ padding: 9px 11px;
1484
+ }}
1485
+ textarea {{ min-height: 112px; resize: vertical; }}
1486
+ input:focus, select:focus, textarea:focus {{
1487
+ outline: 2px solid rgba(20, 87, 217, 0.18);
1488
+ border-color: var(--primary);
1489
+ background: #ffffff;
1490
+ }}
1491
+ .actions {{
1492
+ display: flex;
1493
+ gap: 12px;
1494
+ align-items: center;
1495
+ justify-content: flex-end;
1496
+ margin-top: 22px;
1497
+ }}
1498
+ button {{
1499
+ min-height: 40px;
1500
+ border: 0;
1501
+ border-radius: 6px;
1502
+ background: var(--primary);
1503
+ color: #ffffff;
1504
+ font-weight: 700;
1505
+ padding: 10px 16px;
1506
+ cursor: pointer;
1507
+ }}
1508
+ button:hover {{ background: var(--primary-dark); }}
1509
+ .alert {{
1510
+ border-radius: 6px;
1511
+ padding: 12px 14px;
1512
+ margin-bottom: 18px;
1513
+ font-size: 14px;
1514
+ }}
1515
+ .alert-error {{
1516
+ color: var(--danger);
1517
+ background: var(--danger-bg);
1518
+ border: 1px solid #fecdca;
1519
+ }}
1520
+ .alert-ok {{
1521
+ color: var(--ok);
1522
+ background: var(--ok-bg);
1523
+ border: 1px solid #abefc6;
1524
+ }}
1525
+ .hint {{
1526
+ margin: 12px 0 0;
1527
+ color: var(--muted);
1528
+ font-size: 13px;
1529
+ line-height: 1.45;
1530
+ }}
1531
+ @media (max-width: 720px) {{
1532
+ .topbar {{ padding: 0 16px; }}
1533
+ main {{ margin: 18px auto; }}
1534
+ .panel-header, .content {{ padding-left: 18px; padding-right: 18px; }}
1535
+ .meta {{ grid-template-columns: 1fr; }}
1536
+ .actions {{ justify-content: stretch; }}
1537
+ button {{ width: 100%; }}
1538
+ }}
1539
+ </style>
1540
+ </head>
1541
+ <body>
1542
+ <header class="topbar">
1543
+ <div class="brand">Sentinel X</div>
1544
+ <div class="eyebrow">GxP Approval</div>
1545
+ </header>
1546
+ <main>{body}</main>
1547
+ </body>
1548
+ </html>
1549
+ """
1550
+
1551
+
1552
+ def _approval_meta_html(approval: dict) -> str:
1553
+ title = _escape_html(approval.get("source_title") or approval.get("source_id") or "Approval")
1554
+ stage = _escape_html(approval.get("stage") or "Approval")
1555
+ approver = _escape_html(approval.get("assigned_user_name") or approval.get("assigned_user_id"))
1556
+ return f"""
1557
+ <div class="meta">
1558
+ <div class="meta-item">
1559
+ <span class="meta-label">Record</span>
1560
+ <span class="meta-value">{title}</span>
1561
+ </div>
1562
+ <div class="meta-item">
1563
+ <span class="meta-label">Stage</span>
1564
+ <span class="meta-value">{stage}</span>
1565
+ </div>
1566
+ <div class="meta-item">
1567
+ <span class="meta-label">Assigned Approver</span>
1568
+ <span class="meta-value">{approver}</span>
1569
+ </div>
1570
+ </div>
1571
+ """
1572
+
1573
+
1574
+ def _approval_login_html(approval: dict, token: str, error: str | None = None) -> str:
1575
+ error_block = f'<div class="alert alert-error">{_escape_html(error)}</div>' if error else ""
1576
+ body = f"""
1577
+ <section class="panel">
1578
+ <div class="panel-header">
1579
+ <h1>Sign In To Review Approval</h1>
1580
+ <p class="subtitle">Authentication is required before this electronic approval can be opened.</p>
1581
+ </div>
1582
+ <div class="content">
1583
+ {error_block}
1584
+ {_approval_meta_html(approval)}
1585
+ <form method="post">
1586
+ <input type="hidden" name="token" value="{_escape_html(token)}" />
1587
+ <input type="hidden" name="action" value="login" />
1588
+ <label>Email</label>
1589
+ <input name="email" type="email" autocomplete="email" required />
1590
+ <label>Password</label>
1591
+ <input name="password" type="password" autocomplete="current-password" required />
1592
+ <p class="hint">Only the assigned approver can open and sign this approval request.</p>
1593
+ <div class="actions">
1594
+ <button type="submit">Sign In</button>
1595
+ </div>
1596
+ </form>
1597
+ </div>
1598
+ </section>
1599
+ """
1600
+ return _approval_page_shell("Sign In To Review Approval", body)
1601
+
1602
+
1603
+ def _approval_form_html(approval: dict, token: str, error: str | None = None, current_user: dict | None = None) -> str:
1604
+ error_block = f'<div class="alert alert-error">{_escape_html(error)}</div>' if error else ""
1605
+ signed_in_as = _escape_html((current_user or {}).get("email") or (current_user or {}).get("sub") or "Authenticated user")
1606
+ body = f"""
1607
+ <section class="panel">
1608
+ <div class="panel-header">
1609
+ <h1>Approval Required</h1>
1610
+ <p class="subtitle">Review the controlled change request and submit an electronic decision.</p>
1611
+ </div>
1612
+ <div class="content">
1613
+ {error_block}
1614
+ {_approval_meta_html(approval)}
1615
+ <p class="hint">Signed in as {signed_in_as}</p>
1616
+ <form method="post">
1617
+ <input type="hidden" name="token" value="{_escape_html(token)}" />
1618
+ <input type="hidden" name="action" value="decide" />
1619
+ <label>Decision</label>
1620
+ <select name="decision" required>
1621
+ <option value="Approved">Approve</option>
1622
+ <option value="Rejected">Reject</option>
1623
+ </select>
1624
+ <label>Reason / Comment</label>
1625
+ <textarea name="reason" rows="5" placeholder="Required for rejection"></textarea>
1626
+ <label>Signature Meaning</label>
1627
+ <input name="meaning" value="I reviewed and approve/reject this approval request." />
1628
+ <div class="actions">
1629
+ <button type="submit">Submit Decision</button>
1630
+ </div>
1631
+ </form>
1632
+ </div>
1633
+ </section>
1634
+ """
1635
+ return _approval_page_shell("Approval Required", body)
1636
+
1637
+
1638
+ def _approval_complete_html(approval: dict, decision: str) -> str:
1639
+ body = f"""
1640
+ <section class="panel">
1641
+ <div class="panel-header">
1642
+ <h1>Decision Submitted</h1>
1643
+ <p class="subtitle">The electronic approval decision has been recorded.</p>
1644
+ </div>
1645
+ <div class="content">
1646
+ <div class="alert alert-ok">{_escape_html(decision)} recorded for {_escape_html(approval.get("source_title") or approval.get("source_id"))}.</div>
1647
+ {_approval_meta_html(approval)}
1648
+ </div>
1649
+ </section>
1650
+ """
1651
+ return _approval_page_shell("Decision Submitted", body)
1652
+
1653
+
1654
+ def _legacy_approval_form_html(approval: dict, token: str, error: str | None = None) -> str:
1655
+ error_block = f"<p style='color:#b91c1c'>{_escape_html(error)}</p>" if error else ""
1656
+ title = _escape_html(approval.get("source_title") or approval.get("source_id") or "Approval")
1657
+ stage = _escape_html(approval.get("stage") or "Approval")
1658
+ approver = _escape_html(approval.get("assigned_user_name") or approval.get("assigned_user_id"))
1659
+ return f"""
1660
+ <!doctype html>
1661
+ <html>
1662
+ <head><title>Approval Required</title></head>
1663
+ <body>
1664
+ {error_block}
1665
+ <p><strong>Record:</strong> {title}</p>
1666
+ <p><strong>Stage:</strong> {stage}</p>
1667
+ <p><strong>Approver:</strong> {approver}</p>
1668
+ </body>
1669
+ </html>
1670
+ """
1671
+
1672
+
1673
+ def _approval_user_matches(approval: dict, user: dict | None) -> bool:
1674
+ if not user:
1675
+ return False
1676
+ user_id = user.get("sub") or user.get("user_id")
1677
+ email = str(user.get("email") or "").strip().lower()
1678
+ assigned_id = str(approval.get("assigned_user_id") or "").strip()
1679
+ assigned_email = str(approval.get("assigned_user_email") or "").strip().lower()
1680
+ return bool((assigned_id and user_id == assigned_id) or (assigned_email and email == assigned_email))
1681
+
1682
+
1683
+ def _approval_token_error(approval: dict | None, token: str) -> str | None:
1684
+ if not approval or approval.get("approval_token") != token:
1685
+ return "Invalid or expired approval link."
1686
+ terminal_status = str(approval.get("status") or approval.get("decision") or "").strip().title()
1687
+ if terminal_status in {"Approved", "Rejected"}:
1688
+ return "Approval has already been decided."
1689
+ expires_at = _as_aware_utc(approval.get("approval_token_expires_at"))
1690
+ if expires_at and expires_at <= utc_now():
1691
+ return "This approval link has expired. Request a new approval notification."
1692
+ return None
1693
+
1694
+
1695
+ def _approval_user_from_request(request: Request | None) -> dict | None:
1696
+ if request is None:
1697
+ return None
1698
+ auth_header = request.headers.get("authorization", "")
1699
+ token = None
1700
+ if auth_header.lower().startswith("bearer "):
1701
+ token = auth_header.split(" ", 1)[1].strip()
1702
+ if not token:
1703
+ token = request.cookies.get(APPROVAL_ACCESS_COOKIE_NAME)
1704
+ if not token:
1705
+ return None
1706
+ try:
1707
+ return decode_access_token(token)
1708
+ except JWTError:
1709
+ return None
1710
+
1711
+
1712
+ async def _approval_login_user(db, email: str | None, password: str | None) -> dict | None:
1713
+ if not email or not password:
1714
+ return None
1715
+ result = await get_by_email(db, email)
1716
+ user = result[0] if result else None
1717
+ if not user or not user.get("is_active", False) or user.get("mfa_enabled"):
1718
+ return None
1719
+ candidate_hash = user.get("password")
1720
+ if not candidate_hash:
1721
+ return None
1722
+ try:
1723
+ if not verify_password(password, candidate_hash):
1724
+ return None
1725
+ except Exception:
1726
+ return None
1727
+ return user
1728
+
1729
+
1730
+ def _set_approval_access_cookie(response: HTMLResponse, user: dict) -> None:
1731
+ token = create_access_token(user["user_id"], user.get("email") or "", user.get("role") or "", [])
1732
+ response.set_cookie(
1733
+ key=APPROVAL_ACCESS_COOKIE_NAME,
1734
+ value=token,
1735
+ httponly=True,
1736
+ secure=False,
1737
+ samesite="lax",
1738
+ max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
1739
+ )
1740
+
1741
+
1742
+ async def _issue_login_tokens(db, response: Response, user: dict) -> TokenResponse:
1743
+ access = await _get_permissions(db, user["role"])
1744
+ access_token = create_access_token(user["user_id"], user["email"], user["role"], access)
1745
+ refresh_token = create_refresh_token(user["user_id"])
1746
+ await save_refresh_token(refresh_token)
1747
+ response.set_cookie(value=refresh_token, **COOKIE_OPTIONS)
1748
+ return TokenResponse(access_token=access_token, refresh_token=refresh_token)
1749
+
1750
+
1751
+ async def _verify_mfa_or_backup_code(db, user: dict, code: str) -> bool:
1752
+ secret = user.get("mfa_secret")
1753
+ if secret and _verify_totp(secret, code):
1754
+ return True
1755
+
1756
+ code_hash = _hash_backup_code(code)
1757
+ backup_hashes = list(user.get("mfa_backup_code_hashes") or [])
1758
+ if code_hash in backup_hashes:
1759
+ backup_hashes.remove(code_hash)
1760
+ await db["users"].update_one(
1761
+ {"user_id": user["user_id"]},
1762
+ {"$set": {"mfa_backup_code_hashes": backup_hashes, "mfa_backup_code_used_at": utc_now()}},
1763
+ )
1764
+ return True
1765
+ return False
1766
+
1767
+
1768
+ async def _get_permissions(db, role_id: str) -> list:
1769
+ """
1770
+ Look up a role by its ID and resolve its permissions.
1771
+ Raises HTTP 500 if the role document is missing.
1772
+ """
1773
+ role = await get_role_by_id(db, role_id)
1774
+ if role is None:
1775
+ role = await get_role_by_name(db, role_id)
1776
+
1777
+ if role is None:
1778
+ raise HTTPException(
1779
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1780
+ detail=f"Role '{role_id}' not found — check that the roles collection is seeded",
1781
+ )
1782
+
1783
+ permissions = await asyncio.gather(*[
1784
+ get_by_permission_id(db, p) for p in role["permissions"]
1785
+ ])
1786
+ return [
1787
+ p
1788
+ for sublist in permissions
1789
+ for p in (sublist if isinstance(sublist, list) else [sublist])
1790
+ if p
1791
+ ]
1792
+
1793
+
1794
+ # ---------------------------------------------------------------------------
1795
+ # POST /auth/login
1796
+ # ---------------------------------------------------------------------------
1797
+ @router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
1798
+ async def login(
1799
+ request: Request,
1800
+ response: Response,
1801
+ db: AsyncIOMotorDatabase = Depends(get_database),
1802
+ ):
1803
+ """
1804
+ Authenticate with email + password.
1805
+ Accepts both JSON body and form-urlencoded (OAuth2 compatibility).
1806
+ Returns a short-lived access token in the body and sets an
1807
+ httpOnly refresh token cookie.
1808
+ """
1809
+ content_type = request.headers.get("content-type", "")
1810
+ email = None
1811
+ pwd = None
1812
+
1813
+ if "application/x-www-form-urlencoded" in content_type:
1814
+ form_data = await request.form()
1815
+ email = form_data.get("username") or form_data.get("email")
1816
+ pwd = form_data.get("password")
1817
+ elif "application/json" in content_type:
1818
+ try:
1819
+ body_data = await request.json()
1820
+ email = body_data.get("email")
1821
+ pwd = body_data.get("password")
1822
+ except Exception:
1823
+ pass
1824
+
1825
+ if not email or not pwd:
1826
+ raise HTTPException(
1827
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
1828
+ detail="Email and password are required",
1829
+ )
1830
+
1831
+ result = await get_by_email(db, email)
1832
+ user = result[0] if result else None
1833
+ now = utc_now()
1834
+ locked_until = _as_aware_utc(user.get("locked_until") if user else None)
1835
+ if locked_until and locked_until > now:
1836
+ await _record_login_event(
1837
+ db,
1838
+ email=email,
1839
+ status_value="locked",
1840
+ request=request,
1841
+ user_id=user.get("user_id"),
1842
+ reason=f"Account locked until {locked_until.isoformat()}",
1843
+ )
1844
+ raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="Account is locked")
1845
+ if user and not user.get("is_active", False):
1846
+ await _record_login_event(
1847
+ db,
1848
+ email=email,
1849
+ status_value="failed",
1850
+ request=request,
1851
+ user_id=user.get("user_id"),
1852
+ reason="Inactive account",
1853
+ )
1854
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
1855
+
1856
+ # Always run bcrypt to prevent timing-based user enumeration
1857
+ _DUMMY = "$2b$12$eImiTXuWVxfM37uY4JANjQ==" # invalid hash — compare will always fail
1858
+ candidate_hash = user.get("password") if user else _DUMMY
1859
+ if not candidate_hash:
1860
+ candidate_hash = _DUMMY
1861
+ try:
1862
+ password_ok = verify_password(pwd, candidate_hash)
1863
+ except Exception:
1864
+ password_ok = False
1865
+
1866
+ if not user or not password_ok:
1867
+ if user:
1868
+ failed_count = int(user.get("failed_login_count") or 0) + 1
1869
+ fields = {
1870
+ "failed_login_count": failed_count,
1871
+ "last_failed_login_at": now,
1872
+ }
1873
+ reason = "Invalid credentials"
1874
+ if failed_count >= settings.PART11_FAILED_LOGIN_LIMIT:
1875
+ locked_until = now + timedelta(minutes=settings.PART11_LOCKOUT_MINUTES)
1876
+ fields["locked_until"] = locked_until
1877
+ reason = f"Account locked after {failed_count} failed login attempts"
1878
+ await db["users"].update_one({"user_id": user["user_id"]}, {"$set": fields})
1879
+ await _record_login_event(
1880
+ db,
1881
+ email=email,
1882
+ status_value="failed",
1883
+ request=request,
1884
+ user_id=user.get("user_id"),
1885
+ reason=reason,
1886
+ )
1887
+ else:
1888
+ await _record_login_event(
1889
+ db,
1890
+ email=email,
1891
+ status_value="failed",
1892
+ request=request,
1893
+ reason="Unknown user",
1894
+ )
1895
+ raise HTTPException(
1896
+ status_code=status.HTTP_401_UNAUTHORIZED,
1897
+ detail="Invalid credentials",
1898
+ )
1899
+ await db["users"].update_one(
1900
+ {"user_id": user["user_id"]},
1901
+ {"$set": {"failed_login_count": 0, "locked_until": None, "last_login_at": now}},
1902
+ )
1903
+ await _record_login_event(
1904
+ db,
1905
+ email=email,
1906
+ status_value="success",
1907
+ request=request,
1908
+ user_id=user.get("user_id"),
1909
+ )
1910
+ if user.get("mfa_enabled"):
1911
+ challenge_token = create_mfa_challenge_token(user["user_id"])
1912
+ await _record_login_event(
1913
+ db,
1914
+ email=email,
1915
+ status_value="mfa_required",
1916
+ request=request,
1917
+ user_id=user.get("user_id"),
1918
+ )
1919
+ return TokenResponse(mfa_required=True, mfa_challenge_token=challenge_token)
1920
+
1921
+ return await _issue_login_tokens(db, response, user)
1922
+
1923
+
1924
+ # ---------------------------------------------------------------------------
1925
+ # POST /auth/refresh
1926
+ # ---------------------------------------------------------------------------
1927
+ @router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
1928
+ async def refresh(
1929
+ response: Response,
1930
+ body: RefreshTokenRequest | None = None,
1931
+ refresh_token: str | None = Cookie(default=None, alias=settings.REFRESH_COOKIE_NAME),
1932
+ db: AsyncIOMotorDatabase = Depends(get_database),
1933
+ ):
1934
+ token = body.refresh_token if body and body.refresh_token else refresh_token
1935
+ if not token:
1936
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Refresh token missing")
1937
+
1938
+ exists = await token_exists(token)
1939
+ if not exists:
1940
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Refresh token revoked")
1941
+
1942
+ try:
1943
+ payload = decode_refresh_token(token)
1944
+ except JWTError as exc:
1945
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
1946
+
1947
+ user = await get_by_id(db, payload["sub"])
1948
+
1949
+ if not user:
1950
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found")
1951
+
1952
+ access = await _get_permissions(db, user["role"])
1953
+
1954
+ access_token = create_access_token(user["user_id"], user["email"], user["role"], access)
1955
+ refresh_token = create_refresh_token(user["user_id"])
1956
+ await save_refresh_token(refresh_token)
1957
+ response.set_cookie(value=refresh_token, **COOKIE_OPTIONS)
1958
+ return TokenResponse(access_token=access_token, refresh_token=refresh_token)
1959
+
1960
+
1961
+ @router.post("/mfa/verify", response_model=TokenResponse)
1962
+ async def verify_mfa_challenge(
1963
+ body: MfaVerifyRequest,
1964
+ request: Request,
1965
+ response: Response,
1966
+ db: AsyncIOMotorDatabase = Depends(get_database),
1967
+ ):
1968
+ try:
1969
+ payload = decode_mfa_challenge_token(body.mfa_challenge_token)
1970
+ except JWTError as exc:
1971
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
1972
+
1973
+ user = await get_by_id(db, payload["sub"])
1974
+ if not user or not user.get("mfa_enabled"):
1975
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="MFA is not enabled")
1976
+
1977
+ if not await _verify_mfa_or_backup_code(db, user, body.code):
1978
+ await _record_login_event(
1979
+ db,
1980
+ email=user.get("email") or "",
1981
+ status_value="mfa_failed",
1982
+ request=request,
1983
+ user_id=user.get("user_id"),
1984
+ reason="Invalid MFA code",
1985
+ )
1986
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code")
1987
+
1988
+ await db["users"].update_one({"user_id": user["user_id"]}, {"$set": {"mfa_last_verified_at": utc_now()}})
1989
+ await _record_login_event(
1990
+ db,
1991
+ email=user.get("email") or "",
1992
+ status_value="mfa_success",
1993
+ request=request,
1994
+ user_id=user.get("user_id"),
1995
+ )
1996
+ return await _issue_login_tokens(db, response, user)
1997
+
1998
+
1999
+ @router.post("/mfa/enroll", response_model=MfaEnrollResponse)
2000
+ async def enroll_mfa(
2001
+ body: MfaEnrollRequest,
2002
+ db: AsyncIOMotorDatabase = Depends(get_database),
2003
+ current_user: dict = Depends(get_current_user),
2004
+ ):
2005
+ user_id = _current_user_id(current_user)
2006
+ user = await get_by_id(db, user_id)
2007
+ if not user:
2008
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
2009
+ if not verify_password(body.password, user.get("password") or ""):
2010
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password invalid")
2011
+
2012
+ secret = _new_mfa_secret()
2013
+ backup_codes = _new_backup_codes()
2014
+ await db["users"].update_one(
2015
+ {"user_id": user_id},
2016
+ {
2017
+ "$set": {
2018
+ "mfa_secret": secret,
2019
+ "mfa_enabled": False,
2020
+ "mfa_enrolled_at": utc_now(),
2021
+ "mfa_backup_code_hashes": [_hash_backup_code(code) for code in backup_codes],
2022
+ }
2023
+ },
2024
+ )
2025
+ next_user = {**user, "mfa_secret": secret}
2026
+ return MfaEnrollResponse(secret=secret, otpauth_uri=_otpauth_uri(next_user, secret), backup_codes=backup_codes)
2027
+
2028
+
2029
+ @router.post("/mfa/enable")
2030
+ async def enable_mfa(
2031
+ body: MfaEnableRequest,
2032
+ db: AsyncIOMotorDatabase = Depends(get_database),
2033
+ current_user: dict = Depends(get_current_user),
2034
+ ):
2035
+ user_id = _current_user_id(current_user)
2036
+ user = await get_by_id(db, user_id)
2037
+ if not user or not user.get("mfa_secret"):
2038
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MFA enrollment is not started")
2039
+ if not _verify_totp(user["mfa_secret"], body.code):
2040
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code")
2041
+ await db["users"].update_one(
2042
+ {"user_id": user_id},
2043
+ {"$set": {"mfa_enabled": True, "mfa_enabled_at": utc_now(), "mfa_last_verified_at": utc_now()}},
2044
+ )
2045
+ return {"mfa_enabled": True}
2046
+
2047
+
2048
+ @router.post("/mfa/disable")
2049
+ async def disable_mfa(
2050
+ body: MfaDisableRequest,
2051
+ db: AsyncIOMotorDatabase = Depends(get_database),
2052
+ current_user: dict = Depends(get_current_user),
2053
+ ):
2054
+ user_id = _current_user_id(current_user)
2055
+ user = await get_by_id(db, user_id)
2056
+ if not user:
2057
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
2058
+ if not verify_password(body.password, user.get("password") or ""):
2059
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password invalid")
2060
+ if user.get("mfa_enabled") and not await _verify_mfa_or_backup_code(db, user, body.code):
2061
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code")
2062
+ await db["users"].update_one(
2063
+ {"user_id": user_id},
2064
+ {
2065
+ "$set": {
2066
+ "mfa_enabled": False,
2067
+ "mfa_disabled_at": utc_now(),
2068
+ "mfa_secret": None,
2069
+ "mfa_backup_code_hashes": [],
2070
+ }
2071
+ },
2072
+ )
2073
+ return {"mfa_enabled": False}
2074
+
2075
+
2076
+ @router.get("/mfa/status")
2077
+ async def mfa_status(
2078
+ current_user: dict = Depends(get_current_user),
2079
+ db: AsyncIOMotorDatabase = Depends(get_database),
2080
+ ):
2081
+ user = await get_by_id(db, _current_user_id(current_user))
2082
+ return {
2083
+ "mfa_enabled": bool(user and user.get("mfa_enabled")),
2084
+ "mfa_enrolled": bool(user and user.get("mfa_secret")),
2085
+ "backup_codes_remaining": len(user.get("mfa_backup_code_hashes") or []) if user else 0,
2086
+ }
2087
+
2088
+
2089
+ # ---------------------------------------------------------------------------
2090
+ # POST /auth/logout
2091
+ # ---------------------------------------------------------------------------
2092
+ @router.post("/logout", status_code=status.HTTP_200_OK)
2093
+ async def logout(
2094
+ response: Response,
2095
+ request: Request,
2096
+ refresh_token: str | None = Cookie(default=None, alias=settings.REFRESH_COOKIE_NAME),
2097
+ db: AsyncIOMotorDatabase = Depends(get_database),
2098
+ ):
2099
+ """
2100
+ Revoke the refresh token and clear the cookie.
2101
+ """
2102
+ if refresh_token:
2103
+ await revoke_refresh_token(refresh_token)
2104
+ await _record_login_event(db, email="", status_value="logout", request=request)
2105
+
2106
+ response.delete_cookie(
2107
+ key=settings.REFRESH_COOKIE_NAME,
2108
+ path="/",
2109
+ httponly=True,
2110
+ samesite="strict",
2111
+ )
2112
+ return {"message": "Logged out"}
2113
+
2114
+
2115
+ @router.post(
2116
+ "/electronic-signatures",
2117
+ response_model=ElectronicSignatureResponse,
2118
+ status_code=status.HTTP_201_CREATED,
2119
+ )
2120
+ async def create_electronic_signature(
2121
+ body: ElectronicSignatureRequest,
2122
+ db: AsyncIOMotorDatabase = Depends(get_database),
2123
+ current_user: dict = Depends(get_current_user),
2124
+ ):
2125
+ user_id = _current_user_id(current_user)
2126
+ user = await get_by_id(db, user_id)
2127
+ if not user:
2128
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
2129
+
2130
+ candidate_hash = user.get("password")
2131
+ if not candidate_hash or not verify_password(body.password, candidate_hash):
2132
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Electronic signature password invalid")
2133
+
2134
+ record = await _find_signed_record(db, body.collection, body.record_id)
2135
+ if not record:
2136
+ raise HTTPException(
2137
+ status_code=status.HTTP_404_NOT_FOUND,
2138
+ detail=f"Record '{body.record_id}' not found in collection '{body.collection}'",
2139
+ )
2140
+
2141
+ record_hash = signature_payload_hash(record)
2142
+ signed_at = utc_now()
2143
+ signer = {
2144
+ "user_id": user_id,
2145
+ "email": user.get("email") or current_user.get("email"),
2146
+ "role": user.get("role") or current_user.get("role"),
2147
+ "name": user.get("name"),
2148
+ }
2149
+ reauthentication = {
2150
+ "method": "password",
2151
+ "status": "verified",
2152
+ "verified_at": signed_at,
2153
+ "verified_user_id": user_id,
2154
+ }
2155
+ signature_payload = {
2156
+ "signature_id": f"esign-{uuid4().hex}",
2157
+ "signer": signer,
2158
+ "meaning": body.meaning,
2159
+ "timestamp": signed_at,
2160
+ "collection": body.collection,
2161
+ "record_id": body.record_id,
2162
+ "action": body.action,
2163
+ "record_hash": record_hash,
2164
+ "reauthentication": reauthentication,
2165
+ }
2166
+ signature_hash = signature_payload_hash(signature_payload)
2167
+ signature = {
2168
+ "signature_id": signature_payload["signature_id"],
2169
+ "standard": "21 CFR Part 11",
2170
+ "standards": compliance_readiness()["standards"],
2171
+ **compliance_metadata(),
2172
+ "user_id": user_id,
2173
+ "email": signer["email"],
2174
+ "role": signer["role"],
2175
+ "signer": signer,
2176
+ "collection": body.collection,
2177
+ "record_id": body.record_id,
2178
+ "action": body.action,
2179
+ "meaning": body.meaning,
2180
+ "record_hash": record_hash,
2181
+ "reauthentication": reauthentication,
2182
+ "signature_payload": signature_payload,
2183
+ "signature_hash": signature_hash,
2184
+ "signed_at": signed_at,
2185
+ }
2186
+ await db[SIGNATURE_COLLECTION].insert_one(signature)
2187
+ await _link_signature_to_entity(db, signature)
2188
+ return ElectronicSignatureResponse(
2189
+ signature_id=signature["signature_id"],
2190
+ user_id=signature["user_id"],
2191
+ collection=signature["collection"],
2192
+ record_id=signature["record_id"],
2193
+ action=signature["action"],
2194
+ meaning=signature["meaning"],
2195
+ record_hash=record_hash,
2196
+ signature_hash=signature_hash,
2197
+ signed_at=signed_at.isoformat(),
2198
+ )
2199
+
2200
+
2201
+ @router.post("/audit-trail", status_code=status.HTTP_201_CREATED)
2202
+ async def create_manual_audit_trail_entry(
2203
+ body: ManualAuditTrailRequest,
2204
+ db: AsyncIOMotorDatabase = Depends(get_database),
2205
+ current_user: dict = Depends(get_current_user),
2206
+ ):
2207
+ now = utc_now()
2208
+ payload = {
2209
+ "manual_audit_event": True,
2210
+ "reason": body.reason,
2211
+ "detail": body.detail,
2212
+ "created_at": now,
2213
+ "created_by": _current_user_id(current_user),
2214
+ }
2215
+ await append_audit_entry(
2216
+ db,
2217
+ collection=body.collection,
2218
+ action=body.action,
2219
+ record_id=body.record_id,
2220
+ after=payload,
2221
+ result={"source": "manual_audit_entry"},
2222
+ )
2223
+ entry = await db[AUDIT_COLLECTION].find_one(
2224
+ {"collection": body.collection, "record_id": body.record_id, "action": body.action},
2225
+ {"_id": 0},
2226
+ sort=[("_id", -1)],
2227
+ )
2228
+ return _bson_safe(entry or payload)
2229
+
2230
+
2231
+ @router.get("/audit-trail")
2232
+ async def list_audit_trail(
2233
+ q: str | None = Query(default=None, min_length=1),
2234
+ collection: str | None = Query(default=None),
2235
+ record_id: str | None = Query(default=None),
2236
+ page: int = Query(default=1, ge=1),
2237
+ page_size: int = Query(default=50, ge=1, le=500),
2238
+ db: AsyncIOMotorDatabase = Depends(get_database),
2239
+ _: dict = Depends(get_current_user),
2240
+ ):
2241
+ elasticsearch_result = await _search_audit_trail_elasticsearch(
2242
+ q=q,
2243
+ collection=collection,
2244
+ record_id=record_id,
2245
+ page=page,
2246
+ page_size=page_size,
2247
+ )
2248
+ if elasticsearch_result is not None:
2249
+ return _bson_safe(elasticsearch_result)
2250
+
2251
+ query: dict = {}
2252
+ if collection:
2253
+ query["collection"] = collection
2254
+ if record_id:
2255
+ query["record_id"] = record_id
2256
+ if q:
2257
+ query["$or"] = [
2258
+ {"audit_id": {"$regex": q, "$options": "i"}},
2259
+ {"collection": {"$regex": q, "$options": "i"}},
2260
+ {"record_id": {"$regex": q, "$options": "i"}},
2261
+ {"action": {"$regex": q, "$options": "i"}},
2262
+ {"change_reason": {"$regex": q, "$options": "i"}},
2263
+ {"actor.user_id": {"$regex": q, "$options": "i"}},
2264
+ {"actor.email": {"$regex": q, "$options": "i"}},
2265
+ ]
2266
+ total = await db[AUDIT_COLLECTION].count_documents(query)
2267
+ cursor = (
2268
+ db[AUDIT_COLLECTION]
2269
+ .find(query, {"_id": 0})
2270
+ .sort("_id", -1)
2271
+ .skip((page - 1) * page_size)
2272
+ .limit(page_size)
2273
+ )
2274
+ return _bson_safe({
2275
+ "total": total,
2276
+ "page": page,
2277
+ "page_size": page_size,
2278
+ "source": "mongodb",
2279
+ "results": [entry async for entry in cursor],
2280
+ })
2281
+
2282
+
2283
+ @router.get("/audit-trail/verify")
2284
+ async def verify_audit_trail(
2285
+ db: AsyncIOMotorDatabase = Depends(get_database),
2286
+ _: dict = Depends(get_current_user),
2287
+ ):
2288
+ cursor = db[AUDIT_COLLECTION].find({}).sort("_id", 1)
2289
+ previous_hash = None
2290
+ checked = 0
2291
+ async for entry in cursor:
2292
+ checked += 1
2293
+ stored_hash = entry.get("entry_hash")
2294
+ payload = {key: value for key, value in entry.items() if key not in {"_id", "entry_hash"}}
2295
+ expected_hash = sha256_hex(payload)
2296
+ if stored_hash != expected_hash or entry.get("previous_hash") != previous_hash:
2297
+ return {
2298
+ "valid": False,
2299
+ "checked": checked,
2300
+ "audit_id": entry.get("audit_id"),
2301
+ "reason": "Audit hash chain mismatch",
2302
+ }
2303
+ previous_hash = stored_hash
2304
+ return {"valid": True, "checked": checked, "last_hash": previous_hash}
2305
+
2306
+
2307
+ @router.put("/retention-policies/{collection}")
2308
+ async def upsert_retention_policy(
2309
+ collection: str,
2310
+ body: RetentionPolicyRequest,
2311
+ db: AsyncIOMotorDatabase = Depends(get_database),
2312
+ current_user: dict = Depends(get_current_user),
2313
+ ):
2314
+ now = utc_now()
2315
+ policy = {
2316
+ "collection": collection,
2317
+ "minimum_retention_days": body.minimum_retention_days,
2318
+ "allow_delete_before_retention_expiry": body.allow_delete_before_retention_expiry,
2319
+ "enabled": body.enabled,
2320
+ "reason": body.reason,
2321
+ "updated_at": now,
2322
+ "updated_by": _current_user_id(current_user),
2323
+ }
2324
+ policy.setdefault("created_at", now)
2325
+ await db[RETENTION_COLLECTION].update_one(
2326
+ {"collection": collection},
2327
+ {
2328
+ "$set": policy,
2329
+ "$setOnInsert": {"policy_id": f"retention-{uuid4().hex}", "created_at": now},
2330
+ },
2331
+ upsert=True,
2332
+ )
2333
+ return policy
2334
+
2335
+
2336
+ @router.get("/retention-policies")
2337
+ async def list_retention_policies(
2338
+ db: AsyncIOMotorDatabase = Depends(get_database),
2339
+ _: dict = Depends(get_current_user),
2340
+ ):
2341
+ cursor = db[RETENTION_COLLECTION].find({}, {"_id": 0}).sort("collection", 1)
2342
+ return [policy async for policy in cursor]
2343
+
2344
+
2345
+ @router.get("/records/{collection}/{record_id}/compliance-bundle")
2346
+ async def export_compliance_bundle(
2347
+ collection: str,
2348
+ record_id: str,
2349
+ db: AsyncIOMotorDatabase = Depends(get_database),
2350
+ current_user: dict = Depends(get_current_user),
2351
+ ):
2352
+ record = await _find_signed_record(db, collection, record_id)
2353
+ if not record:
2354
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
2355
+
2356
+ audit_cursor = db[AUDIT_COLLECTION].find({"collection": collection, "record_id": record_id}, {"_id": 0}).sort("_id", 1)
2357
+ version_cursor = (
2358
+ db[VERSION_COLLECTION]
2359
+ .find({"collection": collection, "record_id": record_id}, {"_id": 0})
2360
+ .sort("version_number", 1)
2361
+ )
2362
+ signature_cursor = (
2363
+ db[SIGNATURE_COLLECTION]
2364
+ .find({"collection": collection, "record_id": record_id}, {"_id": 0})
2365
+ .sort("signed_at", 1)
2366
+ )
2367
+ bundle = {
2368
+ "standard": "21 CFR Part 11",
2369
+ "standards": compliance_readiness()["standards"],
2370
+ "export_id": f"export-{uuid4().hex}",
2371
+ "exported_at": utc_now(),
2372
+ "exported_by": {
2373
+ "user_id": _current_user_id(current_user),
2374
+ "email": current_user.get("email"),
2375
+ "role": current_user.get("role"),
2376
+ },
2377
+ "collection": collection,
2378
+ "record_id": record_id,
2379
+ "record_hash": signature_payload_hash(record),
2380
+ "record": record,
2381
+ "audit_trail": [entry async for entry in audit_cursor],
2382
+ "versions": [entry async for entry in version_cursor],
2383
+ "electronic_signatures": [entry async for entry in signature_cursor],
2384
+ "change_controls": await db[CHANGE_CONTROL_COLLECTION].find({"affected_entities.entity_id": record_id}, {"_id": 0}).sort("_id", -1).to_list(length=100),
2385
+ }
2386
+ encoded = _bson_safe(bundle)
2387
+ encoded["bundle_hash"] = sha256_hex(encoded)
2388
+ await db[EXPORT_COLLECTION].insert_one(
2389
+ {
2390
+ "export_id": encoded["export_id"],
2391
+ "standard": "21 CFR Part 11",
2392
+ "standards": compliance_readiness()["standards"],
2393
+ "collection": collection,
2394
+ "record_id": record_id,
2395
+ "bundle_hash": encoded["bundle_hash"],
2396
+ "exported_at": utc_now(),
2397
+ "exported_by": encoded["exported_by"],
2398
+ }
2399
+ )
2400
+ return encoded
2401
+
2402
+
2403
+
2404
+ @router.get("/compliance/standards")
2405
+ async def get_compliance_standards(
2406
+ _: dict = Depends(get_current_user),
2407
+ ):
2408
+ return compliance_readiness()
2409
+
2410
+
2411
+
2412
+ def _risk_level(rpn: int) -> str:
2413
+ if rpn >= 75:
2414
+ return "High"
2415
+ if rpn >= 35:
2416
+ return "Medium"
2417
+ return "Low"
2418
+
2419
+
2420
+ def _compliance_proof_risk_rows() -> list[dict]:
2421
+ candidates = [
2422
+ {
2423
+ "title": "Unauthorized access to regulated records",
2424
+ "process": "Access Control",
2425
+ "hazard": "Unauthorized user can create, change, approve, or export regulated records.",
2426
+ "impact": "Data integrity and Part 11 control failure.",
2427
+ "severity": 5,
2428
+ "occurrence": 2,
2429
+ "detectability": 3,
2430
+ "mitigation": "RBAC, authenticated routes, MFA endpoints, electronic signatures, access review records.",
2431
+ },
2432
+ {
2433
+ "title": "Audit trail tampering",
2434
+ "process": "Audit Trail",
2435
+ "hazard": "Audit rows are changed or removed without detection.",
2436
+ "impact": "Loss of complete and trustworthy regulated history.",
2437
+ "severity": 5,
2438
+ "occurrence": 1,
2439
+ "detectability": 2,
2440
+ "mitigation": "Hash-chained audit entries, verification endpoint, server-side Mongo wrapper, Elasticsearch sync for searchability.",
2441
+ },
2442
+ {
2443
+ "title": "Electronic signature repudiation",
2444
+ "process": "Electronic Signatures",
2445
+ "hazard": "Signer can deny approval or signature meaning cannot be reconstructed.",
2446
+ "impact": "Approval chain and release records become non-compliant.",
2447
+ "severity": 4,
2448
+ "occurrence": 2,
2449
+ "detectability": 2,
2450
+ "mitigation": "Password re-authentication, signer identity, meaning, timestamp, record hash, and signature hash.",
2451
+ },
2452
+ {
2453
+ "title": "Record loss or stale local state",
2454
+ "process": "Record Persistence",
2455
+ "hazard": "Regulated UI state diverges from backend persistence or disappears after refresh.",
2456
+ "impact": "Incomplete or stale regulated records.",
2457
+ "severity": 4,
2458
+ "occurrence": 3,
2459
+ "detectability": 3,
2460
+ "mitigation": "Backend persistence, Redis-backed session state, no regulated localStorage persistence, compliance bundle export.",
2461
+ },
2462
+ {
2463
+ "title": "Incorrect GraphRAG answer used operationally",
2464
+ "process": "Compliant Intelligence",
2465
+ "hazard": "Unsupported answer is used as operational evidence.",
2466
+ "impact": "Wrong operational or quality decision.",
2467
+ "severity": 4,
2468
+ "occurrence": 3,
2469
+ "detectability": 4,
2470
+ "mitigation": "Source details, approved question set, explicit evidence gaps, no autonomous regulated record changes.",
2471
+ },
2472
+ {
2473
+ "title": "Canvas and graph relationships out of sync",
2474
+ "process": "Factory Ontology Canvas",
2475
+ "hazard": "Canvas links do not match graph links after edit or refresh.",
2476
+ "impact": "Incorrect material flow, audit, or workflow reasoning.",
2477
+ "severity": 3,
2478
+ "occurrence": 3,
2479
+ "detectability": 3,
2480
+ "mitigation": "Backend canvas/graph sync APIs, stable node IDs, canvas restore and propagation tests.",
2481
+ },
2482
+ ]
2483
+ rows: list[dict] = []
2484
+ for item in candidates:
2485
+ rpn = item["severity"] * item["occurrence"] * item["detectability"]
2486
+ rows.append(
2487
+ {
2488
+ **item,
2489
+ "risk_priority_number": rpn,
2490
+ "risk_level": _risk_level(rpn),
2491
+ "status": "Open" if rpn >= 35 else "Controlled",
2492
+ "disposition": "Requires QA disposition" if rpn >= 35 else "Acceptable with listed controls",
2493
+ }
2494
+ )
2495
+ return rows
2496
+
2497
+
2498
+ def _default_user_requirements() -> list[dict]:
2499
+ return [
2500
+ {
2501
+ "requirement_id": "URS-001",
2502
+ "title": "Unique user authentication",
2503
+ "description": "The system shall require unique authenticated users for regulated actions.",
2504
+ "category": "Access Control",
2505
+ "priority": "Critical",
2506
+ "source": "21 CFR Part 11 / User Requirement Specification",
2507
+ "acceptance_criteria": ["Login is required", "Refresh sessions are controlled", "User identity is captured in regulated records"],
2508
+ "linked_controls": ["unique_user_identity", "role_based_access_control"],
2509
+ "linked_tests": ["tests/test_live_smoke_integration.py::test_oq_live_authenticated_api_smoke"],
2510
+ },
2511
+ {
2512
+ "requirement_id": "URS-002",
2513
+ "title": "Electronic signature control",
2514
+ "description": "The system shall bind electronic signatures to signer identity, meaning, timestamp, record hash, and signed record.",
2515
+ "category": "Electronic Signatures",
2516
+ "priority": "Critical",
2517
+ "source": "21 CFR Part 11 / User Requirement Specification",
2518
+ "acceptance_criteria": ["Password re-authentication is required", "Signature hash is stored", "Record hash is linked"],
2519
+ "linked_controls": ["electronic_signature_reauthentication", "signature_record_hash_linking"],
2520
+ "linked_tests": ["tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke"],
2521
+ },
2522
+ {
2523
+ "requirement_id": "URS-003",
2524
+ "title": "Tamper-evident audit trail",
2525
+ "description": "The system shall create a complete, hash-chained audit trail for regulated create, update, delete, sign, export, and workflow actions.",
2526
+ "category": "Audit Trail",
2527
+ "priority": "Critical",
2528
+ "source": "21 CFR Part 11 / User Requirement Specification",
2529
+ "acceptance_criteria": ["Audit entries include actor/action/timestamp", "Audit hashes verify", "Before/after record hashes are retained"],
2530
+ "linked_controls": ["hash_chained_audit_trail", "audit_trail_verification", "record_version_history"],
2531
+ "linked_tests": ["tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke"],
2532
+ },
2533
+ {
2534
+ "requirement_id": "URS-004",
2535
+ "title": "Validation evidence and test execution",
2536
+ "description": "The system shall retain IQ, OQ, PQ, and compliant-intelligence execution evidence including failed tests.",
2537
+ "category": "Validation",
2538
+ "priority": "High",
2539
+ "source": "EU GMP Annex 11 / Annex 15 / User Requirement Specification",
2540
+ "acceptance_criteria": ["IQ/OQ/PQ reports are generated", "Failed tests are identified", "Evidence is hash recorded"],
2541
+ "linked_controls": ["record_version_history", "audit_trail_verification"],
2542
+ "linked_tests": [
2543
+ "tests/test_live_smoke_integration.py::test_iq_live_service_health_smoke",
2544
+ "tests/test_live_smoke_integration.py::test_oq_live_authenticated_api_smoke",
2545
+ "tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke",
2546
+ "tests/test_live_smoke_integration.py::test_gqa_live_agentos_smoke",
2547
+ ],
2548
+ },
2549
+ {
2550
+ "requirement_id": "URS-005",
2551
+ "title": "Traceability matrix",
2552
+ "description": "The system shall maintain traceability from user requirements to controls, risks, tests, and validation evidence.",
2553
+ "category": "Validation",
2554
+ "priority": "High",
2555
+ "source": "EU GMP Annex 15 / User Requirement Specification",
2556
+ "acceptance_criteria": ["Each requirement has coverage status", "Linked evidence is visible", "Coverage gaps are reported"],
2557
+ "linked_controls": ["audit_trail_verification", "record_version_history"],
2558
+ "linked_tests": ["tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke"],
2559
+ },
2560
+ {
2561
+ "requirement_id": "URS-006",
2562
+ "title": "Compliant intelligence guardrails",
2563
+ "description": "The system shall answer only approved compliant-intelligence questions from local graph evidence unless explicit web search is requested.",
2564
+ "category": "Compliant Intelligence",
2565
+ "priority": "High",
2566
+ "source": "User Requirement Specification",
2567
+ "acceptance_criteria": ["Approved questions are routed locally", "Unsupported questions do not create regulated records", "Answers include deterministic evidence"],
2568
+ "linked_controls": ["audit_trail_verification"],
2569
+ "linked_tests": ["tests/test_live_smoke_integration.py::test_gqa_live_agentos_smoke"],
2570
+ },
2571
+ ]
2572
+
2573
+
2574
+ def _requirement_input_to_row(requirement: UserRequirementInput) -> dict:
2575
+ row = requirement.model_dump(mode="python")
2576
+ row["requirement_id"] = row.get("requirement_id") or f"URS-{uuid4().hex[:8].upper()}"
2577
+ return row
2578
+
2579
+
2580
+ def _assess_user_requirement(row: dict, available_controls: set[str], available_tests: set[str], evidence_refs: list[str]) -> dict:
2581
+ linked_controls = list(row.get("linked_controls") or [])
2582
+ linked_tests = list(row.get("linked_tests") or [])
2583
+ control_hits = [item for item in linked_controls if item in available_controls]
2584
+ test_hits = [item for item in linked_tests if item in available_tests]
2585
+ gaps: list[str] = []
2586
+ if not linked_controls:
2587
+ gaps.append("No linked compliance control.")
2588
+ elif len(control_hits) != len(linked_controls):
2589
+ missing = sorted(set(linked_controls) - set(control_hits))
2590
+ gaps.append(f"Missing controls: {', '.join(missing)}")
2591
+ if not linked_tests:
2592
+ gaps.append("No linked verification test.")
2593
+ elif len(test_hits) != len(linked_tests):
2594
+ missing = sorted(set(linked_tests) - set(test_hits))
2595
+ gaps.append(f"Missing tests: {', '.join(missing)}")
2596
+ if not row.get("acceptance_criteria"):
2597
+ gaps.append("No acceptance criteria.")
2598
+
2599
+ status_value = "Satisfied" if not gaps else "Partially Covered" if control_hits or test_hits else "Gap"
2600
+ coverage = {
2601
+ "controls_required": len(linked_controls),
2602
+ "controls_covered": len(control_hits),
2603
+ "tests_required": len(linked_tests),
2604
+ "tests_covered": len(test_hits),
2605
+ "evidence_refs": list(dict.fromkeys([*(row.get("linked_evidence") or []), *evidence_refs])),
2606
+ }
2607
+ return {
2608
+ **row,
2609
+ "status": status_value,
2610
+ "coverage": coverage,
2611
+ "gaps": gaps,
2612
+ }
2613
+
2614
+
2615
+ async def _latest_validation_evidence_refs(db) -> list[str]:
2616
+ records = await db[VALIDATION_EVIDENCE_COLLECTION].find({}, {"_id": 0, "validation_evidence_id": 1, "title": 1}).sort("_id", -1).limit(20).to_list(length=20)
2617
+ refs: list[str] = []
2618
+ for record in records:
2619
+ refs.append(str(record.get("validation_evidence_id") or record.get("title")))
2620
+ return [item for item in refs if item]
2621
+
2622
+
2623
+ async def _latest_risk_refs(db) -> list[str]:
2624
+ records = await db[RISK_ASSESSMENT_COLLECTION].find({}, {"_id": 0, "risk_assessment_id": 1, "title": 1}).sort("_id", -1).limit(20).to_list(length=20)
2625
+ refs: list[str] = []
2626
+ for record in records:
2627
+ refs.append(str(record.get("risk_assessment_id") or record.get("title")))
2628
+ return [item for item in refs if item]
2629
+
2630
+
2631
+ def _traceability_rows(requirements: list[dict], risk_refs: list[str], evidence_refs: list[str]) -> list[dict]:
2632
+ rows: list[dict] = []
2633
+ for requirement in requirements:
2634
+ coverage = requirement.get("coverage") if isinstance(requirement.get("coverage"), dict) else {}
2635
+ rows.append(
2636
+ {
2637
+ "requirement_id": requirement.get("requirement_id"),
2638
+ "requirement": requirement.get("title"),
2639
+ "category": requirement.get("category"),
2640
+ "priority": requirement.get("priority"),
2641
+ "controls": requirement.get("linked_controls") or [],
2642
+ "risks": risk_refs,
2643
+ "tests": requirement.get("linked_tests") or [],
2644
+ "evidence": list(dict.fromkeys([*(coverage.get("evidence_refs") or []), *evidence_refs])),
2645
+ "status": requirement.get("status"),
2646
+ "gaps": requirement.get("gaps") or [],
2647
+ }
2648
+ )
2649
+ return rows
2650
+
2651
+
2652
+ @router.post("/compliance/risk-assessments/run")
2653
+ async def run_compliance_risk_assessment(
2654
+ db: AsyncIOMotorDatabase = Depends(get_database),
2655
+ current_user: dict = Depends(get_current_user),
2656
+ ):
2657
+ assessed_at = utc_now()
2658
+ assessed_by = _current_user_id(current_user)
2659
+ rows = _compliance_proof_risk_rows()
2660
+ payload = {
2661
+ "risk_assessment_id": f"risk-assessment-{uuid4().hex}",
2662
+ "title": "Pharma Compliance Proof Risk Assessment",
2663
+ "standard_id": "ich-q9-r1",
2664
+ "status": "Open" if any(row["risk_level"] != "Low" for row in rows) else "Controlled",
2665
+ "assessed_at": assessed_at,
2666
+ "assessed_by": assessed_by,
2667
+ "summary": {
2668
+ "total": len(rows),
2669
+ "high": sum(1 for row in rows if row["risk_level"] == "High"),
2670
+ "medium": sum(1 for row in rows if row["risk_level"] == "Medium"),
2671
+ "low": sum(1 for row in rows if row["risk_level"] == "Low"),
2672
+ "max_rpn": max(row["risk_priority_number"] for row in rows),
2673
+ },
2674
+ "rows": rows,
2675
+ }
2676
+ payload["record_hash"] = sha256_hex(payload)
2677
+ await db[RISK_ASSESSMENT_COLLECTION].insert_one(
2678
+ {
2679
+ **payload,
2680
+ "created_at": assessed_at,
2681
+ "updated_at": assessed_at,
2682
+ "created_by": assessed_by,
2683
+ "updated_by": assessed_by,
2684
+ "record_type": "risk_assessment_execution",
2685
+ }
2686
+ )
2687
+ return _bson_safe(payload)
2688
+
2689
+
2690
+ @router.get("/compliance/risk-assessments/latest")
2691
+ async def latest_compliance_risk_assessment(
2692
+ db: AsyncIOMotorDatabase = Depends(get_database),
2693
+ _: dict = Depends(get_current_user),
2694
+ ):
2695
+ record = await db[RISK_ASSESSMENT_COLLECTION].find_one(
2696
+ {"record_type": "risk_assessment_execution"},
2697
+ {"_id": 0},
2698
+ sort=[("_id", -1)],
2699
+ )
2700
+ return _bson_safe(record) if record else None
2701
+
2702
+
2703
+ @router.post("/compliance/risk-assessments", status_code=status.HTTP_201_CREATED)
2704
+ async def create_risk_assessment(
2705
+ body: RiskAssessmentRequest,
2706
+ db: AsyncIOMotorDatabase = Depends(get_database),
2707
+ current_user: dict = Depends(get_current_user),
2708
+ ):
2709
+ payload = body.model_dump(mode="python")
2710
+ payload["risk_priority_number"] = body.severity * body.occurrence * body.detectability
2711
+ payload["standard_id"] = "ich-q9-r1"
2712
+ return await _insert_gxp_record(db, RISK_ASSESSMENT_COLLECTION, "risk_assessment", payload, current_user)
2713
+
2714
+
2715
+ @router.get("/compliance/risk-assessments")
2716
+ async def list_risk_assessments(
2717
+ status_value: str | None = Query(default=None, alias="status"),
2718
+ page: int = Query(default=1, ge=1),
2719
+ page_size: int = Query(default=50, ge=1, le=500),
2720
+ db: AsyncIOMotorDatabase = Depends(get_database),
2721
+ _: dict = Depends(get_current_user),
2722
+ ):
2723
+ return await _list_gxp_records(db, RISK_ASSESSMENT_COLLECTION, page, page_size, status_value)
2724
+
2725
+
2726
+ @router.post("/compliance/user-requirements/assess")
2727
+ async def assess_user_requirements(
2728
+ body: UserRequirementsAssessmentRequest | None = None,
2729
+ db: AsyncIOMotorDatabase = Depends(get_database),
2730
+ current_user: dict = Depends(get_current_user),
2731
+ ):
2732
+ assessed_at = utc_now()
2733
+ assessed_by = _current_user_id(current_user)
2734
+ request_body = body or UserRequirementsAssessmentRequest()
2735
+ source_rows = (
2736
+ [_requirement_input_to_row(requirement) for requirement in request_body.requirements]
2737
+ if request_body.requirements
2738
+ else _default_user_requirements()
2739
+ )
2740
+ controls = set(compliance_readiness()["technical_controls"])
2741
+ available_tests = {
2742
+ "tests/test_live_smoke_integration.py::test_iq_live_service_health_smoke",
2743
+ "tests/test_live_smoke_integration.py::test_oq_live_authenticated_api_smoke",
2744
+ "tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke",
2745
+ "tests/test_live_smoke_integration.py::test_gqa_live_agentos_smoke",
2746
+ }
2747
+ evidence_refs = await _latest_validation_evidence_refs(db)
2748
+ assessed_rows = [
2749
+ _assess_user_requirement(row, controls, available_tests, evidence_refs)
2750
+ for row in source_rows
2751
+ ]
2752
+ satisfied = sum(1 for row in assessed_rows if row["status"] == "Satisfied")
2753
+ partially_covered = sum(1 for row in assessed_rows if row["status"] == "Partially Covered")
2754
+ gaps = sum(1 for row in assessed_rows if row["status"] == "Gap")
2755
+ summary = {
2756
+ "total": len(assessed_rows),
2757
+ "passed": satisfied,
2758
+ "failed": partially_covered + gaps,
2759
+ "satisfied": satisfied,
2760
+ "partially_covered": partially_covered,
2761
+ "gaps": gaps,
2762
+ }
2763
+ payload = {
2764
+ "assessment_id": f"urs-assessment-{uuid4().hex}",
2765
+ "title": "Pharma Compliance Proof User Requirements Assessment",
2766
+ "kind": "user-requirements-assessment",
2767
+ "scope": request_body.scope,
2768
+ "standard_id": "eu-gmp-annex-15",
2769
+ "status": "Passed" if summary["passed"] == summary["total"] else "Open",
2770
+ "assessed_at": assessed_at,
2771
+ "assessed_by": assessed_by,
2772
+ "summary": summary,
2773
+ "rows": assessed_rows,
2774
+ "requirements": assessed_rows,
2775
+ }
2776
+ payload["record_hash"] = sha256_hex(payload)
2777
+ await db[USER_REQUIREMENT_COLLECTION].insert_one(
2778
+ {
2779
+ **payload,
2780
+ "created_at": assessed_at,
2781
+ "updated_at": assessed_at,
2782
+ "created_by": assessed_by,
2783
+ "updated_by": assessed_by,
2784
+ "record_type": "user_requirement_assessment",
2785
+ }
2786
+ )
2787
+ return _bson_safe(payload)
2788
+
2789
+
2790
+ @router.get("/compliance/user-requirements/latest")
2791
+ async def latest_user_requirements_assessment(
2792
+ db: AsyncIOMotorDatabase = Depends(get_database),
2793
+ _: dict = Depends(get_current_user),
2794
+ ):
2795
+ record = await db[USER_REQUIREMENT_COLLECTION].find_one(
2796
+ {"record_type": "user_requirement_assessment"},
2797
+ {"_id": 0},
2798
+ sort=[("_id", -1)],
2799
+ )
2800
+ return _bson_safe(record) if record else None
2801
+
2802
+
2803
+ @router.get("/compliance/user-requirements")
2804
+ async def list_user_requirements(
2805
+ status_value: str | None = Query(default=None, alias="status"),
2806
+ page: int = Query(default=1, ge=1),
2807
+ page_size: int = Query(default=50, ge=1, le=500),
2808
+ db: AsyncIOMotorDatabase = Depends(get_database),
2809
+ _: dict = Depends(get_current_user),
2810
+ ):
2811
+ return await _list_gxp_records(db, USER_REQUIREMENT_COLLECTION, page, page_size, status_value)
2812
+
2813
+
2814
+ @router.post("/compliance/traceability-matrix/run")
2815
+ async def run_traceability_matrix(
2816
+ body: TraceabilityMatrixRequest | None = None,
2817
+ db: AsyncIOMotorDatabase = Depends(get_database),
2818
+ current_user: dict = Depends(get_current_user),
2819
+ ):
2820
+ generated_at = utc_now()
2821
+ generated_by = _current_user_id(current_user)
2822
+ request_body = body or TraceabilityMatrixRequest()
2823
+ latest_assessment = await db[USER_REQUIREMENT_COLLECTION].find_one(
2824
+ {"scope": request_body.scope},
2825
+ {"_id": 0},
2826
+ sort=[("_id", -1)],
2827
+ )
2828
+ requirements = list((latest_assessment or {}).get("requirements") or [])
2829
+ if request_body.requirement_ids:
2830
+ requested_ids = set(request_body.requirement_ids)
2831
+ requirements = [row for row in requirements if row.get("requirement_id") in requested_ids]
2832
+ if not requirements:
2833
+ assessment_payload = await assess_user_requirements(
2834
+ UserRequirementsAssessmentRequest(scope=request_body.scope),
2835
+ db=db,
2836
+ current_user=current_user,
2837
+ )
2838
+ requirements = list(assessment_payload.get("requirements") or [])
2839
+ if request_body.requirement_ids:
2840
+ requested_ids = set(request_body.requirement_ids)
2841
+ requirements = [row for row in requirements if row.get("requirement_id") in requested_ids]
2842
+
2843
+ risk_refs = await _latest_risk_refs(db)
2844
+ evidence_refs = await _latest_validation_evidence_refs(db)
2845
+ rows = _traceability_rows(requirements, risk_refs, evidence_refs)
2846
+ summary = {
2847
+ "total_requirements": len(rows),
2848
+ "satisfied": sum(1 for row in rows if row["status"] == "Satisfied"),
2849
+ "partially_covered": sum(1 for row in rows if row["status"] == "Partially Covered"),
2850
+ "gaps": sum(1 for row in rows if row["status"] == "Gap"),
2851
+ "coverage_percent": round((sum(1 for row in rows if row["status"] == "Satisfied") / len(rows)) * 100, 2) if rows else 0,
2852
+ }
2853
+ payload = {
2854
+ "traceability_matrix_id": f"traceability-matrix-{uuid4().hex}",
2855
+ "assessment_id": f"traceability-matrix-assessment-{uuid4().hex}",
2856
+ "title": "Pharma Compliance Proof Traceability Matrix Assessment",
2857
+ "kind": "traceability-matrix-assessment",
2858
+ "scope": request_body.scope,
2859
+ "standard_id": "eu-gmp-annex-15",
2860
+ "status": "Passed" if rows and summary["gaps"] == 0 else "Open",
2861
+ "generated_at": generated_at,
2862
+ "generated_by": generated_by,
2863
+ "assessed_at": generated_at,
2864
+ "assessed_by": generated_by,
2865
+ "summary": {
2866
+ **summary,
2867
+ "total": summary["total_requirements"],
2868
+ "passed": summary["satisfied"],
2869
+ "failed": summary["partially_covered"] + summary["gaps"],
2870
+ },
2871
+ "rows": rows,
2872
+ }
2873
+ payload["record_hash"] = sha256_hex(payload)
2874
+ await db[TRACEABILITY_MATRIX_COLLECTION].insert_one(
2875
+ {
2876
+ **payload,
2877
+ "created_at": generated_at,
2878
+ "updated_at": generated_at,
2879
+ "created_by": generated_by,
2880
+ "updated_by": generated_by,
2881
+ "record_type": "traceability_matrix",
2882
+ }
2883
+ )
2884
+ return _bson_safe(payload)
2885
+
2886
+
2887
+ @router.post("/compliance/traceability-matrix/assess")
2888
+ async def assess_traceability_matrix(
2889
+ body: TraceabilityMatrixRequest | None = None,
2890
+ db: AsyncIOMotorDatabase = Depends(get_database),
2891
+ current_user: dict = Depends(get_current_user),
2892
+ ):
2893
+ return await run_traceability_matrix(body=body, db=db, current_user=current_user)
2894
+
2895
+
2896
+
2897
+ @router.get("/compliance/traceability-matrix/latest")
2898
+ async def latest_traceability_matrix_assessment(
2899
+ db: AsyncIOMotorDatabase = Depends(get_database),
2900
+ _: dict = Depends(get_current_user),
2901
+ ):
2902
+ record = await db[TRACEABILITY_MATRIX_COLLECTION].find_one(
2903
+ {"record_type": "traceability_matrix"},
2904
+ {"_id": 0},
2905
+ sort=[("_id", -1)],
2906
+ )
2907
+ return _bson_safe(record) if record else None
2908
+
2909
+
2910
+ @router.get("/compliance/traceability-matrix")
2911
+ async def list_traceability_matrices(
2912
+ status_value: str | None = Query(default=None, alias="status"),
2913
+ page: int = Query(default=1, ge=1),
2914
+ page_size: int = Query(default=50, ge=1, le=500),
2915
+ db: AsyncIOMotorDatabase = Depends(get_database),
2916
+ _: dict = Depends(get_current_user),
2917
+ ):
2918
+ return await _list_gxp_records(db, TRACEABILITY_MATRIX_COLLECTION, page, page_size, status_value)
2919
+
2920
+
2921
+ @router.post("/compliance/validation-evidence", status_code=status.HTTP_201_CREATED)
2922
+ async def create_validation_evidence(
2923
+ body: GxpEvidenceRequest,
2924
+ db: AsyncIOMotorDatabase = Depends(get_database),
2925
+ current_user: dict = Depends(get_current_user),
2926
+ ):
2927
+ payload = body.model_dump(mode="python")
2928
+ payload["standard_id"] = payload.get("standard_id") or "eu-gmp-annex-11"
2929
+ return await _insert_gxp_record(db, VALIDATION_EVIDENCE_COLLECTION, "validation_evidence", payload, current_user)
2930
+
2931
+
2932
+ @router.get("/compliance/validation-evidence")
2933
+ async def list_validation_evidence(
2934
+ status_value: str | None = Query(default=None, alias="status"),
2935
+ page: int = Query(default=1, ge=1),
2936
+ page_size: int = Query(default=50, ge=1, le=500),
2937
+ db: AsyncIOMotorDatabase = Depends(get_database),
2938
+ _: dict = Depends(get_current_user),
2939
+ ):
2940
+ return await _list_gxp_records(db, VALIDATION_EVIDENCE_COLLECTION, page, page_size, status_value)
2941
+
2942
+
2943
+
2944
+ COMPLIANCE_TEST_SCOPE_SELECTORS = {
2945
+ "iq": "tests/test_live_smoke_integration.py::test_iq_live_service_health_smoke",
2946
+ "oq": "tests/test_live_smoke_integration.py::test_oq_live_authenticated_api_smoke",
2947
+ "pq": "tests/test_live_smoke_integration.py::test_pq_live_compliance_process_smoke",
2948
+ "gqa": "tests/test_live_smoke_integration.py::test_gqa_live_agentos_smoke",
2949
+ "test-execution-reports": "tests/test_live_smoke_integration.py",
2950
+ "all": "tests/test_live_smoke_integration.py",
2951
+ }
2952
+
2953
+
2954
+ def _compliance_test_command(scope: str) -> list[str]:
2955
+ selector = COMPLIANCE_TEST_SCOPE_SELECTORS.get(scope) or COMPLIANCE_TEST_SCOPE_SELECTORS["all"]
2956
+ if selector.startswith("tests/"):
2957
+ targets = selector.split()
2958
+ else:
2959
+ targets = [f"tests/test_pharma_compliance_proof_validation.py::{selector}"]
2960
+ return [sys.executable, "-m", "pytest", "-vv", "--no-cov", *targets]
2961
+
2962
+ def _test_report_name(test_id: str) -> str:
2963
+ lowered = test_id.lower()
2964
+ if "test_iq_" in lowered or "empty_table" in lowered or "seeded_database" in lowered or "seed_static" in lowered:
2965
+ return "IQ Execution Report"
2966
+ if "test_oq_" in lowered or "auth" in lowered or "audit" in lowered or "change_control" in lowered or "workflow_canvas" in lowered or "download" in lowered:
2967
+ return "OQ Execution Report"
2968
+ if "test_pq_" in lowered or "material_flow" in lowered or "ahmedabad" in lowered or "canvas" in lowered or "event_flow" in lowered:
2969
+ return "PQ Execution Report"
2970
+ if "test_gqa_" in lowered or "agentos" in lowered or "llm_judge" in lowered:
2971
+ return "Compliant Intelligence Execution Report"
2972
+ return "OQ Execution Report"
2973
+
2974
+
2975
+ def _parse_pytest_results(output: str) -> list[dict]:
2976
+ results: list[dict] = []
2977
+ seen: set[str] = set()
2978
+ pattern = re.compile(r"^(tests/[^\s]+?)\s+(PASSED|FAILED|SKIPPED|ERROR|XFAILED|XPASSED)\b", re.MULTILINE)
2979
+ for match in pattern.finditer(output):
2980
+ test_id = match.group(1).strip()
2981
+ status_value = match.group(2).strip().lower()
2982
+ if test_id in seen:
2983
+ continue
2984
+ seen.add(test_id)
2985
+ results.append(
2986
+ {
2987
+ "id": test_id,
2988
+ "report": _test_report_name(test_id),
2989
+ "status": "Passed" if status_value == "passed" else status_value.replace("x", "x-").title(),
2990
+ "evidence": f"pytest {status_value}: {test_id}",
2991
+ }
2992
+ )
2993
+ return results
2994
+
2995
+
2996
+ def _summarize_pytest(output: str, returncode: int, results: list[dict]) -> dict:
2997
+ passed = sum(1 for item in results if item["status"] == "Passed")
2998
+ failed = sum(1 for item in results if item["status"] in {"Failed", "Error"})
2999
+ skipped = sum(1 for item in results if item["status"] == "Skipped")
3000
+ summary_matches = re.findall(r"=+\s*(.+?)\s*=+\s*$", output.strip(), re.MULTILINE)
3001
+ return {
3002
+ "status": "Passed" if returncode == 0 else "Failed",
3003
+ "passed": passed,
3004
+ "failed": failed,
3005
+ "skipped": skipped,
3006
+ "total": len(results),
3007
+ "summary": summary_matches[-1] if summary_matches else ("pytest completed" if returncode == 0 else "pytest failed"),
3008
+ }
3009
+
3010
+
3011
+ def _failed_test_rows(rows: list[dict]) -> list[dict]:
3012
+ return [item for item in rows if item["status"] not in {"Passed", "Skipped"}]
3013
+
3014
+
3015
+ def _report_evidence(report_name: str, rows: list[dict], executed_at: datetime, executed_by: str) -> str:
3016
+ passed = sum(1 for item in rows if item["status"] == "Passed")
3017
+ failed_rows = _failed_test_rows(rows)
3018
+ skipped = sum(1 for item in rows if item["status"] == "Skipped")
3019
+ evidence = (
3020
+ f"Executed {executed_at.strftime('%d/%m/%Y, %H:%M:%S')} by {executed_by}; "
3021
+ f"passed {passed}, failed {len(failed_rows)}, skipped {skipped}"
3022
+ )
3023
+ if failed_rows:
3024
+ failed_ids = "; ".join(str(item.get("id") or "unknown test") for item in failed_rows)
3025
+ evidence = f"{evidence}; failed tests: {failed_ids}"
3026
+ return evidence
3027
+
3028
+
3029
+ def _compliance_execution_reports(results: list[dict], executed_at: datetime, executed_by: str) -> list[dict]:
3030
+ reports: list[dict] = []
3031
+ for report_name in [
3032
+ "IQ Execution Report",
3033
+ "OQ Execution Report",
3034
+ "PQ Execution Report",
3035
+ "Compliant Intelligence Execution Report",
3036
+ ]:
3037
+ rows = [item for item in results if item["report"] == report_name]
3038
+ if not rows:
3039
+ continue
3040
+ failed_tests = _failed_test_rows(rows)
3041
+ reports.append(
3042
+ {
3043
+ "report": report_name,
3044
+ "status": "Passed" if not failed_tests else "Failed",
3045
+ "passed": sum(1 for item in rows if item["status"] == "Passed"),
3046
+ "failed": len(failed_tests),
3047
+ "skipped": sum(1 for item in rows if item["status"] == "Skipped"),
3048
+ "total": len(rows),
3049
+ "scope": f"{len(rows)} integration tests",
3050
+ "evidence": _report_evidence(report_name, rows, executed_at, executed_by),
3051
+ "failed_tests": failed_tests,
3052
+ "tests": rows,
3053
+ }
3054
+ )
3055
+ return reports
3056
+
3057
+
3058
+ def _compliance_agent_scope_summary(scope: str, execution: dict) -> dict:
3059
+ summary = execution.get("summary") or {}
3060
+ failed_tests: list[dict] = []
3061
+ for report in execution.get("reports") or []:
3062
+ failed_tests.extend(report.get("failed_tests") or [])
3063
+ return {
3064
+ "scope": scope,
3065
+ "status": summary.get("status") or "Unknown",
3066
+ "passed": int(summary.get("passed") or 0),
3067
+ "failed": int(summary.get("failed") or 0),
3068
+ "skipped": int(summary.get("skipped") or 0),
3069
+ "total": int(summary.get("total") or 0),
3070
+ "evidence": summary.get("summary") or execution.get("raw_output_tail") or "",
3071
+ "execution_id": execution.get("execution_id"),
3072
+ "failed_tests": failed_tests,
3073
+ "validation_evidence_id": execution.get("execution_id"),
3074
+ }
3075
+
3076
+
3077
+ def _compliance_agent_reports(scope_results: list[dict]) -> list[dict]:
3078
+ report_names = {
3079
+ "iq": "IQ Execution Report",
3080
+ "oq": "OQ Execution Report",
3081
+ "pq": "PQ Execution Report",
3082
+ "gqa": "Compliant Intelligence Execution Report",
3083
+ }
3084
+ return [
3085
+ {
3086
+ "report": report_names.get(item["scope"], f"{item['scope'].upper()} Execution Report"),
3087
+ "scope": item["scope"],
3088
+ "status": item["status"],
3089
+ "passed": item["passed"],
3090
+ "failed": item["failed"],
3091
+ "skipped": item["skipped"],
3092
+ "total": item["total"],
3093
+ "evidence": item["evidence"],
3094
+ "execution_id": item["execution_id"],
3095
+ "validation_evidence_id": item["validation_evidence_id"],
3096
+ "failed_tests": item["failed_tests"],
3097
+ "tests": item["failed_tests"],
3098
+ }
3099
+ for item in scope_results
3100
+ ]
3101
+
3102
+
3103
+ def _validate_compliance_agent_scopes(scopes: list[str]) -> list[str]:
3104
+ normalized = [str(scope).strip().lower() for scope in scopes if str(scope).strip()]
3105
+ if not normalized:
3106
+ normalized = ["iq", "oq", "pq", "gqa"]
3107
+ allowed = {"iq", "oq", "pq", "gqa"}
3108
+ invalid = [scope for scope in normalized if scope not in allowed]
3109
+ if invalid:
3110
+ raise HTTPException(
3111
+ status_code=status.HTTP_400_BAD_REQUEST,
3112
+ detail=f"Unsupported compliance agent scope(s): {', '.join(invalid)}",
3113
+ )
3114
+ return normalized
3115
+
3116
+
3117
+ @router.post("/compliance/test-execution/run")
3118
+ async def run_compliance_test_execution(
3119
+ body: ComplianceTestExecutionRequest | None = None,
3120
+ db: AsyncIOMotorDatabase = Depends(get_database),
3121
+ current_user: dict = Depends(get_current_user),
3122
+ ):
3123
+ repo_root = os.environ.get("MONKEYPATCHED_REPO_ROOT") or str(Path(__file__).resolve().parents[3])
3124
+ env = os.environ.copy()
3125
+ env.setdefault("PYTHONPATH", repo_root)
3126
+ env.setdefault("RUN_LIVE_SMOKE", "1")
3127
+ scope = (body.scope if body else "all")
3128
+ cmd = _compliance_test_command(scope)
3129
+ try:
3130
+ process = await asyncio.to_thread(
3131
+ subprocess.run,
3132
+ cmd,
3133
+ cwd=repo_root,
3134
+ env=env,
3135
+ capture_output=True,
3136
+ text=True,
3137
+ timeout=900,
3138
+ check=False,
3139
+ )
3140
+ except subprocess.TimeoutExpired as exc:
3141
+ output = f"{exc.stdout or ''}\n{exc.stderr or ''}".strip()
3142
+ results = _parse_pytest_results(output)
3143
+ summary = _summarize_pytest(output, 124, results)
3144
+ summary["status"] = "Timed Out"
3145
+ summary["summary"] = "pytest timed out after 900 seconds"
3146
+ else:
3147
+ output = f"{process.stdout}\n{process.stderr}".strip()
3148
+ results = _parse_pytest_results(output)
3149
+ summary = _summarize_pytest(output, process.returncode, results)
3150
+
3151
+ executed_at = utc_now()
3152
+ executed_by = _current_user_id(current_user)
3153
+ reports = _compliance_execution_reports(results, executed_at, executed_by)
3154
+
3155
+ payload = {
3156
+ "execution_id": f"test-exec-{uuid4().hex}",
3157
+ "executed_at": executed_at,
3158
+ "executed_by": executed_by,
3159
+ "command": " ".join(cmd),
3160
+ "scope": scope,
3161
+ "summary": summary,
3162
+ "reports": reports,
3163
+ "raw_output_tail": output[-12000:],
3164
+ }
3165
+ await db[VALIDATION_EVIDENCE_COLLECTION].insert_one(
3166
+ {
3167
+ "validation_evidence_id": payload["execution_id"],
3168
+ "title": "Compliance Integration Test Suite Execution",
3169
+ "description": summary["summary"],
3170
+ "standard_id": "eu-gmp-annex-11",
3171
+ "status": summary["status"],
3172
+ "evidence_type": "test_execution_report",
3173
+ "record_hash": sha256_hex(payload),
3174
+ "created_at": executed_at,
3175
+ "updated_at": executed_at,
3176
+ "created_by": payload["executed_by"],
3177
+ "updated_by": payload["executed_by"],
3178
+ "payload": payload,
3179
+ }
3180
+ )
3181
+ return _bson_safe(payload)
3182
+
3183
+
3184
+ @router.post("/compliance/agent/run")
3185
+ async def run_compliance_agent(
3186
+ body: ComplianceAgentRunRequest | None = None,
3187
+ db: AsyncIOMotorDatabase = Depends(get_database),
3188
+ current_user: dict = Depends(get_current_user),
3189
+ ):
3190
+ request_body = body or ComplianceAgentRunRequest()
3191
+ scopes = _validate_compliance_agent_scopes(request_body.scopes)
3192
+ started_at = utc_now()
3193
+ executed_by = _current_user_id(current_user)
3194
+ agent_run_id = f"compliance-agent-run-{uuid4().hex}"
3195
+ scope_results: list[dict] = []
3196
+ failed_scope: str | None = None
3197
+ failed_tests: list[dict] = []
3198
+
3199
+ for scope in scopes:
3200
+ execution = await run_compliance_test_execution(
3201
+ body=ComplianceTestExecutionRequest(scope=scope),
3202
+ db=db,
3203
+ current_user=current_user,
3204
+ )
3205
+ scope_summary = _compliance_agent_scope_summary(scope, execution)
3206
+ scope_results.append(scope_summary)
3207
+ if scope_summary["status"] != "Passed" or scope_summary["failed"] > 0:
3208
+ failed_scope = scope
3209
+ failed_tests = list(scope_summary["failed_tests"])
3210
+ if not request_body.continue_on_failure:
3211
+ break
3212
+
3213
+ all_scopes_passed = len(scope_results) == len(scopes) and all(
3214
+ item["status"] == "Passed" and item["failed"] == 0 for item in scope_results
3215
+ )
3216
+ urs_assessment = None
3217
+ traceability_matrix = None
3218
+ if all_scopes_passed:
3219
+ urs_assessment = await assess_user_requirements(
3220
+ UserRequirementsAssessmentRequest(),
3221
+ db=db,
3222
+ current_user=current_user,
3223
+ )
3224
+ traceability_matrix = await run_traceability_matrix(
3225
+ TraceabilityMatrixRequest(),
3226
+ db=db,
3227
+ current_user=current_user,
3228
+ )
3229
+
3230
+ completed_at = utc_now()
3231
+ summary = {
3232
+ "total_scopes": len(scopes),
3233
+ "executed_scopes": len(scope_results),
3234
+ "passed_scopes": sum(1 for item in scope_results if item["status"] == "Passed" and item["failed"] == 0),
3235
+ "failed_scopes": sum(1 for item in scope_results if item["status"] != "Passed" or item["failed"] > 0),
3236
+ "passed": sum(int(item["passed"]) for item in scope_results),
3237
+ "failed": sum(int(item["failed"]) for item in scope_results),
3238
+ "skipped": sum(int(item["skipped"]) for item in scope_results),
3239
+ "total": sum(int(item["total"]) for item in scope_results),
3240
+ "status": "Passed" if all_scopes_passed else "Failed",
3241
+ "summary": (
3242
+ "All compliance scopes passed"
3243
+ if all_scopes_passed
3244
+ else f"{failed_scope or 'One or more compliance scopes'} failed"
3245
+ ),
3246
+ "failed_scope": failed_scope,
3247
+ "failed_tests": failed_tests,
3248
+ "continue_on_failure": request_body.continue_on_failure,
3249
+ }
3250
+ payload = {
3251
+ "agent_run_id": agent_run_id,
3252
+ "execution_id": agent_run_id,
3253
+ "title": "Compliance Agent Sequential Test Execution",
3254
+ "evidence_type": "compliance_agent_run",
3255
+ "scope": "agent",
3256
+ "status": summary["status"],
3257
+ "started_at": started_at,
3258
+ "completed_at": completed_at,
3259
+ "executed_by": executed_by,
3260
+ "scopes": scopes,
3261
+ "summary": summary,
3262
+ "scope_results": scope_results,
3263
+ "reports": _compliance_agent_reports(scope_results),
3264
+ "user_requirements_assessment_id": (urs_assessment or {}).get("assessment_id"),
3265
+ "traceability_matrix_id": (traceability_matrix or {}).get("traceability_matrix_id"),
3266
+ "notification": {
3267
+ "status": summary["status"],
3268
+ "message": (
3269
+ "Passed: all compliance scopes passed"
3270
+ if all_scopes_passed
3271
+ else f"Failed: {failed_scope or 'one or more scopes'} failed"
3272
+ ),
3273
+ },
3274
+ }
3275
+ payload["record_hash"] = sha256_hex(payload)
3276
+ await db[VALIDATION_EVIDENCE_COLLECTION].insert_one(
3277
+ {
3278
+ "validation_evidence_id": agent_run_id,
3279
+ "title": payload["title"],
3280
+ "description": payload["notification"]["message"],
3281
+ "standard_id": "eu-gmp-annex-11",
3282
+ "status": payload["status"],
3283
+ "evidence_type": "compliance_agent_run",
3284
+ "record_hash": payload["record_hash"],
3285
+ "created_at": completed_at,
3286
+ "updated_at": completed_at,
3287
+ "created_by": executed_by,
3288
+ "updated_by": executed_by,
3289
+ "payload": payload,
3290
+ }
3291
+ )
3292
+ return _bson_safe(payload)
3293
+
3294
+
3295
+ @router.post("/compliance/agent/report")
3296
+ async def save_compliance_agent_report(
3297
+ body: ComplianceAgentReportRequest,
3298
+ db: AsyncIOMotorDatabase = Depends(get_database),
3299
+ current_user: dict = Depends(get_current_user),
3300
+ ):
3301
+ completed_at = utc_now()
3302
+ executed_by = _current_user_id(current_user)
3303
+ report_id = f"compliance-agent-report-{uuid4().hex}"
3304
+ failed_tests = list(body.failed_tests or body.summary.get("failed_tests") or [])
3305
+ reports = list(body.reports or [])
3306
+ summary = dict(body.summary or {})
3307
+ if reports and not {"passed", "failed", "skipped", "total", "summary"}.issubset(summary.keys()):
3308
+ summary.update(
3309
+ {
3310
+ "passed": sum(int(report.get("passed") or 0) for report in reports),
3311
+ "failed": sum(int(report.get("failed") or 0) for report in reports),
3312
+ "skipped": sum(int(report.get("skipped") or 0) for report in reports),
3313
+ "total": sum(int(report.get("total") or 0) for report in reports),
3314
+ "summary": body.message or f"{summary.get('passed_scopes', 0)} of {summary.get('total_scopes', 0)} compliance scopes passed",
3315
+ }
3316
+ )
3317
+ else:
3318
+ summary.setdefault("passed", int(summary.get("passed_scopes") or 0))
3319
+ summary.setdefault("failed", int(summary.get("failed_scopes") or len(failed_tests)))
3320
+ summary.setdefault("skipped", 0)
3321
+ summary.setdefault("total", int(summary.get("total_scopes") or summary.get("passed", 0) + summary.get("failed", 0)))
3322
+ summary.setdefault("summary", body.message or f"{summary.get('passed_scopes', summary.get('passed', 0))} of {summary.get('total_scopes', summary.get('total', 0))} compliance scopes passed")
3323
+ summary.setdefault("status", "Failed" if failed_tests or int(summary.get("failed") or 0) > 0 else "Passed")
3324
+ payload = {
3325
+ "agent_run_id": report_id,
3326
+ "execution_id": report_id,
3327
+ "title": "n8n Compliance Agent Report",
3328
+ "evidence_type": "compliance_agent_run",
3329
+ "scope": "agent",
3330
+ "status": summary["status"],
3331
+ "started_at": body.raw_response.get("started_at"),
3332
+ "completed_at": completed_at,
3333
+ "executed_at": completed_at,
3334
+ "executed_by": executed_by,
3335
+ "source": body.source,
3336
+ "summary": summary,
3337
+ "scope_results": body.scope_results,
3338
+ "reports": reports,
3339
+ "failed_tests": failed_tests,
3340
+ "raw_response": body.raw_response or body.model_dump(mode="python"),
3341
+ }
3342
+ payload["record_hash"] = sha256_hex(payload)
3343
+ await db[VALIDATION_EVIDENCE_COLLECTION].insert_one(
3344
+ {
3345
+ "validation_evidence_id": report_id,
3346
+ "title": payload["title"],
3347
+ "description": body.message or f"n8n compliance agent report: {payload['status']}",
3348
+ "standard_id": "eu-gmp-annex-11",
3349
+ "status": payload["status"],
3350
+ "evidence_type": "compliance_agent_run",
3351
+ "record_hash": payload["record_hash"],
3352
+ "created_at": completed_at,
3353
+ "updated_at": completed_at,
3354
+ "created_by": executed_by,
3355
+ "updated_by": executed_by,
3356
+ "payload": payload,
3357
+ }
3358
+ )
3359
+ return _bson_safe(payload)
3360
+
3361
+
3362
+ @router.post("/compliance/agent/n8n/run")
3363
+ async def run_compliance_agent_via_n8n(
3364
+ body: ComplianceAgentRunRequest | None = None,
3365
+ db: AsyncIOMotorDatabase = Depends(get_database),
3366
+ current_user: dict = Depends(get_current_user),
3367
+ ):
3368
+ webhook_url = str(settings.N8N_COMPLIANCE_REPORT_WEBHOOK_URL or "").strip()
3369
+ if not webhook_url:
3370
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="N8N_COMPLIANCE_REPORT_WEBHOOK_URL is not configured")
3371
+ request_body = body or ComplianceAgentRunRequest()
3372
+ payload = {
3373
+ "message": "Run compliance report",
3374
+ "scopes": _validate_compliance_agent_scopes(request_body.scopes),
3375
+ "continue_on_failure": request_body.continue_on_failure,
3376
+ "source": "sentinel-x-chat",
3377
+ }
3378
+ async with httpx.AsyncClient(timeout=900.0) as client:
3379
+ response = await client.post(webhook_url, json=payload, headers=n8n_webhook_auth_headers())
3380
+ if response.status_code >= 400:
3381
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"n8n compliance webhook returned HTTP {response.status_code}")
3382
+ try:
3383
+ n8n_payload = response.json()
3384
+ except ValueError:
3385
+ n8n_payload = {"message": response.text}
3386
+ report = await save_compliance_agent_report(
3387
+ ComplianceAgentReportRequest(
3388
+ summary=n8n_payload.get("summary") or {},
3389
+ reports=n8n_payload.get("reports") or [],
3390
+ failed_tests=n8n_payload.get("failed_tests") or (n8n_payload.get("summary") or {}).get("failed_tests") or [],
3391
+ scope_results=n8n_payload.get("scope_results") or [],
3392
+ message=n8n_payload.get("message") or "n8n compliance report returned",
3393
+ source="n8n",
3394
+ raw_response=n8n_payload,
3395
+ ),
3396
+ db=db,
3397
+ current_user=current_user,
3398
+ )
3399
+ return report
3400
+
3401
+
3402
+ @router.get("/compliance/test-execution/latest")
3403
+ async def latest_compliance_test_execution(
3404
+ db: AsyncIOMotorDatabase = Depends(get_database),
3405
+ _: dict = Depends(get_current_user),
3406
+ ):
3407
+ record = await db[VALIDATION_EVIDENCE_COLLECTION].find_one(
3408
+ {"evidence_type": {"$in": ["test_execution_report", "compliance_agent_run"]}, "payload": {"$exists": True}},
3409
+ {"_id": 0, "payload": 1},
3410
+ sort=[("_id", -1)],
3411
+ )
3412
+ payload = (record or {}).get("payload")
3413
+ return _bson_safe(payload) if payload else None
3414
+
3415
+
3416
+ @router.post("/compliance/change-controls/approval-chain/preview")
3417
+ async def preview_change_control_approval_chain(
3418
+ body: ChangeControlRequest,
3419
+ db: AsyncIOMotorDatabase = Depends(get_database),
3420
+ current_user: dict = Depends(get_current_user),
3421
+ ):
3422
+ payload = body.model_dump(mode="python")
3423
+ manual_chain = _manual_chain_from_payload(payload)
3424
+ resolved_entities = await _resolve_affected_entities(db, payload.get("affected_entities") or [])
3425
+ preview_id = payload.get("change_control_id") or f"change_control-preview-{uuid4().hex}"
3426
+ auto_chain, basis = await resolve_named_approval_chain(
3427
+ db,
3428
+ source_collection=CHANGE_CONTROL_COLLECTION,
3429
+ source_id=preview_id,
3430
+ source_document={**payload, "change_control_id": preview_id, "affected_entities": resolved_entities},
3431
+ affected_entities=resolved_entities,
3432
+ current_user_id=_current_user_id(current_user),
3433
+ )
3434
+ selected_chain = auto_chain
3435
+ assignment_basis = basis
3436
+ mode = "auto_graph_resolution"
3437
+ if manual_chain:
3438
+ selected_chain = await _resolve_manual_approval_chain(db, manual_chain, basis)
3439
+ assignment_basis = {
3440
+ **basis,
3441
+ "mode": "manual_dialog_assignment",
3442
+ "manual_dialog_assignment": True,
3443
+ "auto_chain_available": True,
3444
+ "manual_chain_count": len(selected_chain),
3445
+ }
3446
+ mode = "manual_dialog_assignment"
3447
+ return _bson_safe(
3448
+ {
3449
+ "mode": mode,
3450
+ "approval_chain": selected_chain,
3451
+ "auto_approval_chain": auto_chain,
3452
+ "approval_assignment_basis": assignment_basis,
3453
+ "affected_entities": resolved_entities,
3454
+ "dialog_contract": {
3455
+ "submit_field": "approval_chain",
3456
+ "required_step_fields": ["stage", "role", "assigned_user_id"],
3457
+ "accepted_worker_fields": ["assigned_user_id", "assigned_user_name", "assigned_user_email"],
3458
+ },
3459
+ }
3460
+ )
3461
+
3462
+
3463
+ @router.get("/compliance/approvers/typeahead")
3464
+ async def approver_typeahead(
3465
+ q: str = Query(default="", max_length=120),
3466
+ plant_id: str | None = Query(default=None, max_length=128),
3467
+ line_id: str | None = Query(default=None, max_length=128),
3468
+ stage_id: str | None = Query(default=None, max_length=128),
3469
+ workstation_id: str | None = Query(default=None, max_length=128),
3470
+ role: str | None = Query(default=None, max_length=128),
3471
+ limit: int = Query(default=20, ge=1, le=50),
3472
+ db: AsyncIOMotorDatabase = Depends(get_database),
3473
+ _: dict = Depends(get_current_user),
3474
+ ):
3475
+ line_id = line_id if isinstance(line_id, str) else None
3476
+ stage_id = stage_id if isinstance(stage_id, str) else None
3477
+ workstation_id = workstation_id if isinstance(workstation_id, str) else None
3478
+ role = role if isinstance(role, str) else None
3479
+ limit = limit if isinstance(limit, int) else 20
3480
+ text = q.strip()
3481
+ filters: list[dict] = [
3482
+ {
3483
+ "$or": [
3484
+ {"is_active": {"$ne": False}},
3485
+ {"is_active": {"$exists": False}},
3486
+ ]
3487
+ }
3488
+ ]
3489
+ if text:
3490
+ pattern = re.escape(text).replace("\\ ", ".*")
3491
+ filters.append(
3492
+ {
3493
+ "$or": [
3494
+ {"user_id": {"$regex": pattern, "$options": "i"}},
3495
+ {"employee_id": {"$regex": pattern, "$options": "i"}},
3496
+ {"name": {"$regex": pattern, "$options": "i"}},
3497
+ {"email": {"$regex": pattern, "$options": "i"}},
3498
+ {"title": {"$regex": pattern, "$options": "i"}},
3499
+ {"role": {"$regex": pattern, "$options": "i"}},
3500
+ ]
3501
+ }
3502
+ )
3503
+ if role:
3504
+ role_pattern = re.escape(role.strip()).replace("\\ ", ".*")
3505
+ filters.append({"$or": [{"role": {"$regex": role_pattern, "$options": "i"}}, {"title": {"$regex": role_pattern, "$options": "i"}}]})
3506
+ context_or = []
3507
+ if workstation_id:
3508
+ context_or.append({"workstation_id": workstation_id})
3509
+ if stage_id:
3510
+ context_or.append({"stage_id": stage_id})
3511
+ if line_id:
3512
+ context_or.append({"line_id": line_id})
3513
+ if plant_id:
3514
+ context_or.append({"plant_id": plant_id})
3515
+ if context_or:
3516
+ filters.append({"$or": context_or + [{"title": {"$in": ["Document Controller", "Quality Control", "Change Control", "Plant Manager", "Line Manager"]}}]})
3517
+ query = {"$and": filters} if filters else {}
3518
+ cursor = db["users"].find(
3519
+ query,
3520
+ {
3521
+ "_id": 0,
3522
+ "embedding": 0,
3523
+ "password": 0,
3524
+ "hashed_password": 0,
3525
+ "refresh_tokens": 0,
3526
+ },
3527
+ ).limit(limit)
3528
+ workers = []
3529
+ async for user in cursor:
3530
+ workers.append(
3531
+ {
3532
+ "user_id": user.get("user_id"),
3533
+ "name": user.get("name") or user.get("email") or user.get("user_id"),
3534
+ "email": user.get("email"),
3535
+ "title": user.get("title"),
3536
+ "role": user.get("role"),
3537
+ "employee_id": user.get("employee_id"),
3538
+ "plant_id": user.get("plant_id"),
3539
+ "line_id": user.get("line_id"),
3540
+ "stage_id": user.get("stage_id"),
3541
+ "workstation_id": user.get("workstation_id"),
3542
+ "label": " - ".join(
3543
+ str(part)
3544
+ for part in (user.get("name") or user.get("email") or user.get("user_id"), user.get("title"), user.get("email"))
3545
+ if part
3546
+ ),
3547
+ "value": user.get("user_id"),
3548
+ }
3549
+ )
3550
+ return _bson_safe({"query": text, "count": len(workers), "results": workers})
3551
+
3552
+
3553
+ @router.post("/compliance/change-controls", status_code=status.HTTP_201_CREATED)
3554
+ async def create_change_control(
3555
+ body: ChangeControlRequest,
3556
+ db: AsyncIOMotorDatabase = Depends(get_database),
3557
+ current_user: dict = Depends(get_current_user),
3558
+ ):
3559
+ payload = body.model_dump(mode="python")
3560
+ record = await _build_change_control_record(db, payload, current_user)
3561
+ await db[CHANGE_CONTROL_COLLECTION].insert_one(record)
3562
+ proposed_nodes = await _create_proposed_change_nodes(db, record, current_user)
3563
+ if proposed_nodes:
3564
+ await db[CHANGE_CONTROL_COLLECTION].update_one(
3565
+ {"change_control_id": record["change_control_id"]},
3566
+ {
3567
+ "$set": {
3568
+ "proposed_change_nodes": proposed_nodes,
3569
+ "proposed_change_node_ids": [node.get("proposed_change_id") for node in proposed_nodes],
3570
+ "live_node_touched": False,
3571
+ "cdc_source": PROPOSED_CHANGE_COLLECTION,
3572
+ "updated_at": utc_now(),
3573
+ }
3574
+ },
3575
+ )
3576
+ record = {
3577
+ **record,
3578
+ "proposed_change_nodes": proposed_nodes,
3579
+ "proposed_change_node_ids": [node.get("proposed_change_id") for node in proposed_nodes],
3580
+ "live_node_touched": False,
3581
+ "cdc_source": PROPOSED_CHANGE_COLLECTION,
3582
+ }
3583
+ await safe_mirror(mirror_document(CHANGE_CONTROL_COLLECTION, record, "create"))
3584
+ queue_publication_status = {
3585
+ "change_control_queue": {"published": False, "subject": settings.NATS_CHANGE_CONTROL_SUBJECT},
3586
+ "approval_notification_queue": {"published": False, "subject": settings.NATS_APPROVALS_SUBJECT},
3587
+ "n8n_email_webhook": {"enabled": bool(str(settings.N8N_EMAIL_WEBHOOK_URL or "").strip()), "attempted": 0, "queued": 0, "failed": 0},
3588
+ }
3589
+ try:
3590
+ subject = await nats_store.publish_change_control_event(
3591
+ {
3592
+ "event": "change_control_created",
3593
+ "source_collection": CHANGE_CONTROL_COLLECTION,
3594
+ "source_id": record["change_control_id"],
3595
+ "source_title": record.get("title") or record.get("change_control_number"),
3596
+ "change_control": _bson_safe(record),
3597
+ "proposed_change_nodes": proposed_nodes,
3598
+ "proposed_node_collection": PROPOSED_CHANGE_COLLECTION,
3599
+ "approval_chain": record["approval_chain"],
3600
+ "approval_assignment_basis": record.get("approval_assignment_basis"),
3601
+ }
3602
+ )
3603
+ queue_publication_status["change_control_queue"] = {"published": True, "subject": subject}
3604
+ except Exception as exc:
3605
+ queue_publication_status["change_control_queue"]["error"] = str(exc)
3606
+ approval_token_records = await create_approval_tracking_and_notifications(
3607
+ db,
3608
+ source_collection=CHANGE_CONTROL_COLLECTION,
3609
+ source_id=record["change_control_id"],
3610
+ source_title=record.get("title") or record.get("change_control_number") or record["change_control_id"],
3611
+ chain=record["approval_chain"],
3612
+ current_user_id=_current_user_id(current_user),
3613
+ )
3614
+ approval_event = approval_notification_event(
3615
+ source_collection=CHANGE_CONTROL_COLLECTION,
3616
+ source_id=record["change_control_id"],
3617
+ source_title=record.get("title") or record.get("change_control_number") or record["change_control_id"],
3618
+ source=_bson_safe(record),
3619
+ chain=record["approval_chain"],
3620
+ token_records=approval_token_records,
3621
+ )
3622
+ try:
3623
+ subject = await nats_store.publish_approval_event(
3624
+ approval_event
3625
+ )
3626
+ queue_publication_status["approval_notification_queue"] = {"published": True, "subject": subject}
3627
+ except Exception as exc:
3628
+ queue_publication_status["approval_notification_queue"]["error"] = str(exc)
3629
+ queue_publication_status["n8n_email_webhook"] = await _post_approval_notifications_to_n8n(approval_event)
3630
+ response_record = dict(record)
3631
+ response_record["queue_publication_status"] = queue_publication_status
3632
+ response_record["notification_delivery"] = {
3633
+ "mode": "external_nats_queue",
3634
+ "approval_subject": settings.NATS_APPROVALS_SUBJECT,
3635
+ "expected_consumer": "reviewer assignment / email notification workflow",
3636
+ "n8n_email_webhook_configured": bool(str(settings.N8N_EMAIL_WEBHOOK_URL or "").strip()),
3637
+ }
3638
+ return _bson_safe(response_record)
3639
+
3640
+
3641
+ @router.get("/compliance/change-controls")
3642
+ async def list_change_controls(
3643
+ status_value: str | None = Query(default=None, alias="status"),
3644
+ page: int = Query(default=1, ge=1),
3645
+ page_size: int = Query(default=50, ge=1, le=500),
3646
+ db: AsyncIOMotorDatabase = Depends(get_database),
3647
+ _: dict = Depends(get_current_user),
3648
+ ):
3649
+ return _bson_safe(await _list_gxp_records(db, CHANGE_CONTROL_COLLECTION, page, page_size, status_value))
3650
+
3651
+
3652
+ @router.get("/compliance/approvals/{approval_id}/form", response_class=HTMLResponse)
3653
+ async def approval_form(
3654
+ approval_id: str,
3655
+ request: Request,
3656
+ token: str = Query(..., min_length=1),
3657
+ db: AsyncIOMotorDatabase = Depends(get_database),
3658
+ ):
3659
+ approval = await db["approvals"].find_one({"approval_id": approval_id}, {"_id": 0})
3660
+ token_error = _approval_token_error(approval, token)
3661
+ if token_error:
3662
+ return HTMLResponse(_approval_login_html(approval or {"source_title": "Invalid approval"}, token, token_error), status_code=403)
3663
+ current_user = _approval_user_from_request(request)
3664
+ if not current_user:
3665
+ return HTMLResponse(_approval_login_html(approval, token))
3666
+ if not _approval_user_matches(approval, current_user):
3667
+ return HTMLResponse(_approval_login_html(approval, token, "This approval is assigned to a different user."), status_code=403)
3668
+ return HTMLResponse(_approval_form_html(approval, token, current_user=current_user))
3669
+
3670
+
3671
+ @router.post("/compliance/approvals/{approval_id}/form", response_class=HTMLResponse)
3672
+ async def submit_approval_form(
3673
+ approval_id: str,
3674
+ request: Request,
3675
+ token: str = Form(...),
3676
+ action: str = Form(default="decide"),
3677
+ decision: str = Form(default=""),
3678
+ reason: str | None = Form(default=None),
3679
+ meaning: str | None = Form(default=None),
3680
+ email: str | None = Form(default=None),
3681
+ password: str | None = Form(default=None),
3682
+ db: AsyncIOMotorDatabase = Depends(get_database),
3683
+ ):
3684
+ approval = await db["approvals"].find_one({"approval_id": approval_id}, {"_id": 0})
3685
+ token_error = _approval_token_error(approval, token)
3686
+ if token_error:
3687
+ return HTMLResponse(_approval_login_html(approval or {"source_title": "Invalid approval"}, token, token_error), status_code=403)
3688
+
3689
+ if action == "login":
3690
+ user = await _approval_login_user(db, email, password)
3691
+ if not user:
3692
+ return HTMLResponse(_approval_login_html(approval, token, "Invalid credentials or MFA-enabled account."), status_code=401)
3693
+ current_user = {"sub": user.get("user_id"), "email": user.get("email"), "role": user.get("role")}
3694
+ if not _approval_user_matches(approval, current_user):
3695
+ return HTMLResponse(_approval_login_html(approval, token, "This approval is assigned to a different user."), status_code=403)
3696
+ response = HTMLResponse(_approval_form_html(approval, token, current_user=current_user))
3697
+ _set_approval_access_cookie(response, user)
3698
+ return response
3699
+
3700
+ current_user = _approval_user_from_request(request)
3701
+ if not current_user:
3702
+ return HTMLResponse(_approval_login_html(approval, token, "Sign in before submitting this approval."), status_code=401)
3703
+ if not _approval_user_matches(approval, current_user):
3704
+ return HTMLResponse(_approval_login_html(approval, token, "This approval is assigned to a different user."), status_code=403)
3705
+
3706
+ normalized_decision = decision.strip().title()
3707
+ if normalized_decision not in {"Approved", "Rejected"}:
3708
+ return HTMLResponse(_approval_form_html(approval, token, "Decision must be Approved or Rejected.", current_user=current_user), status_code=400)
3709
+ if normalized_decision == "Rejected" and not str(reason or "").strip():
3710
+ return HTMLResponse(_approval_form_html(approval, token, "Rejection reason is required.", current_user=current_user), status_code=400)
3711
+
3712
+ try:
3713
+ result = await record_approval_decision(
3714
+ db,
3715
+ approval=approval,
3716
+ approval_id=approval_id,
3717
+ decision=normalized_decision,
3718
+ reason=reason,
3719
+ signature_meaning=meaning,
3720
+ decision_source="sentinel_x_email_form",
3721
+ )
3722
+ except ApprovalAlreadyDecidedError as exc:
3723
+ return HTMLResponse(_approval_form_html(approval, token, str(exc), current_user=current_user), status_code=409)
3724
+ return HTMLResponse(_approval_complete_html(result["approval"], normalized_decision))
3725
+
3726
+
3727
+ @router.post("/compliance/approvals/{approval_id}/external-decision")
3728
+ async def submit_external_approval_decision(
3729
+ approval_id: str,
3730
+ body: ExternalApprovalDecisionRequest,
3731
+ db: AsyncIOMotorDatabase = Depends(get_database),
3732
+ ):
3733
+ approval = await db["approvals"].find_one({"approval_id": approval_id}, {"_id": 0})
3734
+ token_error = _approval_token_error(approval, body.token)
3735
+ if token_error:
3736
+ status_code = status.HTTP_409_CONFLICT if "already been decided" in token_error else status.HTTP_403_FORBIDDEN
3737
+ raise HTTPException(status_code=status_code, detail=token_error)
3738
+ requester = {
3739
+ "sub": body.approver_user_id,
3740
+ "user_id": body.approver_user_id,
3741
+ "email": body.approver_email,
3742
+ }
3743
+ if not _approval_user_matches(approval, requester):
3744
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This approval is assigned to a different user.")
3745
+
3746
+ normalized_decision = body.decision.strip().title()
3747
+ if normalized_decision not in {"Approved", "Rejected"}:
3748
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Decision must be Approved or Rejected.")
3749
+ if normalized_decision == "Rejected" and not str(body.reason or "").strip():
3750
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejection reason is required.")
3751
+
3752
+ try:
3753
+ result = await record_approval_decision(
3754
+ db,
3755
+ approval=approval,
3756
+ approval_id=approval_id,
3757
+ decision=normalized_decision,
3758
+ reason=body.reason,
3759
+ signature_meaning=body.signature_meaning,
3760
+ decision_source=body.source or "external_approval_form",
3761
+ )
3762
+ except ApprovalAlreadyDecidedError as exc:
3763
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
3764
+ return _bson_safe(
3765
+ {
3766
+ "status": "accepted",
3767
+ "approval_id": approval_id,
3768
+ "decision": normalized_decision,
3769
+ "source_id": approval.get("source_id"),
3770
+ "target_node": "Electronic Signature",
3771
+ "decision_subject": result["decision_subject"],
3772
+ "audit_subject": result["audit_subject"],
3773
+ "approval_audit_status": result["approval_audit_status"],
3774
+ "approvals": result["approvals"],
3775
+ }
3776
+ )
3777
+
3778
+
3779
+ @router.get("/compliance/change-controls/{change_control_id}")
3780
+ async def get_change_control(
3781
+ change_control_id: str,
3782
+ db: AsyncIOMotorDatabase = Depends(get_database),
3783
+ _: dict = Depends(get_current_user),
3784
+ ):
3785
+ record = await db[CHANGE_CONTROL_COLLECTION].find_one(
3786
+ {
3787
+ "$or": [
3788
+ {"change_control_id": change_control_id},
3789
+ {"change_control_number": change_control_id},
3790
+ ]
3791
+ },
3792
+ {"_id": 0},
3793
+ )
3794
+ if not record:
3795
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Change control not found")
3796
+ return _bson_safe(record)
3797
+
3798
+
3799
+ @router.post("/compliance/access-reviews", status_code=status.HTTP_201_CREATED)
3800
+ async def create_access_review(
3801
+ body: ReviewRequest,
3802
+ db: AsyncIOMotorDatabase = Depends(get_database),
3803
+ current_user: dict = Depends(get_current_user),
3804
+ ):
3805
+ payload = body.model_dump(mode="python")
3806
+ payload["standard_id"] = "eu-gmp-annex-11"
3807
+ return await _insert_gxp_record(db, ACCESS_REVIEW_COLLECTION, "access_review", payload, current_user)
3808
+
3809
+
3810
+ @router.get("/compliance/access-reviews")
3811
+ async def list_access_reviews(
3812
+ page: int = Query(default=1, ge=1),
3813
+ page_size: int = Query(default=50, ge=1, le=500),
3814
+ db: AsyncIOMotorDatabase = Depends(get_database),
3815
+ _: dict = Depends(get_current_user),
3816
+ ):
3817
+ return await _list_gxp_records(db, ACCESS_REVIEW_COLLECTION, page, page_size)
3818
+
3819
+
3820
+ @router.post("/compliance/data-integrity-reviews", status_code=status.HTTP_201_CREATED)
3821
+ async def create_data_integrity_review(
3822
+ body: ReviewRequest,
3823
+ db: AsyncIOMotorDatabase = Depends(get_database),
3824
+ current_user: dict = Depends(get_current_user),
3825
+ ):
3826
+ payload = body.model_dump(mode="python")
3827
+ payload["standard_id"] = "mhra-gxp-data-integrity"
3828
+ payload["principles"] = compliance_readiness()["data_integrity_principles"]
3829
+ return await _insert_gxp_record(db, DATA_INTEGRITY_REVIEW_COLLECTION, "data_integrity_review", payload, current_user)
3830
+
3831
+
3832
+ @router.get("/compliance/data-integrity-reviews")
3833
+ async def list_data_integrity_reviews(
3834
+ page: int = Query(default=1, ge=1),
3835
+ page_size: int = Query(default=50, ge=1, le=500),
3836
+ db: AsyncIOMotorDatabase = Depends(get_database),
3837
+ _: dict = Depends(get_current_user),
3838
+ ):
3839
+ return await _list_gxp_records(db, DATA_INTEGRITY_REVIEW_COLLECTION, page, page_size)