Coverage for backend/django/flowsheetInternals/unitops/services/edit_operations/replay.py: 82%

213 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1from __future__ import annotations 

2 

3from collections.abc import Iterable 

4from typing import Any 

5 

6from django.db import models 

7from rest_framework.exceptions import ValidationError 

8 

9from core.auxiliary.models.Flowsheet import Flowsheet 

10 

11from .context import replay_in_progress 

12from .lifecycle import after_replay, before_replay_deletions 

13from .scope import ( 

14 HistoryModelSpec, 

15 get_history_model_spec, 

16 resolve_instance_flowsheet_id, 

17 resolve_instance_flowsheet_state_id, 

18 resolve_row_flowsheet_id, 

19 resolve_row_flowsheet_state_id, 

20) 

21 

22 

23VALID_ACTIONS = frozenset({"create", "update", "delete"}) 

24 

25 

26def _python_value(field: models.Field, value: Any) -> Any: 

27 if value is None: 

28 return None 

29 return field.to_python(value) 

30 

31 

32def _deserialize_fields( 

33 spec: HistoryModelSpec, 

34 values: dict[str, Any], 

35) -> dict[str, Any]: 

36 fields_by_name = {field.attname: field for field in spec.fields} 

37 unknown = set(values) - set(fields_by_name) 

38 if unknown: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 raise ValidationError( 

40 {"detail": f"This edit contains unsupported fields: {sorted(unknown)}."} 

41 ) 

42 return { 

43 name: _python_value(fields_by_name[name], value) 

44 for name, value in values.items() 

45 } 

46 

47 

48def _validate_create_identity( 

49 spec: HistoryModelSpec, 

50 *, 

51 pk: Any, 

52 fields: dict[str, Any], 

53) -> None: 

54 """Require the payload identity to match the complete created row.""" 

55 pk_name = spec.model._meta.pk.attname 

56 if str(fields[pk_name]) != str(pk): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 raise ValidationError( 

58 {"detail": "A created history row has an inconsistent identity."} 

59 ) 

60 

61 

62def _normalize_changes(payload: dict, flowsheet: Flowsheet) -> list[dict]: 

63 if payload.get("handler") != "row_change_set": 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 raise ValidationError({"detail": "This edit operation type is not supported."}) 

65 raw_changes = payload.get("changes") 

66 if not isinstance(raw_changes, list): 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true

67 raise ValidationError({"detail": "The edit operation payload is invalid."}) 

68 

69 normalized: list[dict] = [] 

70 seen: set[tuple[str, str]] = set() 

71 for raw_change in raw_changes: 

72 if not isinstance(raw_change, dict): 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 raise ValidationError({"detail": "The edit operation payload is invalid."}) 

74 model_label = str(raw_change.get("model", "")).lower() 

75 spec = get_history_model_spec(model_label) 

76 action = raw_change.get("action") 

77 pk = raw_change.get("pk") 

78 if spec is None or action not in VALID_ACTIONS or pk is None: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 raise ValidationError({"detail": "The edit operation payload is invalid."}) 

80 key = (model_label, str(pk)) 

81 if key in seen: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 raise ValidationError({"detail": "The edit operation payload is invalid."}) 

83 seen.add(key) 

84 

85 fields = raw_change.get("fields") 

86 if action == "delete": 

87 if fields is not None: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 raise ValidationError( 

89 {"detail": "The edit operation payload is invalid."} 

90 ) 

91 deserialized_fields = None 

92 else: 

93 if not isinstance(fields, dict): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 raise ValidationError( 

95 {"detail": "The edit operation payload is invalid."} 

96 ) 

97 deserialized_fields = _deserialize_fields(spec, fields) 

98 if action == "create" and set(deserialized_fields) != spec.field_names: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise ValidationError( 

100 {"detail": "A created history row does not contain complete state."} 

101 ) 

102 if action == "create": 

103 _validate_create_identity( 

104 spec, 

105 pk=pk, 

106 fields=deserialized_fields, 

107 ) 

108 if ( 108 ↛ 112line 108 didn't jump to line 112 because the condition on line 108 was never true

109 action == "update" 

110 and spec.model._meta.pk.attname in deserialized_fields 

111 ): 

112 raise ValidationError( 

113 {"detail": "An edit cannot change a row identity."} 

114 ) 

115 

116 existing = spec.model._base_manager.filter(pk=pk).first() 

117 if action != "create": 

118 if existing is None: 

119 raise ValidationError( 

120 {"detail": "A row referenced by this edit no longer exists."} 

121 ) 

122 normalized.append( 

123 { 

124 "model": model_label, 

125 "pk": pk, 

126 "action": action, 

127 "fields": deserialized_fields, 

128 "spec": spec, 

129 "existing": existing, 

130 } 

131 ) 

132 

133 pending_creates = { 

134 (change["model"], str(change["pk"])): change 

135 for change in normalized 

136 if change["action"] == "create" 

137 } 

