Coverage for backend/django/diagnostics/views.py: 38%

122 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-02-12 01:47 +0000

1""" 

2Diagnostics API views. 

3 

4These endpoints exist to support the diagnostics UI: 

5- Retrieve persisted `DiagnosticRun`s (task-triggered, evented) 

6- Run light-weight "what if?" validations used by the properties panel 

7 

8The goal is to keep views fairly thin: parse/validate the request, fetch the 

9minimal DB state needed, then delegate to the diagnostics rules engine. 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15 

16from django.db.models import Prefetch 

17from drf_spectacular.types import OpenApiTypes 

18from drf_spectacular.utils import OpenApiParameter, extend_schema 

19from rest_framework import status 

20from rest_framework.decorators import api_view 

21from rest_framework.response import Response 

22 

23from core.validation import api_view_validate 

24from diagnostics.models import DiagnosticRun 

25from diagnostics.rules.engine import build_rule_context, evaluate_rules, load_decision_model, evaluate_property_rules 

26from diagnostics.services import validate_property_change 

27from diagnostics.serializers import ( 

28 DiagnosticRunListSerializer, 

29 DiagnosticRunSerializer, 

30 PreSolveValidationSerializer, 

31 PropertyRuleFindingsSerializer, 

32 RuleValidateRequestSerializer, 

33 RunEventsSerializer, 

34 RunSummarySerializer, 

35) 

36from core.auxiliary.models.PropertyInfo import PropertyInfo 

37from core.auxiliary.models.PropertySet import PropertySet 

38from core.auxiliary.models.PropertyValue import PropertyValue 

39from flowsheetInternals.unitops.models.SimulationObject import SimulationObject 

40 

41logger = logging.getLogger(__name__) 

42 

43 

44def _get_simulation_object_for_rules(object_id: int, *, include_indexed_items: bool) -> SimulationObject: 

45 """ 

46 Fetch a SimulationObject with properties eagerly loaded for rule evaluation. 

47 

48 I prefetch properties/values here to avoid N+1 queries when we walk through 

49 all properties during rule evaluation. The `include_indexed_items` flag is 

50 for pre-solve validation where we need to temporarily mutate indexed values. 

