Coverage for backend/django/core/auxiliary/views/SolveView.py: 87%
147 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1import logging
2import traceback
3from django.views.decorators.csrf import csrf_exempt
4from pydantic import ValidationError as PydanticValidationError
5from authentication.custom_drf_authentication import DaprApiTokenAuthentication
6from common.models.idaes.payloads.solve_request_schema import (
7 IdaesSolveCompletionEvent,
8 DispatchMultiSolveEvent,
9)
10from common.models.idaes.payloads.build_state_request_schema import (
11 BuildStateCompletionEvent,
12 BuildStateRequestEvent,
13)
14from common.models.solve_completion_email import SolveCompletionEmailRequestEvent
15from core.auxiliary.serializers import TaskSerializer
16from idaes_factory import endpoints
17from pgraph_factory.pg_sheet import PgProcess
18from drf_spectacular.utils import extend_schema
19from rest_framework.response import Response
20from rest_framework.decorators import api_view, authentication_classes, permission_classes
21from rest_framework.exceptions import NotFound
22from rest_framework.permissions import IsAuthenticated
23from rest_framework import serializers, status
24from flowsheetInternals.unitops.models.SimulationObject import SimulationObject
25from core.auxiliary.models.Flowsheet import Flowsheet
26from core.auxiliary.models import Task
27from idaes_factory.endpoints import (
28 cancel_idaes_solve,
29 process_idaes_solve_response,
30 start_flowsheet_solve_event,
31 start_multi_steady_state_solve_event,
32 process_failed_idaes_solve_response,
33 process_build_state_response,
34 process_build_state_request_dead_letter,
35)
36from idaes_factory.idaes_factory_context import LiveSolveParams
37from core.auxiliary.services.solve_completion_email import deliver_solve_completion_email
38from core.validation import api_view_ignore_access_control, api_view_validate
39from core.auxiliary.models.Scenario import Scenario, ScenarioTabTypeEnum
40from core.auxiliary.models.Task import Task
41from core.managers import (
42 StaleFlowsheetState,
43 get_flowsheet_access,
44 has_flowsheet_write_access,
45)
47logger = logging.getLogger(__name__)
50class SolveRequestSerializer(serializers.Serializer):
51 group_id = serializers.IntegerField(required=True)
52 debug = serializers.BooleanField(required=False)
53 require_variables_fixed = serializers.BooleanField(required=False)
54 scenario_number = serializers.IntegerField(required=False, allow_null=True)
55 perform_diagnostics = serializers.BooleanField(
56 required=False, default=False
57 ) # currently, this doesn't do anything on MSS solves.
60def create_error(message, cause) -> Response:
61 """Build a standardised error response payload for solve requests.
63 Args:
64 message: Human-readable description of the failure.
65 cause: Short identifier describing which phase failed.
67 Returns:
68 REST response with a 400 status code and diagnostic metadata.
69 """
70 return Response(
71 status=400,
72 data={
73 "status": "error",
74 "error": {
75 "message": message,
76 "cause": cause,
77 "traceback": traceback.format_exc(),
78 },
79 "log": None,
80 "debug": {"input_flowsheet": None, "output_flowsheet": None, "timing": {}},
81 },
82 )
85@api_view_validate
86@extend_schema(request=SolveRequestSerializer, responses=TaskSerializer)
87@api_view(["POST"])
88def solve_idaes(request) -> Response:
89 """Dispatch a solve request to either IDAES or the process-graph solver."""
90 # Validate the request data
91 try:
92 serializer = SolveRequestSerializer(data=request.data)
93 serializer.is_valid(raise_exception=True)
94 validated_data = serializer.validated_data
96 flowsheet_id = request.query_params.get('flowsheet')
97 group_id = validated_data.get('group_id')
98 scenario_number: int = validated_data.get('scenario_number')
99 perform_diagnostics: bool = validated_data.get(
100 'perform_diagnostics', False)
102 except Exception as e:
103 return create_error("Invalid request data", "validation")
105 # Create the factory
106 # This is where the flowsheet should be loaded from the database
107 try:
108 access_state = get_flowsheet_access(request.user, flowsheet_id)
109 if access_state.has_read_access and not access_state.has_write_access:
110 return Response(
111 {"error": "This flowsheet is shared with read-only access."},
112 status=status.HTTP_403_FORBIDDEN,
113 )
115 # get the optimisation that matches the flowsheet
116 scenario = Scenario.objects.filter(id=scenario_number).first()
118 if scenario and scenario.state_name == ScenarioTabTypeEnum.MultiSteadyState:
119 return start_multi_steady_state_solve_event(
120 flowsheet_id, request.user, scenario
121 )
123 # TODO: Start using is_optimization to determine if to use the optimisation or not.
124 # Stop sending multiple optimisations to the solver, just the scenario one.
126 # Check the existence of a decision node
127 if ( 127 ↛ 137line 127 didn't jump to line 137 because the condition on line 127 was always true
128 SimulationObject.objects.filter(
129 objectType="decisionNode",
130 flowsheet_state__flowsheet_id=flowsheet_id,
131 ).count()
132 == 0
133 ):
134 # Run as normal
135 return start_flowsheet_solve_event(flowsheet_id, group_id, request.user, scenario, perform_diagnostics=perform_diagnostics)
136 else:
137 pgraph_factory = PgProcess(flowsheet_id)
138 pgraph_factory.solve()
139 pgraph_factory.create_process_paths()
141 return Response(
142 status=200,
143 data=[
144 [block.componentName for block in solution]
145 for solution in pgraph_factory.solutions
146 ],
147 )
148 except StaleFlowsheetState:
149 raise
150 except Exception as e:
151 return create_error(str(e), "idaes_factory_run")
154@extend_schema(exclude=True)
155@api_view(["POST"])
156@authentication_classes([DaprApiTokenAuthentication])
157@permission_classes([IsAuthenticated])
158@csrf_exempt
159def process_idaes_solve_completion_event(request) -> Response:
160 """Handle a solve completion event (sent by Dapr) from the IDAES service."""
161 solve_response = IdaesSolveCompletionEvent.model_validate(request.data)
162 solve_data = solve_response.data
164 process_idaes_solve_response(solve_data)
166 return Response(status=200)
169@extend_schema(exclude=True)
170@api_view(["POST"])
171@authentication_classes([DaprApiTokenAuthentication])
172@permission_classes([IsAuthenticated])
173@csrf_exempt
174def process_failed_idaes_solve_event(request) -> Response:
175 """
176 This endpoint is used to process solve completion events that were not received or processed
177 by Django correctly. Errors could be due to crashes, reaching the message TTL, concurrency issues, etc.
178 This will allow unprocessed solve tasks to be marked as failed and notify the user.
179 """
180 solve_response = IdaesSolveCompletionEvent.model_validate(request.data)
181 solve_data = solve_response.data
183 process_failed_idaes_solve_response(solve_data)
185 return Response(status=200)
188@extend_schema(exclude=True)
189@api_view(["POST"])
190@authentication_classes([DaprApiTokenAuthentication])
191@permission_classes([IsAuthenticated])
192@csrf_exempt
193def process_build_state_response_event(request) -> Response:
194 """Handle Dapr-delivered build-state completion events from IDAES."""
195 build_state_response = BuildStateCompletionEvent.model_validate(request.data)
196 process_build_state_response(build_state_response.data)
198 return Response(status=200)
201@extend_schema(exclude=True)
202@api_view(["POST"])
203@authentication_classes([DaprApiTokenAuthentication])
204@permission_classes([IsAuthenticated])
205@csrf_exempt
206def process_build_state_request_dead_letter_event(request) -> Response:
207 """Handle dead-lettered build-state requests that never reached IDAES."""
208 try:
209 build_state_request = BuildStateRequestEvent.model_validate(request.data)
210 except PydanticValidationError:
211 logger.warning(
212 "Discarding malformed build-state request dead-letter event.",
213 exc_info=True,
214 )
215 return Response(status=200)
217 process_build_state_request_dead_letter(build_state_request.data)
218 return Response(status=200)
221@extend_schema(exclude=True)
222@api_view(["POST"])
223@authentication_classes([DaprApiTokenAuthentication])
224@permission_classes([IsAuthenticated])
225@csrf_exempt
226def process_dispatch_multi_solve(request) -> Response:
227 """
228 This endpoint is used to process dispatch multi-solve events sent via the primary
229 solve endpoint when the scenario is a multi-steady state scenario.
230 """
232 dispatch_request = DispatchMultiSolveEvent.model_validate(request.data)
233 multi_solve_payload = dispatch_request.data
235 endpoints.dispatch_multi_solves(
236 multi_solve_payload.task_id, multi_solve_payload.scenario_id
237 )
239 return Response(status=200)
242@extend_schema(exclude=True)
243@api_view(["POST"])
244@authentication_classes([DaprApiTokenAuthentication])
245@permission_classes([IsAuthenticated])
246@csrf_exempt
247@api_view_ignore_access_control
248def process_solve_completion_email_event(request) -> Response:
249 """Handle Dapr-delivered solve completion email requests."""
250 email_event = SolveCompletionEmailRequestEvent.model_validate(request.data)
251 deliver_solve_completion_email(email_event.data)
252 return Response(status=200)
255class CancelTaskRequestSerializer(serializers.Serializer):
256 task_id = serializers.IntegerField()
259@extend_schema(request=CancelTaskRequestSerializer)
260@api_view_validate
261@api_view(["POST"])
262def cancel_idaes_solve_handler(request) -> Response:
263 """Accept a client request to cancel a pending or running solve task."""
264 cancel_request_serializer = CancelTaskRequestSerializer(data=request.data)
265 cancel_request_serializer.is_valid(raise_exception=True)
266 cancel_request = cancel_request_serializer.validated_data
268 task_id = cancel_request.get("task_id")
270 flowsheet_id = int(request.query_params.get("flowsheet"))
271 if not has_flowsheet_write_access(request.user, flowsheet_id): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 return Response(
273 {"error": "You do not have write access to this flowsheet."},
274 status=status.HTTP_403_FORBIDDEN,
275 )
277 task = (
278 Task.objects
279 .filter(id=task_id, flowsheet_id=flowsheet_id)
280 .first()
281 )
282 if task is None:
283 return Response(
284 {"error": "Task not found for this flowsheet."},
285 status=status.HTTP_404_NOT_FOUND,
286 )
288 # Need to add task_id query parameter
289 cancel_idaes_solve(task_id)
291 return Response(status=200)