138 

139 def pending_owner(change: dict, visiting: set[tuple[str, str]]) -> int | None: 

140 key = (change["model"], str(change["pk"])) 

141 if key in visiting: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 return None 

143 visiting.add(key) 

144 fields = change["fields"] or {} 

145 owner_id = resolve_row_flowsheet_id(change["spec"], fields) 

146 if owner_id is not None: 

147 visiting.remove(key) 

148 return owner_id 

149 for field in change["spec"].fields: 149 ↛ 162line 149 didn't jump to line 162 because the loop on line 149 didn't complete

150 if not isinstance(field, models.ForeignKey): 

151 continue 

152 related_id = fields.get(field.attname) 

153 related_spec = get_history_model_spec(field.remote_field.model) 

154 if related_id is None or related_spec is None: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 continue 

156 related_change = pending_creates.get((related_spec.label, str(related_id))) 

157 if related_change is not None: 157 ↛ 149line 157 didn't jump to line 149 because the condition on line 157 was always true

158 owner_id = pending_owner(related_change, visiting) 

159 if owner_id is not None: 159 ↛ 149line 159 didn't jump to line 149 because the condition on line 159 was always true

160 visiting.remove(key) 

161 return owner_id 

162 visiting.remove(key) 

163 return None 

164 

165 def pending_state(change: dict, visiting: set[tuple[str, str]]) -> int | None: 

166 """Resolve state ownership through rows created in this payload.""" 

167 key = (change["model"], str(change["pk"])) 

168 if key in visiting: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true

169 return None 

170 visiting.add(key) 

171 fields = change["fields"] or {} 

172 state_id = resolve_row_flowsheet_state_id(change["spec"], fields) 

173 if state_id is not None: 

174 visiting.remove(key) 

175 return state_id 

176 for field in change["spec"].fields: 176 ↛ 189line 176 didn't jump to line 189 because the loop on line 176 didn't complete

177 if not isinstance(field, models.ForeignKey): 

178 continue 

179 related_id = fields.get(field.attname) 

180 related_spec = get_history_model_spec(field.remote_field.model) 

181 if related_id is None or related_spec is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 continue 

183 related_change = pending_creates.get((related_spec.label, str(related_id))) 

184 if related_change is not None: 184 ↛ 176line 184 didn't jump to line 176 because the condition on line 184 was always true

185 state_id = pending_state(related_change, visiting) 

186 if state_id is not None: 186 ↛ 176line 186 didn't jump to line 176 because the condition on line 186 was always true

187 visiting.remove(key) 

188 return state_id 

189 visiting.remove(key) 

190 return None 

191 

192 def validate_foreign_keys(change: dict) -> None: 

193 """Reject missing and cross-flowsheet relations before replay writes.""" 

194 fields = change["fields"] or {} 

195 for field in change["spec"].fields: 

196 if not isinstance(field, models.ForeignKey) or field.attname not in fields: 

197 continue 

198 related_id = fields[field.attname] 

199 if related_id is None: 

200 continue 

201 

202 related_model = field.remote_field.model 

203 related_spec = get_history_model_spec(related_model) 

204 related_change = ( 

205 pending_creates.get((related_spec.label, str(related_id))) 

206 if related_spec is not None 

207 else None 

208 ) 

209 if related_change is not None: 

210 related_owner_id = pending_owner(related_change, set()) 

211 related_state_id = pending_state(related_change, set()) 

212 else: 

213 related_instance = related_model._base_manager.filter( 

214 pk=related_id 

215 ).first() 

216 if related_instance is None: 

217 raise ValidationError( 

218 {"detail": "This edit references a row that no longer exists."} 

219 ) 

220 if related_model is Flowsheet: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 related_owner_id = related_instance.pk 

222 related_state_id = None 

223 elif related_spec is not None: 

224 related_owner_id = resolve_instance_flowsheet_id(related_instance) 

225 related_state_id = resolve_instance_flowsheet_state_id( 

226 related_instance 

227 ) 

228 else: 

229 related_owner_id = getattr( 

230 related_instance, 

231 "flowsheet_id", 

232 None, 

233 ) 

234 related_state_id = ( 

235 related_instance.pk 

236 if related_model._meta.label_lower 

237 == "core_auxiliary.flowsheetstate" 

238 else getattr(related_instance, "flowsheet_state_id", None) 

239 ) 

240 

241 if related_owner_id is not None and related_owner_id != flowsheet.pk: 

242 raise ValidationError( 

243 {"detail": "This edit references data from another flowsheet."} 

244 ) 

245 if ( 245 ↛ 249line 245 didn't jump to line 249 because the condition on line 245 was never true

246 related_state_id is not None 

247 and related_state_id != flowsheet.current_state_id 

248 ): 

249 raise ValidationError( 

250 { 

251 "detail": "This edit references data from another flowsheet state." 

252 } 

253 ) 

