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.
- monkeybrain_runtime-1.0.0.dist-info/METADATA +76 -0
- monkeybrain_runtime-1.0.0.dist-info/RECORD +838 -0
- monkeybrain_runtime-1.0.0.dist-info/WHEEL +5 -0
- monkeybrain_runtime-1.0.0.dist-info/entry_points.txt +3 -0
- monkeybrain_runtime-1.0.0.dist-info/top_level.txt +2 -0
- services/__init__.py +8 -0
- services/agentos/__init__.py +0 -0
- services/agentos/main.py +1 -0
- services/assets/helpers/__init__.py +12 -0
- services/assets/helpers/device.py +59 -0
- services/assets/helpers/equipment.py +179 -0
- services/assets/helpers/instruments.py +72 -0
- services/assets/helpers/machines.py +183 -0
- services/assets/helpers/materials.py +76 -0
- services/assets/helpers/parts.py +116 -0
- services/assets/helpers/plc.py +134 -0
- services/assets/helpers/tags.py +108 -0
- services/assets/helpers/tools.py +101 -0
- services/assets/main.py +75 -0
- services/assets/models/__init__.py +12 -0
- services/assets/models/device.py +79 -0
- services/assets/models/equipment.py +222 -0
- services/assets/models/instruments.py +85 -0
- services/assets/models/machines.py +230 -0
- services/assets/models/material.py +266 -0
- services/assets/models/parts.py +96 -0
- services/assets/models/plc.py +264 -0
- services/assets/models/tags.py +76 -0
- services/assets/models/tools.py +179 -0
- services/assets/routers/__init__.py +12 -0
- services/assets/routers/classes.py +65 -0
- services/assets/routers/device.py +86 -0
- services/assets/routers/equipment.py +145 -0
- services/assets/routers/families.py +61 -0
- services/assets/routers/instruments.py +70 -0
- services/assets/routers/machines.py +136 -0
- services/assets/routers/materials.py +105 -0
- services/assets/routers/parts.py +130 -0
- services/assets/routers/plc.py +94 -0
- services/assets/routers/subclasses.py +68 -0
- services/assets/routers/tags.py +138 -0
- services/assets/routers/tools.py +113 -0
- services/auth/helpers/__init__.py +13 -0
- services/auth/helpers/approval_decisions.py +261 -0
- services/auth/helpers/audit_elasticsearch_sync.py +350 -0
- services/auth/helpers/departments.py +53 -0
- services/auth/helpers/graph_store.py +848 -0
- services/auth/helpers/influx_store.py +280 -0
- services/auth/helpers/me.py +33 -0
- services/auth/helpers/nats_consumer.py +618 -0
- services/auth/helpers/nats_store.py +242 -0
- services/auth/helpers/permissions.py +62 -0
- services/auth/helpers/roles.py +87 -0
- services/auth/helpers/store.py +54 -0
- services/auth/helpers/team_members.py +155 -0
- services/auth/helpers/teams.py +87 -0
- services/auth/helpers/tokens.py +71 -0
- services/auth/helpers/users.py +119 -0
- services/auth/helpers/websocket_broadcast.py +41 -0
- services/auth/main.py +88 -0
- services/auth/models/__init__.py +12 -0
- services/auth/models/departments.py +55 -0
- services/auth/models/login.py +20 -0
- services/auth/models/permissions.py +61 -0
- services/auth/models/roles.py +53 -0
- services/auth/models/session.py +26 -0
- services/auth/models/teamMembers.py +59 -0
- services/auth/models/teams.py +56 -0
- services/auth/models/users.py +77 -0
- services/auth/routers/__init__.py +14 -0
- services/auth/routers/auth.py +3839 -0
- services/auth/routers/departments.py +84 -0
- services/auth/routers/integration_config.py +703 -0
- services/auth/routers/me.py +28 -0
- services/auth/routers/permissions.py +96 -0
- services/auth/routers/roles.py +139 -0
- services/auth/routers/session.py +224 -0
- services/auth/routers/team_members.py +152 -0
- services/auth/routers/teams.py +112 -0
- services/auth/routers/users.py +131 -0
- services/auth/routers/websocket_events.py +247 -0
- services/batch_execution/__init__.py +19 -0
- services/batch_execution/pipeline_triggers/__init__.py +53 -0
- services/batch_execution/pipeline_triggers/pipeline_parameter_adjuster.py +440 -0
- services/batch_execution/pipeline_triggers/pipeline_trigger_engine.py +445 -0
- services/batch_execution/pipeline_triggers/re_evaluation_queue.py +341 -0
- services/batch_execution/pipeline_triggers/test_phase3_implementation.py +553 -0
- services/batch_execution/pipeline_triggers/workflow_reevaluator.py +367 -0
- services/batch_execution/smoke_test_e2e_feedback_loop.py +704 -0
- services/batch_execution/workflow_executor.py +478 -0
- services/changeover/helpers/__init__.py +11 -0
- services/changeover/helpers/changeover.py +87 -0
- services/changeover/helpers/changeover_common.py +55 -0
- services/changeover/helpers/changeover_events.py +66 -0
- services/changeover/helpers/changeover_kpis.py +33 -0
- services/changeover/helpers/changeover_matrix.py +60 -0
- services/changeover/helpers/changeover_procedures.py +164 -0
- services/changeover/helpers/changeover_windows.py +96 -0
- services/changeover/main.py +52 -0
- services/changeover/models/__init__.py +11 -0
- services/changeover/models/changeover.py +73 -0
- services/changeover/models/changeover_events.py +142 -0
- services/changeover/models/changeover_kpis.py +75 -0
- services/changeover/models/changeover_matrix.py +63 -0
- services/changeover/models/changeover_procedures.py +108 -0
- services/changeover/models/changeover_tasks.py +87 -0
- services/changeover/models/changeover_windows.py +72 -0
- services/changeover/routers/__init__.py +9 -0
- services/changeover/routers/changeover_events.py +127 -0
- services/changeover/routers/changeover_kpis.py +80 -0
- services/changeover/routers/changeover_matrix.py +80 -0
- services/changeover/routers/changeover_procedures.py +118 -0
- services/changeover/routers/changeover_windows.py +98 -0
- services/common/__init__.py +2 -0
- services/common/approval_chains.py +648 -0
- services/common/auth.py +56 -0
- services/common/cdc.py +52 -0
- services/common/compat.py +217 -0
- services/common/compliance.py +562 -0
- services/common/config.py +134 -0
- services/common/cors.py +17 -0
- services/common/data_transformation.py +195 -0
- services/common/db.py +577 -0
- services/common/embeddings.py +97 -0
- services/common/event_reducers.py +194 -0
- services/common/event_types.py +51 -0
- services/common/integration_ingestion.py +169 -0
- services/common/logging.py +204 -0
- services/common/models/__init__.py +2 -0
- services/common/models/databricks.py +25 -0
- services/common/models/enums.py +64 -0
- services/common/module_control.py +422 -0
- services/common/mongo_cdc_watcher.py +106 -0
- services/common/n8n_auth.py +22 -0
- services/common/neo4j_mirror.py +1087 -0
- services/common/ontology_registry.py +110 -0
- services/common/reasoning_traces.py +52 -0
- services/common/supply_chain_cdc.py +555 -0
- services/common/tracing.py +159 -0
- services/common/utils.py +30 -0
- services/customers/helpers/__init__.py +8 -0
- services/customers/helpers/customer_details.py +64 -0
- services/customers/helpers/customer_metadata.py +64 -0
- services/customers/helpers/customer_order_metrics.py +67 -0
- services/customers/helpers/customer_payment_data.py +67 -0
- services/customers/main.py +50 -0
- services/customers/models/__init__.py +8 -0
- services/customers/models/customer_details.py +42 -0
- services/customers/models/customer_metadata.py +97 -0
- services/customers/models/customer_order_metrics.py +86 -0
- services/customers/models/customer_payment_data.py +60 -0
- services/customers/routers/__init__.py +8 -0
- services/customers/routers/customer_details.py +88 -0
- services/customers/routers/customer_metadata.py +88 -0
- services/customers/routers/customer_order_metrics.py +88 -0
- services/customers/routers/customer_payment_data.py +88 -0
- services/documents/__init__.py +1 -0
- services/documents/helpers/__init__.py +6 -0
- services/documents/helpers/document_metadata.py +569 -0
- services/documents/helpers/document_workflows.py +215 -0
- services/documents/helpers/report_templates.py +113 -0
- services/documents/main.py +49 -0
- services/documents/models/__init__.py +6 -0
- services/documents/models/document_metadata.py +215 -0
- services/documents/models/document_workflows.py +136 -0
- services/documents/models/report_templates.py +132 -0
- services/documents/routers/__init__.py +6 -0
- services/documents/routers/document_metadata.py +654 -0
- services/documents/routers/document_workflows.py +146 -0
- services/documents/routers/report_templates.py +86 -0
- services/events/helpers/__init__.py +5 -0
- services/events/helpers/events.py +394 -0
- services/events/main.py +40 -0
- services/events/models/__init__.py +5 -0
- services/events/models/events.py +50 -0
- services/events/routers/__init__.py +6 -0
- services/events/routers/count_events.py +109 -0
- services/events/routers/events.py +75 -0
- services/events/seed_events.py +196 -0
- services/facilities/helpers/__init__.py +8 -0
- services/facilities/helpers/lines.py +74 -0
- services/facilities/helpers/locations.py +231 -0
- services/facilities/helpers/plants.py +59 -0
- services/facilities/helpers/stages.py +110 -0
- services/facilities/helpers/workstation.py +213 -0
- services/facilities/main.py +60 -0
- services/facilities/models/__init__.py +10 -0
- services/facilities/models/industrialLine.py +72 -0
- services/facilities/models/industrialPlant.py +164 -0
- services/facilities/models/locations.py +74 -0
- services/facilities/models/stages.py +92 -0
- services/facilities/models/worker.py +73 -0
- services/facilities/models/workstation.py +117 -0
- services/facilities/models/workstation_live_state.py +59 -0
- services/facilities/routers/__init__.py +8 -0
- services/facilities/routers/bays.py +81 -0
- services/facilities/routers/buildings.py +92 -0
- services/facilities/routers/floors.py +81 -0
- services/facilities/routers/lines.py +154 -0
- services/facilities/routers/locations.py +208 -0
- services/facilities/routers/plant.py +203 -0
- services/facilities/routers/rooms.py +81 -0
- services/facilities/routers/stages.py +152 -0
- services/facilities/routers/workstation.py +173 -0
- services/file/backup.py +71 -0
- services/file/main.py +45 -0
- services/file/recieve.py +54 -0
- services/file/send.py +55 -0
- services/file/src/core/config.py +90 -0
- services/file/src/core/keycloak.py +152 -0
- services/file/src/core/logging_config.py +9 -0
- services/file/src/core/security.py +33 -0
- services/file/src/helpers/cad_conversion.py +331 -0
- services/file/src/helpers/helpers.py +825 -0
- services/file/src/routes/cad_conversion.py +26 -0
- services/file/src/routes/files.py +136 -0
- services/file/src/routes/presigned.py +154 -0
- services/file/src/services/websocket.py +293 -0
- services/floor_layout/helpers/__init__.py +8 -0
- services/floor_layout/helpers/bays.py +92 -0
- services/floor_layout/helpers/buildings.py +54 -0
- services/floor_layout/helpers/floors.py +65 -0
- services/floor_layout/helpers/rooms.py +76 -0
- services/floor_layout/main.py +52 -0
- services/floor_layout/models/__init__.py +8 -0
- services/floor_layout/models/bays.py +65 -0
- services/floor_layout/models/buildings.py +52 -0
- services/floor_layout/models/floors.py +45 -0
- services/floor_layout/models/rooms.py +61 -0
- services/floor_layout/routers/__init__.py +9 -0
- services/floor_layout/routers/bays.py +143 -0
- services/floor_layout/routers/buildings.py +116 -0
- services/floor_layout/routers/floors.py +89 -0
- services/floor_layout/routers/locations.py +80 -0
- services/floor_layout/routers/rooms.py +134 -0
- services/inventory/helpers/__init__.py +13 -0
- services/inventory/helpers/cycle_counts.py +124 -0
- services/inventory/helpers/inventory_allocations.py +134 -0
- services/inventory/helpers/inventory_item_counts.py +114 -0
- services/inventory/helpers/inventory_item_quantities.py +114 -0
- services/inventory/helpers/inventory_items.py +103 -0
- services/inventory/helpers/inventory_stage_outputs.py +134 -0
- services/inventory/helpers/inventory_transactions.py +112 -0
- services/inventory/helpers/stock_adjustment_requests.py +101 -0
- services/inventory/helpers/warehouse_cycle_counts.py +133 -0
- services/inventory/helpers/warehouse_locations.py +213 -0
- services/inventory/helpers/warehouse_regulated_records.py +123 -0
- services/inventory/main.py +62 -0
- services/inventory/models/__init__.py +17 -0
- services/inventory/models/cycle_counts.py +99 -0
- services/inventory/models/inventory_allocations.py +121 -0
- services/inventory/models/inventory_common.py +65 -0
- services/inventory/models/inventory_enums.py +21 -0
- services/inventory/models/inventory_item_count.py +65 -0
- services/inventory/models/inventory_item_quantity.py +82 -0
- services/inventory/models/inventory_items.py +168 -0
- services/inventory/models/inventory_responses.py +44 -0
- services/inventory/models/inventory_stage_outputs.py +96 -0
- services/inventory/models/inventory_state.py +15 -0
- services/inventory/models/inventory_transactions.py +80 -0
- services/inventory/models/stock_adjustment_requests.py +109 -0
- services/inventory/models/warehouse_cycle_counts.py +119 -0
- services/inventory/models/warehouse_location_models.py +708 -0
- services/inventory/models/warehouse_regulated_records.py +358 -0
- services/inventory/routers/__init__.py +13 -0
- services/inventory/routers/cycle_counts.py +106 -0
- services/inventory/routers/inventory_allocations.py +125 -0
- services/inventory/routers/inventory_item_counts.py +105 -0
- services/inventory/routers/inventory_item_quantities.py +105 -0
- services/inventory/routers/inventory_items.py +109 -0
- services/inventory/routers/inventory_stage_outputs.py +122 -0
- services/inventory/routers/inventory_transactions.py +96 -0
- services/inventory/routers/stock_adjustment_requests.py +124 -0
- services/inventory/routers/warehouse_cycle_counts.py +124 -0
- services/inventory/routers/warehouse_locations.py +426 -0
- services/inventory/routers/warehouse_regulated_records.py +273 -0
- services/iot/helpers/__init__.py +8 -0
- services/iot/helpers/ble_device.py +87 -0
- services/iot/helpers/mqtt_bridge.py +115 -0
- services/iot/helpers/sensor_readings.py +63 -0
- services/iot/helpers/sensors.py +77 -0
- services/iot/helpers/servers.py +72 -0
- services/iot/helpers/uwb_device.py +95 -0
- services/iot/main.py +53 -0
- services/iot/models/__init__.py +8 -0
- services/iot/models/ble_device.py +118 -0
- services/iot/models/sensors.py +256 -0
- services/iot/models/servers.py +206 -0
- services/iot/models/uwb_device.py +106 -0
- services/iot/routers/__init__.py +8 -0
- services/iot/routers/ble_device.py +110 -0
- services/iot/routers/sensors.py +144 -0
- services/iot/routers/servers.py +141 -0
- services/iot/routers/uwb_device.py +148 -0
- services/module_control/__init__.py +1 -0
- services/module_control/helpers/__init__.py +1 -0
- services/module_control/helpers/integration_config.py +243 -0
- services/module_control/helpers/security.py +104 -0
- services/module_control/main.py +44 -0
- services/module_control/models/__init__.py +1 -0
- services/module_control/models/module_control.py +65 -0
- services/module_control/routers/__init__.py +1 -0
- services/module_control/routers/module_control.py +219 -0
- services/orders/helpers/__init__.py +11 -0
- services/orders/helpers/invoices.py +123 -0
- services/orders/helpers/order_customer_metrics.py +61 -0
- services/orders/helpers/order_details.py +71 -0
- services/orders/helpers/order_metadata.py +61 -0
- services/orders/helpers/order_payment_metadata.py +74 -0
- services/orders/helpers/orders.py +119 -0
- services/orders/helpers/sales_orders.py +136 -0
- services/orders/main.py +56 -0
- services/orders/models/__init__.py +11 -0
- services/orders/models/invoices.py +415 -0
- services/orders/models/order_customer_metrics.py +78 -0
- services/orders/models/order_details.py +46 -0
- services/orders/models/order_metadata.py +60 -0
- services/orders/models/order_payment_metadata.py +63 -0
- services/orders/models/orders.py +64 -0
- services/orders/models/sales_orders.py +130 -0
- services/orders/routers/__init__.py +11 -0
- services/orders/routers/invoices.py +111 -0
- services/orders/routers/order_customer_metrics.py +87 -0
- services/orders/routers/order_details.py +87 -0
- services/orders/routers/order_metadata.py +87 -0
- services/orders/routers/order_payment_metadata.py +87 -0
- services/orders/routers/orders.py +74 -0
- services/orders/routers/sales_orders.py +111 -0
- services/pm/helpers/__init__.py +14 -0
- services/pm/helpers/calendar_bookings.py +114 -0
- services/pm/helpers/calibration_point.py +110 -0
- services/pm/helpers/calibrations.py +196 -0
- services/pm/helpers/checklists.py +318 -0
- services/pm/helpers/cleaning.py +333 -0
- services/pm/helpers/downtime.py +376 -0
- services/pm/helpers/kanban_boards.py +186 -0
- services/pm/helpers/maintainance.py +177 -0
- services/pm/helpers/sop.py +1155 -0
- services/pm/helpers/sop_cdc.py +324 -0
- services/pm/helpers/weekly_schedules.py +79 -0
- services/pm/main.py +62 -0
- services/pm/models/__init__.py +14 -0
- services/pm/models/calendar_booking.py +82 -0
- services/pm/models/calibration_point.py +44 -0
- services/pm/models/calibrations.py +167 -0
- services/pm/models/checklists.py +117 -0
- services/pm/models/cleaning.py +203 -0
- services/pm/models/downtime.py +109 -0
- services/pm/models/kanban_board.py +178 -0
- services/pm/models/maintainanceLog.py +148 -0
- services/pm/models/sop.py +152 -0
- services/pm/models/weekly_schedule.py +91 -0
- services/pm/routers/__init__.py +14 -0
- services/pm/routers/calendar_bookings.py +143 -0
- services/pm/routers/calibration_point.py +94 -0
- services/pm/routers/calibrations.py +232 -0
- services/pm/routers/checklists.py +188 -0
- services/pm/routers/cleaning.py +127 -0
- services/pm/routers/downtime.py +143 -0
- services/pm/routers/kanban_boards.py +283 -0
- services/pm/routers/maintainance.py +241 -0
- services/pm/routers/sop.py +437 -0
- services/pm/routers/weekly_schedules.py +108 -0
- services/process_definitions/helpers/__init__.py +11 -0
- services/process_definitions/helpers/cpp_cqa_registry.py +120 -0
- services/process_definitions/helpers/mbmr_templates.py +107 -0
- services/process_definitions/helpers/packing_instructions.py +113 -0
- services/process_definitions/helpers/process_constraints.py +495 -0
- services/process_definitions/helpers/process_corrections.py +279 -0
- services/process_definitions/helpers/process_definition.py +996 -0
- services/process_definitions/helpers/process_node_catalog.py +786 -0
- services/process_definitions/helpers/process_post_checks.py +441 -0
- services/process_definitions/helpers/process_pre_checks.py +351 -0
- services/process_definitions/helpers/process_steps.py +220 -0
- services/process_definitions/main.py +71 -0
- services/process_definitions/models/__init__.py +13 -0
- services/process_definitions/models/cpp_cqa_registry.py +145 -0
- services/process_definitions/models/gxp_change_controls.py +38 -0
- services/process_definitions/models/gxp_risk_assessments.py +30 -0
- services/process_definitions/models/gxp_validation_evidence.py +33 -0
- services/process_definitions/models/mbmr_templates.py +173 -0
- services/process_definitions/models/packing_instructions.py +176 -0
- services/process_definitions/models/process_constraints.py +159 -0
- services/process_definitions/models/process_corrections.py +118 -0
- services/process_definitions/models/process_definition.py +685 -0
- services/process_definitions/models/process_definition_common.py +48 -0
- services/process_definitions/models/process_node_catalog.py +25 -0
- services/process_definitions/models/process_post_checks.py +171 -0
- services/process_definitions/models/process_pre_checks.py +168 -0
- services/process_definitions/models/process_steps.py +170 -0
- services/process_definitions/node_services/__init__.py +8 -0
- services/process_definitions/node_services/common.py +95 -0
- services/process_definitions/node_services/executor.py +499 -0
- services/process_definitions/node_services/flow_simulator.py +733 -0
- services/process_definitions/node_services/functions.py +193 -0
- services/process_definitions/node_services/messaging.py +44 -0
- services/process_definitions/node_services/models.py +221 -0
- services/process_definitions/node_services/network.py +161 -0
- services/process_definitions/node_services/parsers.py +87 -0
- services/process_definitions/node_services/sequence.py +95 -0
- services/process_definitions/node_services/storage.py +50 -0
- services/process_definitions/node_services/webhooks.py +52 -0
- services/process_definitions/routers/__init__.py +10 -0
- services/process_definitions/routers/cpp_cqa_registry.py +86 -0
- services/process_definitions/routers/mbmr_templates.py +84 -0
- services/process_definitions/routers/packing_instructions.py +84 -0
- services/process_definitions/routers/process_constraints.py +564 -0
- services/process_definitions/routers/process_corrections.py +343 -0
- services/process_definitions/routers/process_definition.py +992 -0
- services/process_definitions/routers/process_post_checks.py +529 -0
- services/process_definitions/routers/process_pre_checks.py +435 -0
- services/process_definitions/routers/process_steps.py +274 -0
- services/procurement/helpers/__init__.py +9 -0
- services/procurement/helpers/goods_receipts.py +240 -0
- services/procurement/helpers/purchase_order_shipping_information.py +85 -0
- services/procurement/helpers/purchase_orders.py +68 -0
- services/procurement/helpers/quality_control.py +235 -0
- services/procurement/helpers/sampling.py +404 -0
- services/procurement/main.py +52 -0
- services/procurement/models/__init__.py +9 -0
- services/procurement/models/goods_receipts.py +165 -0
- services/procurement/models/purchase_orders.py +54 -0
- services/procurement/models/quality_control.py +464 -0
- services/procurement/models/reinspection_records.py +28 -0
- services/procurement/models/sampling.py +262 -0
- services/procurement/models/shipping_information.py +51 -0
- services/procurement/routers/__init__.py +9 -0
- services/procurement/routers/goods_receipts.py +201 -0
- services/procurement/routers/purchase_orders.py +106 -0
- services/procurement/routers/quality_control.py +386 -0
- services/procurement/routers/sampling.py +296 -0
- services/procurement/routers/shipping_information.py +97 -0
- services/production/__init__.py +1 -0
- services/production/agents/__init__.py +5 -0
- services/production/agents/batch_planning_agent.py +815 -0
- services/production/models/__init__.py +25 -0
- services/production/models/batch.py +253 -0
- services/products/helpers/__init__.py +10 -0
- services/products/helpers/boms.py +100 -0
- services/products/helpers/drug_research.py +644 -0
- services/products/helpers/product_component.py +168 -0
- services/products/helpers/product_inventory.py +221 -0
- services/products/helpers/product_pricing.py +123 -0
- services/products/helpers/product_utils.py +32 -0
- services/products/helpers/products.py +81 -0
- services/products/main.py +59 -0
- services/products/models/__init__.py +9 -0
- services/products/models/drug_research.py +138 -0
- services/products/models/product_common.py +60 -0
- services/products/models/product_component.py +1028 -0
- services/products/models/product_inventory.py +118 -0
- services/products/models/product_pricing.py +73 -0
- services/products/models/products.py +151 -0
- services/products/routers/__init__.py +9 -0
- services/products/routers/boms.py +116 -0
- services/products/routers/drug_research.py +115 -0
- services/products/routers/product_components.py +123 -0
- services/products/routers/product_inventory.py +185 -0
- services/products/routers/product_pricing.py +136 -0
- services/products/routers/products.py +165 -0
- services/replenishment/__init__.py +1 -0
- services/replenishment/main.py +46 -0
- services/replenishment/routers/__init__.py +1 -0
- services/replenishment/routers/replenishment.py +20 -0
- services/shifts/helpers/__init__.py +7 -0
- services/shifts/helpers/shift_templates.py +124 -0
- services/shifts/helpers/shifts.py +79 -0
- services/shifts/helpers/timesheets.py +137 -0
- services/shifts/main.py +48 -0
- services/shifts/models/__init__.py +8 -0
- services/shifts/models/shift.py +62 -0
- services/shifts/models/shift_template.py +82 -0
- services/shifts/models/time_range.py +31 -0
- services/shifts/models/timesheet.py +196 -0
- services/shifts/routers/__init__.py +7 -0
- services/shifts/routers/shift_templates.py +97 -0
- services/shifts/routers/shifts.py +117 -0
- services/shifts/routers/timesheets.py +117 -0
- services/shipping/helpers/__init__.py +15 -0
- services/shipping/helpers/carrier.py +78 -0
- services/shipping/helpers/customs_declaration.py +104 -0
- services/shipping/helpers/delivery_note.py +99 -0
- services/shipping/helpers/package.py +95 -0
- services/shipping/helpers/pallet.py +85 -0
- services/shipping/helpers/route.py +93 -0
- services/shipping/helpers/shipping_information.py +82 -0
- services/shipping/helpers/shipping_provider_details.py +59 -0
- services/shipping/helpers/shipping_provider_metadata.py +59 -0
- services/shipping/helpers/vehicle.py +85 -0
- services/shipping/helpers/waybill.py +86 -0
- services/shipping/main.py +64 -0
- services/shipping/models/__init__.py +15 -0
- services/shipping/models/carrier.py +97 -0
- services/shipping/models/customs_declaration.py +138 -0
- services/shipping/models/delivery_note.py +163 -0
- services/shipping/models/package.py +152 -0
- services/shipping/models/pallet.py +137 -0
- services/shipping/models/route.py +120 -0
- services/shipping/models/shipping_information.py +55 -0
- services/shipping/models/shipping_provider_details.py +42 -0
- services/shipping/models/shipping_provider_metadata.py +54 -0
- services/shipping/models/vehicle.py +129 -0
- services/shipping/models/waybill.py +189 -0
- services/shipping/routers/__init__.py +15 -0
- services/shipping/routers/carrier.py +99 -0
- services/shipping/routers/customs_declaration.py +132 -0
- services/shipping/routers/delivery_note.py +150 -0
- services/shipping/routers/package.py +141 -0
- services/shipping/routers/pallet.py +108 -0
- services/shipping/routers/route.py +128 -0
- services/shipping/routers/shipping_information.py +97 -0
- services/shipping/routers/shipping_provider_details.py +80 -0
- services/shipping/routers/shipping_provider_metadata.py +80 -0
- services/shipping/routers/vehicle.py +117 -0
- services/shipping/routers/waybill.py +119 -0
- services/suppliers/helpers/__init__.py +13 -0
- services/suppliers/helpers/supplier_capabilities.py +58 -0
- services/suppliers/helpers/supplier_certifications.py +67 -0
- services/suppliers/helpers/supplier_details.py +58 -0
- services/suppliers/helpers/supplier_financials.py +58 -0
- services/suppliers/helpers/supplier_inventory.py +74 -0
- services/suppliers/helpers/supplier_locations.py +60 -0
- services/suppliers/helpers/supplier_pricing.py +69 -0
- services/suppliers/helpers/supplier_quality.py +69 -0
- services/suppliers/helpers/supplier_shipping.py +69 -0
- services/suppliers/main.py +60 -0
- services/suppliers/models/__init__.py +13 -0
- services/suppliers/models/supplier_capabilities.py +70 -0
- services/suppliers/models/supplier_certifications.py +64 -0
- services/suppliers/models/supplier_details.py +75 -0
- services/suppliers/models/supplier_financials.py +69 -0
- services/suppliers/models/supplier_inventory.py +76 -0
- services/suppliers/models/supplier_locations.py +70 -0
- services/suppliers/models/supplier_pricing.py +74 -0
- services/suppliers/models/supplier_quality.py +74 -0
- services/suppliers/models/supplier_shipping.py +76 -0
- services/suppliers/routers/__init__.py +13 -0
- services/suppliers/routers/supplier_capabilities.py +88 -0
- services/suppliers/routers/supplier_certifications.py +87 -0
- services/suppliers/routers/supplier_details.py +83 -0
- services/suppliers/routers/supplier_financials.py +83 -0
- services/suppliers/routers/supplier_inventory.py +105 -0
- services/suppliers/routers/supplier_locations.py +89 -0
- services/suppliers/routers/supplier_pricing.py +96 -0
- services/suppliers/routers/supplier_quality.py +96 -0
- services/suppliers/routers/supplier_shipping.py +96 -0
- services/supply_allocation/main.py +46 -0
- services/supply_allocation/routers/__init__.py +1 -0
- services/supply_allocation/routers/allocation.py +20 -0
- services/taxonomy/helpers/__init__.py +7 -0
- services/taxonomy/helpers/classes.py +48 -0
- services/taxonomy/helpers/family.py +53 -0
- services/taxonomy/helpers/subclass.py +58 -0
- services/taxonomy/main.py +48 -0
- services/taxonomy/models/__init__.py +7 -0
- services/taxonomy/models/classes.py +52 -0
- services/taxonomy/models/family.py +60 -0
- services/taxonomy/models/subclass.py +50 -0
- services/taxonomy/routers/__init__.py +7 -0
- services/taxonomy/routers/classes.py +78 -0
- services/taxonomy/routers/family.py +77 -0
- services/taxonomy/routers/subclass.py +82 -0
- services/warehouse_execution/__init__.py +1 -0
- services/warehouse_execution/main.py +46 -0
- services/warehouse_execution/routers/__init__.py +1 -0
- services/warehouse_execution/routers/execution.py +21 -0
- services/work_order_agent/__init__.py +17 -0
- services/work_order_agent/agent/__init__.py +17 -0
- services/work_order_agent/agent/work_order_agent.py +658 -0
- services/work_order_agent/tracking/__init__.py +101 -0
- services/work_order_agent/tracking/event_system.py +182 -0
- services/work_order_agent/tracking/state_machine.py +163 -0
- services/work_order_agent/tracking/state_machine_integrator.py +295 -0
- services/work_order_agent/tracking/test_phase2_implementation.py +302 -0
- services/work_order_agent/tracking/time_analysis.py +301 -0
- services/work_order_agent/tracking/tracked_work_order.py +255 -0
- services/work_order_agent/tracking/work_order_adapter.py +367 -0
- services/work_order_agent/tracking/work_order_batch_manager.py +406 -0
- services/work_order_agent/tracking/work_order_repository.py +431 -0
- services/workorders/helpers/__init__.py +5 -0
- services/workorders/helpers/area_room_usage_ledger.py +139 -0
- services/workorders/helpers/batch_execution_records.py +265 -0
- services/workorders/helpers/batch_release_workflows.py +158 -0
- services/workorders/helpers/batch_step_executions.py +145 -0
- services/workorders/helpers/equipment_usage_ledger.py +209 -0
- services/workorders/helpers/executed_bmr_records.py +170 -0
- services/workorders/helpers/executed_bpr_records.py +170 -0
- services/workorders/helpers/executed_instruction_evidence.py +155 -0
- services/workorders/helpers/ipc_result_records.py +134 -0
- services/workorders/helpers/production_batches.py +117 -0
- services/workorders/helpers/work_orders.py +367 -0
- services/workorders/helpers/yield_reconciliation_records.py +158 -0
- services/workorders/main.py +110 -0
- services/workorders/models/__init__.py +5 -0
- services/workorders/models/area_room_usage_ledger.py +154 -0
- services/workorders/models/batch_execution_records.py +575 -0
- services/workorders/models/batch_release_workflows.py +190 -0
- services/workorders/models/batch_step_executions.py +142 -0
- services/workorders/models/equipment_usage_ledger.py +144 -0
- services/workorders/models/executed_bmr_records.py +220 -0
- services/workorders/models/executed_bpr_records.py +220 -0
- services/workorders/models/executed_instruction_evidence.py +128 -0
- services/workorders/models/ipc_result_records.py +164 -0
- services/workorders/models/production_batches.py +181 -0
- services/workorders/models/work_orders.py +255 -0
- services/workorders/models/yield_reconciliation_records.py +175 -0
- services/workorders/routers/__init__.py +5 -0
- services/workorders/routers/area_room_usage_ledger.py +117 -0
- services/workorders/routers/batch_execution_records.py +103 -0
- services/workorders/routers/batch_release_workflows.py +86 -0
- services/workorders/routers/batch_step_executions.py +88 -0
- services/workorders/routers/equipment_usage_ledger.py +115 -0
- services/workorders/routers/executed_bmr_records.py +86 -0
- services/workorders/routers/executed_bpr_records.py +86 -0
- services/workorders/routers/executed_instruction_evidence.py +86 -0
- services/workorders/routers/ipc_result_records.py +86 -0
- services/workorders/routers/production_batches.py +86 -0
- services/workorders/routers/work_orders.py +257 -0
- services/workorders/routers/yield_reconciliation_records.py +86 -0
- src/broca/__init__.py +5 -0
- src/broca/agent.py +201 -0
- src/cerebellum/__init__.py +0 -0
- src/cerebellum/adapter.py +84 -0
- src/cerebellum/capabilities/__init__.py +0 -0
- src/cerebellum/capabilities/agent/__init__.py +0 -0
- src/cerebellum/capabilities/agent/agents.py +65 -0
- src/cerebellum/capabilities/ai/__init__.py +0 -0
- src/cerebellum/capabilities/ai/providers.py +106 -0
- src/cerebellum/capabilities/api/__init__.py +0 -0
- src/cerebellum/capabilities/api/graphql.py +35 -0
- src/cerebellum/capabilities/api/rest_api.py +45 -0
- src/cerebellum/capabilities/api/webhook.py +30 -0
- src/cerebellum/capabilities/browser/__init__.py +0 -0
- src/cerebellum/capabilities/browser/browsers.py +27 -0
- src/cerebellum/capabilities/cloud/__init__.py +0 -0
- src/cerebellum/capabilities/cloud/cloud.py +46 -0
- src/cerebellum/capabilities/communication/__init__.py +0 -0
- src/cerebellum/capabilities/communication/communication.py +62 -0
- src/cerebellum/capabilities/database/__init__.py +0 -0
- src/cerebellum/capabilities/database/elasticsearch.py +40 -0
- src/cerebellum/capabilities/database/influxdb.py +37 -0
- src/cerebellum/capabilities/database/mongodb.py +50 -0
- src/cerebellum/capabilities/database/neo4j.py +32 -0
- src/cerebellum/capabilities/database/redis.py +44 -0
- src/cerebellum/capabilities/enterprise/__init__.py +0 -0
- src/cerebellum/capabilities/enterprise/opensource.py +180 -0
- src/cerebellum/capabilities/enterprise/proprietary.py +313 -0
- src/cerebellum/capabilities/event_streaming/__init__.py +0 -0
- src/cerebellum/capabilities/event_streaming/streaming.py +38 -0
- src/cerebellum/capabilities/infrastructure/__init__.py +0 -0
- src/cerebellum/capabilities/infrastructure/infrastructure.py +30 -0
- src/cerebellum/capabilities/productivity/__init__.py +0 -0
- src/cerebellum/capabilities/productivity/productivity.py +158 -0
- src/cerebellum/capabilities/robotics/__init__.py +0 -0
- src/cerebellum/capabilities/robotics/robotics.py +124 -0
- src/cerebellum/capabilities/runtime/__init__.py +0 -0
- src/cerebellum/capabilities/runtime/runtimes.py +92 -0
- src/cerebellum/capabilities/search/__init__.py +0 -0
- src/cerebellum/capabilities/search/search.py +63 -0
- src/cerebellum/capabilities/source_control/__init__.py +0 -0
- src/cerebellum/capabilities/source_control/source_control.py +113 -0
- src/cerebellum/capabilities/storage/__init__.py +0 -0
- src/cerebellum/capabilities/storage/storage.py +94 -0
- src/cerebellum/capabilities/workflow/__init__.py +0 -0
- src/cerebellum/capabilities/workflow/workflows.py +49 -0
- src/cerebellum/capability.py +108 -0
- src/cerebellum/config.py +157 -0
- src/cerebellum/fallback.py +147 -0
- src/cerebellum/fallback_engine.py +121 -0
- src/cerebellum/key_manager.py +129 -0
- src/cerebellum/keystore.py +179 -0
- src/cerebellum/lifecycle.py +54 -0
- src/cerebellum/metadata.py +61 -0
- src/cerebellum/operator/base.py +25 -0
- src/cerebellum/peripheral.py +92 -0
- src/cerebellum/registry.py +98 -0
- src/cerebellum/resolve_entity_capability.py +259 -0
- src/cingulate/benchmark/__init__.py +23 -0
- src/cingulate/benchmark/reporter.py +102 -0
- src/cingulate/benchmark/runner.py +159 -0
- src/cingulate/benchmark/scenario_runner.py +150 -0
- src/cingulate/benchmark/validator.py +102 -0
- src/cingulate/governance/__init__.py +21 -0
- src/cingulate/governance/architecture_validator.py +194 -0
- src/cingulate/governance/compliance.py +104 -0
- src/cingulate/governance/governance.py +77 -0
- src/cingulate/governance/policy_registry.py +91 -0
- src/cortex/__init__.py +33 -0
- src/cortex/cost.py +71 -0
- src/cortex/counterfactual.py +162 -0
- src/cortex/digital_twin.py +90 -0
- src/cortex/experience.py +83 -0
- src/cortex/feedback.py +144 -0
- src/cortex/loss.py +116 -0
- src/cortex/prediction.py +142 -0
- src/cortex/replay.py +130 -0
- src/cortex/reward.py +113 -0
- src/cortex/simulator.py +102 -0
- src/cortex/world_model.py +180 -0
- src/cortex/world_model_simulation.py +1591 -0
- src/cortex/world_state.py +121 -0
- src/cortex/xavier.py +250 -0
- src/deepdive/__init__.py +29 -0
- src/deepdive/aggregation.py +113 -0
- src/deepdive/digital_twin_aggregator.py +128 -0
- src/deepdive/elasticsearch_adapter.py +110 -0
- src/deepdive/fleet_analytics.py +131 -0
- src/deepdive/knowledge_aggregator.py +130 -0
- src/homeostasis/__init__.py +19 -0
- src/homeostasis/control_plane.py +159 -0
- src/introspection/__init__.py +38 -0
- src/introspection/alerting.py +142 -0
- src/introspection/health.py +101 -0
- src/introspection/lemon.py +243 -0
- src/introspection/logging.py +147 -0
- src/introspection/metrics.py +106 -0
- src/introspection/tracing.py +162 -0
- src/monkey_brain/__init__.py +1 -0
- src/monkey_brain/api/main.py +148 -0
- src/monkey_brain/api/models.py +81 -0
- src/monkey_brain/api/routes/routes/keys.py +106 -0
- src/monkey_brain/api/routes/routes/run.py +169 -0
- src/monkey_brain/api/routes/routes/simulate.py +485 -0
- src/monkey_brain/dlm/__init__.py +44 -0
- src/monkey_brain/dlm/dlm.py +139 -0
- src/monkey_brain/dlm/gc.py +115 -0
- src/monkey_brain/dlm/lifecycle.py +149 -0
- src/monkey_brain/dlm/orphans.py +99 -0
- src/monkey_brain/dlm/storage.py +149 -0
- src/monkey_brain/dlm/ttl.py +140 -0
- src/monkey_brain/documents/__init__.py +0 -0
- src/monkey_brain/documents/document_ocr.py +6 -0
- src/monkey_brain/kernel/__init__.py +53 -0
- src/monkey_brain/kernel/capability_interface.py +144 -0
- src/monkey_brain/kernel/classifier/__init__.py +1 -0
- src/monkey_brain/kernel/classifier/embed_classifier.py +125 -0
- src/monkey_brain/kernel/classifier/intent_examples.py +106 -0
- src/monkey_brain/kernel/dag.py +23 -0
- src/monkey_brain/kernel/execution_state.py +257 -0
- src/monkey_brain/kernel/goal_planner.py +85 -0
- src/monkey_brain/kernel/goal_router.py +20 -0
- src/monkey_brain/kernel/goals/__init__.py +1 -0
- src/monkey_brain/kernel/goals/goal.py +130 -0
- src/monkey_brain/kernel/goals/goal_bootstrap.py +38 -0
- src/monkey_brain/kernel/goals/goal_classifier.py +132 -0
- src/monkey_brain/kernel/goals/goal_registry.py +75 -0
- src/monkey_brain/kernel/intents/__init__.py +1 -0
- src/monkey_brain/kernel/intents/event_adapter.py +246 -0
- src/monkey_brain/kernel/intents/helpers.py +13 -0
- src/monkey_brain/kernel/intents/intent_registry.py +705 -0
- src/monkey_brain/kernel/intents/intent_router.py +102 -0
- src/monkey_brain/kernel/intents/predicates/approval_create.py +9 -0
- src/monkey_brain/kernel/intents/predicates/approval_decision.py +9 -0
- src/monkey_brain/kernel/intents/predicates/approval_hold.py +9 -0
- src/monkey_brain/kernel/intents/predicates/approval_query.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_close.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_creation.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_delete.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_hold.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_record.py +9 -0
- src/monkey_brain/kernel/intents/predicates/batch_update.py +9 -0
- src/monkey_brain/kernel/intents/predicates/change_control.py +49 -0
- src/monkey_brain/kernel/intents/predicates/compliance_audit.py +14 -0
- src/monkey_brain/kernel/intents/predicates/decision_intelligence.py +9 -0
- src/monkey_brain/kernel/intents/predicates/drug_research.py +9 -0
- src/monkey_brain/kernel/intents/predicates/fuzzy_match.py +19 -0
- src/monkey_brain/kernel/intents/predicates/production_kpi.py +9 -0
- src/monkey_brain/kernel/intents/predicates/sop_create.py +9 -0
- src/monkey_brain/kernel/intents/predicates/sop_query.py +9 -0
- src/monkey_brain/kernel/intents/predicates/sop_update.py +9 -0
- src/monkey_brain/kernel/intents/predicates/warehouse_shipping.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_create.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_delete.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_hold.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_query.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_status.py +9 -0
- src/monkey_brain/kernel/intents/predicates/work_order_update.py +9 -0
- src/monkey_brain/kernel/intents/predicates/worker.py +9 -0
- src/monkey_brain/kernel/intents/telemetry_adapter.py +274 -0
- src/monkey_brain/kernel/intents/utils.py +68 -0
- src/monkey_brain/kernel/learning.py +98 -0
- src/monkey_brain/kernel/llm_explorer.py +188 -0
- src/monkey_brain/kernel/loss.py +81 -0
- src/monkey_brain/kernel/nlp/__init__.py +1 -0
- src/monkey_brain/kernel/nlp/compat.py +23 -0
- src/monkey_brain/kernel/nlp/models.py +10 -0
- src/monkey_brain/kernel/nlp/question_analyzer.py +203 -0
- src/monkey_brain/kernel/nlp/spacy_parser.py +53 -0
- src/monkey_brain/kernel/observer.py +97 -0
- src/monkey_brain/kernel/parser/__init__.py +3 -0
- src/monkey_brain/kernel/parser/ast.py +28 -0
- src/monkey_brain/kernel/parser/extractors/__init__.py +11 -0
- src/monkey_brain/kernel/parser/extractors/entities.py +21 -0
- src/monkey_brain/kernel/parser/extractors/filters.py +16 -0
- src/monkey_brain/kernel/parser/extractors/projections.py +36 -0
- src/monkey_brain/kernel/parser/extractors/verbs.py +31 -0
- src/monkey_brain/kernel/parser/parser.py +57 -0
- src/monkey_brain/kernel/parser/rules.py +75 -0
- src/monkey_brain/kernel/pipeline.py +44 -0
- src/monkey_brain/kernel/planner.py +57 -0
- src/monkey_brain/kernel/rl/__init__.py +33 -0
- src/monkey_brain/kernel/rl/learner.py +98 -0
- src/monkey_brain/kernel/rl/policy.py +254 -0
- src/monkey_brain/kernel/rl/reward.py +117 -0
- src/monkey_brain/kernel/rl/transition.py +112 -0
- src/monkey_brain/persistence/__init__.py +47 -0
- src/monkey_brain/persistence/adapters.py +49 -0
- src/monkey_brain/persistence/events.py +105 -0
- src/monkey_brain/persistence/manager.py +124 -0
- src/monkey_brain/persistence/mongodb_adapter.py +91 -0
- src/monkey_brain/persistence/redis_adapter.py +93 -0
- src/monkey_brain/persistence/reducer.py +111 -0
- src/monkey_brain/runtime/__init__.py +49 -0
- src/monkey_brain/runtime/depedencies.py +8 -0
- src/monkey_brain/runtime/engine.py +183 -0
- src/monkey_brain/runtime/message_bus.py +82 -0
- src/monkey_brain/runtime/process.py +144 -0
- src/monkey_brain/runtime/resource_manager.py +100 -0
- src/monkey_brain/runtime/routers.py +8 -0
- src/monkey_brain/runtime/runtime.py +199 -0
- src/monkey_brain/runtime/scheduler.py +165 -0
- src/monkey_brain/runtime/supervisor.py +133 -0
- src/monkey_brain/runtime/worker_pool.py +111 -0
- src/plasticity/seed/__init__.py +30 -0
- src/plasticity/seed/benchmark_generator.py +105 -0
- src/plasticity/seed/event_generator.py +122 -0
- src/plasticity/seed/scenario_builder.py +134 -0
- src/plasticity/seed/seed_data.py +206 -0
- src/plasticity/seed/seeder.py +122 -0
- src/plasticity/testing/__init__.py +28 -0
- src/plasticity/testing/performance.py +131 -0
- src/plasticity/testing/profiler.py +255 -0
- src/plasticity/testing/reporter.py +84 -0
- src/plasticity/testing/runner.py +209 -0
- src/sync/__init__.py +12 -0
- src/sync/cloud_aggregator.py +63 -0
- src/sync/edge_node.py +51 -0
- 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("&", "&")
|
|
1356
|
+
.replace("<", "<")
|
|
1357
|
+
.replace(">", ">")
|
|
1358
|
+
.replace('"', """)
|
|
1359
|
+
.replace("'", "'")
|
|
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)
|