51 """ 

52 qs = SimulationObject.objects.filter(id=object_id).select_related("properties") 

53 values_qs = PropertyValue.objects.select_related( 

54 "controlManipulated", 

55 "controlSetPoint", 

56 ) 

57 if include_indexed_items: 

58 values_qs = values_qs.prefetch_related("indexedItems") 

59 

60 return qs.prefetch_related( 

61 Prefetch( 

62 "properties__ContainedProperties", 

63 queryset=PropertyInfo.objects.select_related("recycleConnection").prefetch_related( 

64 Prefetch("values", queryset=values_qs) 

65 ), 

66 ) 

67 ).get() 

68 

69 

70 

71 

72 

73 

74# ============================================================================ 

75# Diagnostic Run Endpoints 

76# ============================================================================ 

77# These endpoints serve the Diagnostics tab UI. They're intentionally thin: 

78# I fetch the persisted DiagnosticRun rows and return them as-is. The heavy 

79# lifting (rulesets, findings, events) happens in the orchestrator. 

80 

81 

82@extend_schema( 

83 operation_id="diagnostics_runs_list", 

84 parameters=[ 

85 OpenApiParameter( 

86 name="flowsheet", 

87 type=OpenApiTypes.INT, 

88 location=OpenApiParameter.QUERY, 

89 required=True, 

90 ), 

91 ], 

92 responses={200: DiagnosticRunListSerializer(many=True)}, 

93) 

94@api_view_validate 

95@api_view(["GET"]) 

96def list_runs(request) -> Response: 

97 """List diagnostic runs for the current flowsheet.""" 

98 try: 

99 flowsheet_id = int(request.query_params.get("flowsheet")) 

100 except ValueError: 

101 return Response({"error": "flowsheet id must be an integer"}, status=status.HTTP_400_BAD_REQUEST) 

102 

103 runs = DiagnosticRun.objects.filter(flowsheet_id=flowsheet_id).order_by("-created_at")[:50] 

104 serializer = DiagnosticRunListSerializer(runs, many=True) 

105 return Response(serializer.data) 

106 

107 

108@extend_schema( 

109 operation_id="diagnostics_runs_retrieve", 

110 parameters=[ 

111 OpenApiParameter(name="run_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH) 

112 ], 

113 responses={200: DiagnosticRunSerializer}, 

114) 

115@api_view_validate 

116@api_view(["GET"]) 

117def retrieve_run(request, run_id: int) -> Response: 

118 """Retrieve a single diagnostic run.""" 

119 

120 

121 try: 

122 run = DiagnosticRun.objects.get(id=run_id) 

123 except DiagnosticRun.DoesNotExist: 

124 return Response(status=status.HTTP_404_NOT_FOUND) 

125 

126 serializer = DiagnosticRunSerializer(run) 

127 return Response(serializer.data) 

128 

129 

130# TODO: Frontend should derive latest run from list_runs response (runs[0]) instead of 

131# making a separate API call. The get_latest_run endpoint was removed as redundant. 

132 

133@extend_schema( 

134 operation_id="diagnostics_run_summary", 

135 parameters=[ 

136 OpenApiParameter(name="run_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH) 

137 ], 

138 responses={200: RunSummarySerializer}, 

139) 

140@api_view_validate 

141@api_view(["GET"]) 

142def get_run_summary(request, run_id: int) -> Response: 

143 """Get summary for a diagnostic run.""" 

144 try: 

145 run = DiagnosticRun.objects.get(id=run_id) 

146 except DiagnosticRun.DoesNotExist: 

147 return Response(status=status.HTTP_404_NOT_FOUND) 

148 # TODO: Frontend should extract findings from summary.findings instead of 

149 # calling a separate endpoint. The summary already contains all findings. 

150 return Response( 

151 { 

152 "run_id": run.id, 

153 "status": run.diagnostic_status, 

154 "summary": run.summary, 

155 } 

156 ) 

157 

158 

159@extend_schema( 

160 operation_id="diagnostics_run_events", 

161 parameters=[ 

162 OpenApiParameter(name="run_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH) 

163 ], 

164 responses={200: RunEventsSerializer}, 

165) 

166@api_view_validate 

167@api_view(["GET"]) 

168def get_run_events(request, run_id: int) -> Response: 

169 """Get diagnostic events for a run.""" 

170 try: 

171 run = DiagnosticRun.objects.get(id=run_id) 

172 except DiagnosticRun.DoesNotExist: 

173 return Response(status=status.HTTP_404_NOT_FOUND) 

174 

175 events = list(run.events.order_by("id").values("id", "ts", "type", "data")) 

176 return Response({"run_id": run.id, "events": events}) 

177 

178 

179 

180 

181 

182# ============================================================================ 

183# Property Rule Endpoints 

184# ============================================================================ 

185# These are the validation endpoints used by the Properties panel. 

186 

187 

188 

189@extend_schema( 

190 operation_id="diagnostics_evaluate_object_property_rules", 

191 parameters=[ 

192 OpenApiParameter(name="object_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH), 

193 OpenApiParameter( 

194 name="flowsheet", 

195 type=OpenApiTypes.INT, 

196 location=OpenApiParameter.QUERY, 

197 required=True, 

198 ), 

199 ], 

200 responses={200: PropertyRuleFindingsSerializer}, 

201) 

202@api_view_validate 

203@api_view(["GET"]) 

204def evaluate_object_property_rules(request, object_id: int) -> Response: 

205 """ 

206 Evaluate deterministic diagnostics rules against a unit op's current properties. 

207 Intended for inline properties-panel warnings/suggestions/errors. 

208 """ 

209 try: 

210 obj = _get_simulation_object_for_rules(object_id, include_indexed_items=False) 

211 except SimulationObject.DoesNotExist: 

212 return Response(status=status.HTTP_404_NOT_FOUND) 

213 

214 findings = evaluate_property_rules(obj) 

215 return Response({"object_id": obj.id, "findings": [f.to_dict() for f in findings]}) 

216 

217 

218@extend_schema( 

219 operation_id="diagnostics_rules_get", 

220 responses={200: OpenApiTypes.OBJECT}, 

221) 

222@api_view_validate 

223@api_view(["GET"]) 

224def get_rules_jdm(request) -> Response: 

225 """Return the JSON Decision Model (JDM) for frontend rule evaluation.""" 

226 return Response({"jdm": load_decision_model()}) 

227 

228 

229@extend_schema( 

230 operation_id="validate_value_against_rule", 

231 parameters=[ 

232 OpenApiParameter( 

233 name="flowsheet", 

234 type=OpenApiTypes.INT, 

235 location=OpenApiParameter.QUERY, 

236 required=True, 

237 ), 

238 ], 

239 request=RuleValidateRequestSerializer, 

240 responses={200: PropertyRuleFindingsSerializer}, 

241) 

242@api_view_validate 

243@api_view(["POST"]) 

244def validate_value_against_rule(request) -> Response: 

245 """ 