254 

255 for change in normalized: 

256 validate_foreign_keys(change) 

257 if change["action"] == "create": 

258 if change["existing"] is not None: 

259 soft_delete_field = change["spec"].soft_delete_field 

260 if not soft_delete_field or not getattr( 260 ↛ 263line 260 didn't jump to line 263 because the condition on line 260 was never true

261 change["existing"], soft_delete_field 

262 ): 

263 raise ValidationError( 

264 {"detail": "A row restored by this edit already exists."} 

265 ) 

266 owner_id = resolve_instance_flowsheet_id(change["existing"]) 

267 state_id = resolve_instance_flowsheet_state_id(change["existing"]) 

268 else: 

269 owner_id = pending_owner(change, set()) 

270 state_id = pending_state(change, set()) 

271 else: 

272 owner_id = resolve_instance_flowsheet_id(change["existing"]) 

273 state_id = resolve_instance_flowsheet_state_id(change["existing"]) 

274 if owner_id != flowsheet.pk: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 raise ValidationError( 

276 {"detail": "This edit references data from another flowsheet."} 

277 ) 

278 if state_id != flowsheet.current_state_id: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 raise ValidationError( 

280 {"detail": "This edit references data from another flowsheet state."} 

281 ) 

282 change.pop("existing", None) 

283 return normalized 

284 

285 

286def _dependency_ranks(specs: Iterable[HistoryModelSpec]) -> dict[str, int]: 

287 by_label = {spec.label: spec for spec in specs} 

288 ranks: dict[str, int] = {} 

289 

290 def rank(label: str, visiting: set[str]) -> int: 

291 if label in ranks: 

292 return ranks[label] 

293 if label in visiting: 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was never true

294 # Nullable/deferred cycles are applied in a later update phase. 

295 return 0 

296 visiting.add(label) 

297 dependency_ranks = [ 

298 rank(dependency, visiting) 

299 for dependency in by_label[label].dependencies 

300 if dependency in by_label and dependency != label 

301 ] 

302 visiting.remove(label) 

303 ranks[label] = (max(dependency_ranks) + 1) if dependency_ranks else 0 

304 return ranks[label] 

305 

306 for label in by_label: 

307 rank(label, set()) 

308 return ranks 

309 

310 

311def _apply_delete(change: dict) -> None: 

312 instance = change["spec"].model._base_manager.filter(pk=change["pk"]).first() 

313 if instance is None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 raise ValidationError( 

315 {"detail": "A row referenced by this edit no longer exists."} 

316 ) 

317 soft_delete_field = change["spec"].soft_delete_field 

318 if soft_delete_field: 

319 change["spec"].model._base_manager.filter(pk=change["pk"]).update( 

320 **{soft_delete_field: True} 

321 ) 

322 return 

323 instance.delete() 

324 

325 

326def _apply_create(change: dict) -> None: 

327 existing = change["spec"].model._base_manager.filter(pk=change["pk"]).first() 

328 if existing is None: 

329 change["spec"].model(**change["fields"]).save(force_insert=True) 

330 elif change["spec"].soft_delete_field: 330 ↛ 335line 330 didn't jump to line 335 because the condition on line 330 was always true

331 change["spec"].model._base_manager.filter(pk=change["pk"]).update( 

332 **change["fields"] 

333 ) 

334 else: 

335 raise ValidationError({"detail": "A row restored by this edit already exists."}) 

336 

337 

338def _apply_update(change: dict) -> None: 

339 updated = ( 

340 change["spec"] 

341 .model._base_manager.filter(pk=change["pk"]) 

342 .update(**change["fields"]) 

343 ) 

344 if updated != 1: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 raise ValidationError( 

346 {"detail": "A row referenced by this edit no longer exists."} 

347 ) 

348 

349 

350def apply_operation_payload(payload: dict, *, flowsheet: Flowsheet) -> None: 

351 """Validate and apply one generic row change-set atomically.""" 

352 changes = _normalize_changes(payload, flowsheet) 

353 ranks = _dependency_ranks(change["spec"] for change in changes) 

354 token = replay_in_progress.set(True) 

355 try: 

356 deleted_properties = before_replay_deletions(changes) 

357 for change in sorted( 

358 (change for change in changes if change["action"] == "delete"), 

359 key=lambda change: ranks[change["model"]], 

360 reverse=True, 

361 ): 

362 _apply_delete(change) 

363 for change in sorted( 

364 (change for change in changes if change["action"] == "create"), 

365 key=lambda change: ranks[change["model"]], 

366 ): 

367 _apply_create(change) 

368 for change in sorted( 

369 (change for change in changes if change["action"] == "update"), 

370 key=lambda change: ranks[change["model"]], 

371 ): 

372 _apply_update(change) 

373 after_replay(changes, deleted_properties) 

374 finally: 

375 replay_in_progress.reset(token)