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

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) 

46 

47logger = logging.getLogger(__name__) 

48 

49 

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. 

58 

59 

60def create_error(message, cause) -> Response: 

61 """Build a standardised error response payload for solve requests. 

62 

63 Args: 

64 message: Human-readable description of the failure. 

65 cause: Short identifier describing which phase failed. 

66 

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 ) 

83 

84 

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 

95 

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) 

101 

102 except Exception as e: 

103 return create_error("Invalid request data", "validation") 

104 

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 ) 

114 

115 # get the optimisation that matches the flowsheet 

116 scenario = Scenario.objects.filter(id=scenario_number).first() 

117 

118 if scenario and scenario.state_name == ScenarioTabTypeEnum.MultiSteadyState: 

119 return start_multi_steady_state_solve_event( 

120 flowsheet_id, request.user, scenario 

121 ) 

122 

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. 

125 

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() 

140 

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") 

152 

153 

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 

163 

164 process_idaes_solve_response(solve_data) 

165 

166 return Response(status=200) 

167 

168 

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 

182 

183 process_failed_idaes_solve_response(solve_data) 

184 

185 return Response(status=200) 

186 

187 

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) 

197 

198 return Response(status=200) 

199 

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) 

216 

217 process_build_state_request_dead_letter(build_state_request.data) 

218 return Response(status=200) 

219 

220 

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 """ 

231 

232 dispatch_request = DispatchMultiSolveEvent.model_validate(request.data) 

233 multi_solve_payload = dispatch_request.data 

234 

235 endpoints.dispatch_multi_solves( 

236 multi_solve_payload.task_id, multi_solve_payload.scenario_id 

237 ) 

238 

239 return Response(status=200) 

240 

241 

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) 

253 

254 

255class CancelTaskRequestSerializer(serializers.Serializer): 

256 task_id = serializers.IntegerField() 

257 

258 

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 

267 

268 task_id = cancel_request.get("task_id") 

269 

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 ) 

276 

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 ) 

287 

288 # Need to add task_id query parameter 

289 cancel_idaes_solve(task_id) 

290 

291 return Response(status=200)