246 Validate a single property value against deterministic rules. 

247 Intended for frontend inline validation without persisting the value. 

248  

249 This endpoint is simpler than pre_solve_validation: it only needs 

250 object_id, property_key, and value. Use this for basic inline validation 

251 where you don't need to handle indexed properties or property_value_id. 

252 """ 

253 # This is a compute-only endpoint: 

254 # - No DB writes 

255 # - Used for inline validation while the user is typing 

256 serializer = RuleValidateRequestSerializer(data=request.data) 

257 if not serializer.is_valid(): 

258 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 

259 

260 data = serializer.validated_data 

261 object_id = data["object_id"] 

262 property_key = data["property_key"] 

263 value = data["value"] 

264 

265 try: 

266 obj = _get_simulation_object_for_rules(object_id, include_indexed_items=False) 

267 except SimulationObject.DoesNotExist: 

268 return Response(status=status.HTTP_404_NOT_FOUND) 

269 

270 try: 

271 prop_set = obj.properties 

272 except PropertySet.DoesNotExist: 

273 return Response({"object_id": obj.id, "findings": []}) 

274 

275 prop = prop_set.ContainedProperties.filter(key=property_key).first() 

276 if not prop: 

277 # If the property isn't found, treat it as "no rules apply" rather than erroring. 

278 return Response({"object_id": obj.id, "findings": []}) 

279 

280 

281 

282 context = build_rule_context(obj, prop, value) 

283 findings = [f.to_dict() for f in evaluate_rules(context)] #convert to dict for json serialization 

284 return Response({"object_id": obj.id, "findings": findings}) 

285 

286 

287# ============================================================================ 

288# Pre-Solve Validation 

289# ============================================================================ 

290 

291 

292@extend_schema( 

293 operation_id="diagnostics_pre_solve_validation", 

294 parameters=[ 

295 OpenApiParameter(name="flowsheet", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), 

296 ], 

297 request=PreSolveValidationSerializer, 

298 responses={200: PropertyRuleFindingsSerializer}, 

299) 

300@api_view_validate 

301@api_view(["POST"]) 

302def pre_solve_validation(request) -> Response: 

303 """ 

304 Pre-solve validation for a *proposed* property change (compute-only). 

305 

306 This endpoint extends validate_value_against_rule with support for indexed 

307 properties. It exists separately because: 

308 1. It accepts property_id OR property_key (more flexible property resolution) 

309 2. It handles property_value_id for indexed properties (e.g., mole_frac_comp[CO2]) 

310 3. It uses the service layer for "what-if" validation with full property resolution 

311  

312 Use this endpoint when validating changes to indexed properties or when you need 

313 the full property resolution logic. Use validate_value_against_rule for simpler cases. 

314 

315 This endpoint is intentionally NOT persisted: 

316 - It exists to support the UI while the user is editing a value. 

317 - It should be safe to call frequently (e.g. on blur / debounce). 

318 - It should not create DiagnosticRun rows or write events. 

319 """ 

320 serializer = PreSolveValidationSerializer(data=request.data) 

321 if not serializer.is_valid(): 

322 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 

323 

324 data = serializer.validated_data 

325 

326 try: 

327 obj = _get_simulation_object_for_rules(data["object_id"], include_indexed_items=True) 

328 except SimulationObject.DoesNotExist: 

329 return Response(status=status.HTTP_404_NOT_FOUND) 

330 

331 # Delegate to service function (see services.py for the logic) 

332 findings, error = validate_property_change( 

333 obj=obj, 

334 property_id=data.get("property_id"), 

335 property_key=data.get("property_key"), 

336 property_value_id=data.get("property_value_id"), 

337 new_value=data.get("new_value"), 

338 ) 

339 

340 if error: 

341 # Return appropriate status based on error type 

342 if "not found" in error.get("error", "").lower(): 

343 return Response(error, status=status.HTTP_404_NOT_FOUND) 

344 return Response(error, status=status.HTTP_400_BAD_REQUEST) 

345 

346 return Response({"object_id": obj.id, "findings": findings})