Coverage for backend/django/core/auxiliary/services/flowsheet_template_transitions.py: 82%

160 statements  

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

1"""Transactional state transitions for project-backed flowsheets. 

2 

3Flowsheet mutations that do not change folder membership lock the project 

4before the flowsheet. Template creation additionally locks the current folder 

5before the source project so it is ordered consistently with concurrent folder 

6moves. Keeping activation, content timestamps, deletion, and template state 

7here prevents stale model instances from restoring superseded state. 

8""" 

9 

10import re 

11 

12from django.db import connection, transaction 

13from django.utils import timezone 

14 

15from core.auxiliary.enums.FlowsheetTemplateType import FlowsheetTemplateType 

16from core.auxiliary.models.Flowsheet import Flowsheet 

17from core.auxiliary.models.Project import Project 

18from core.auxiliary.models.ProjectFolder import ProjectFolder 

19from core.auxiliary.services.flowsheet_states import clone_to_new_flowsheet 

20 

21 

22_DEFAULT_FLOWSHEET_NAME = re.compile(r"^Flowsheet-\d+$") 

23 

24 

25class FlowsheetNotRegularError(ValueError): 

26 """Raised when an operation requires a regular, non-template flowsheet.""" 

27 

28 

29class FlowsheetNotTemplateError(ValueError): 

30 """Raised when a caller tries to revert a regular flowsheet.""" 

31 

32 

33class PublicTemplateRevertPermissionError(PermissionError): 

34 """Raised when a non-staff user tries to revert a public template.""" 

35 

36 

37def lock_flowsheet_content_mutation(*, candidate: Flowsheet) -> Flowsheet: 

38 """Prelock fresh project/flowsheet state before an outer child mutation. 

39 

40 Call this immediately after entering an existing atomic block and before 

41 writing any row with a foreign key to the flowsheet. PostgreSQL child 

42 writes take a ``KEY SHARE`` lock on that flowsheet; acquiring the canonical 

43 project-then-flowsheet locks first prevents an inverse wait against 

44 template conversion or flowsheet deletion. 

45 """ 

46 

47 if not connection.in_atomic_block: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 raise RuntimeError("Flowsheet row locks require an atomic transaction.") 

49 _, flowsheet = _lock_project_and_flowsheet(candidate) 

50 return flowsheet 

51 

52 

53@transaction.atomic 

54def activate_regular_flowsheet( 

55 *, 

56 project: Project, 

57 candidate: Flowsheet | None, 

58) -> Flowsheet | None: 

59 """Make a fresh regular flowsheet active under the canonical lock order. 

60 

61 ``None`` remains supported for internal workflows that temporarily create 

62 an empty project shell. A template can never be promoted through this 

63 boundary, including when the caller supplies a stale regular instance. 

64 """ 

65 

66 try: 

67 locked_project = Project.objects.select_for_update().get(pk=project.pk) 

68 except Project.DoesNotExist as exc: 

69 raise Flowsheet.DoesNotExist from exc 

70 

71 if candidate is None: 

72 _set_locked_project_active_flowsheet(locked_project, None) 

73 return None 

74 

75 try: 

76 locked_flowsheet = Flowsheet.objects.select_for_update().get( 

77 pk=candidate.pk, 

78 project_id=locked_project.pk, 

79 ) 

80 except Flowsheet.DoesNotExist: 

81 raise 

82 

83 if ( 

84 locked_flowsheet.flowsheet_template_type 

85 != FlowsheetTemplateType.NotTemplate 

86 ): 

87 raise FlowsheetNotRegularError( 

88 "Only regular flowsheets can be made active." 

89 ) 

90 

91 _set_locked_project_active_flowsheet(locked_project, locked_flowsheet) 

92 return locked_flowsheet 

93 

94 

95@transaction.atomic 

96def touch_flowsheet_saved_date( 

97 *, 

98 candidate: Flowsheet, 

99 activate_if_regular: bool, 

100) -> Flowsheet: 

101 """Timestamp fresh flowsheet state and optionally activate regular rows.""" 

102 

103 project, flowsheet = _lock_project_and_flowsheet(candidate) 

104 flowsheet.savedDate = timezone.now() 

105 flowsheet.save(update_fields=["savedDate"]) 

106 

107 if ( 

108 activate_if_regular 

109 and project is not None 

110 and flowsheet.flowsheet_template_type 

111 == FlowsheetTemplateType.NotTemplate 

112 ): 

113 _set_locked_project_active_flowsheet(project, flowsheet) 

114 return flowsheet 

115 

116 

117@transaction.atomic 

118def delete_regular_flowsheet(*, candidate: Flowsheet) -> Flowsheet: 

119 """Delete a fresh regular flowsheet and return its active replacement. 

120 

121 The project lock is acquired before the target flowsheet lock. This avoids 

122 the inverse ordering previously caused by deleting a flowsheet first and 

123 then updating the project's ``SET_NULL`` active reference. 

124 """ 

125 

126 project, flowsheet = _lock_project_and_flowsheet(candidate) 

127 if project is None: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 raise FlowsheetNotRegularError( 

129 "A project-backed regular flowsheet is required." 

130 ) 

131 if flowsheet.flowsheet_template_type != FlowsheetTemplateType.NotTemplate: 

132 raise FlowsheetNotRegularError("Only regular flowsheets can be deleted.") 

133 

134 flowsheet.delete() 

135 replacement = _lock_latest_regular_replacement(project) 

136 if replacement is None: 

137 replacement = Flowsheet.create( 

138 owner=project.owner, 

139 project=project, 

140 ) 

141 

142 _set_locked_project_active_flowsheet(project, replacement) 

143 return replacement 

144 

145 

146@transaction.atomic 

147def convert_flowsheet_to_template( 

148 *, 

149 candidate: Flowsheet, 

150 user_id: int, 

151 template_type: str, 

152) -> Flowsheet: 

153 """Create a template copy while preserving an owned regular flowsheet. 

154 

155 Existing templates change visibility in place. A regular flowsheet is 

156 cloned into a dedicated template project first so the source project stays 

157 visible and project-scoped cloned data retains a valid owner boundary. 

158 """ 

159 

160 flowsheet = _lock_template_transition_target( 

161 candidate, 

162 lock_project_folder=True, 

163 ) 

164 if not _is_flowsheet_owned_by(flowsheet, user_id): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 raise Flowsheet.DoesNotExist 

166 

167 if flowsheet.flowsheet_template_type != FlowsheetTemplateType.NotTemplate: 

168 flowsheet.flowsheet_template_type = template_type 

169 flowsheet.save(update_fields=["flowsheet_template_type"]) 

170 return flowsheet 

171 

172 template_name = _template_name(flowsheet) 

173 template_owner = ( 

174 flowsheet.project.owner if flowsheet.project_id else flowsheet.owner 

175 ) 

176 template_project = Project.create_empty( 

177 name=template_name, 

178 owner=template_owner, 

179 active_flowsheet=None, 

180 is_starred=False, 

181 is_binned=False, 

182 binned_at=None, 

183 ) 

184 template = clone_to_new_flowsheet( 

185 source_flowsheet=flowsheet, 

186 user=template_project.owner, 

187 target_project=template_project, 

188 name=template_name, 

189 ) 

190 _set_locked_project_active_flowsheet(template_project, template) 

191 template.flowsheet_template_type = template_type 

192 template.save(update_fields=["flowsheet_template_type"]) 

193 _replace_active_template_flowsheet(template) 

194 return template 

195 

196 

197def _template_name(flowsheet: Flowsheet) -> str: 

198 """Prefer an explicit flowsheet name, then its project name.""" 

199 

200 if not _DEFAULT_FLOWSHEET_NAME.fullmatch(flowsheet.name): 

201 return flowsheet.name 

202 if flowsheet.project_id is not None and flowsheet.project.name: 202 ↛ 204line 202 didn't jump to line 204 because the condition on line 202 was always true

203 return flowsheet.project.name 

204 return flowsheet.name 

205 

206 

207@transaction.atomic 

208def revert_flowsheet_template( 

209 *, 

210 candidate: Flowsheet, 

211 user_id: int, 

212 is_staff: bool, 

213) -> Flowsheet: 

214 """Revert an accessible template and restore a valid active flowsheet.""" 

215 

216 flowsheet = _lock_template_transition_target( 

217 candidate, 

218 lock_project_folder=False, 

219 ) 

220 is_owner = _is_flowsheet_owned_by(flowsheet, user_id) 

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

222 flowsheet.flowsheet_template_type == FlowsheetTemplateType.PrivateTemplate 

223 and not is_owner 

224 ): 

225 raise Flowsheet.DoesNotExist 

226 if flowsheet.flowsheet_template_type == FlowsheetTemplateType.NotTemplate: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 raise FlowsheetNotTemplateError("Flowsheet is not a template") 

228 if ( 

229 flowsheet.flowsheet_template_type == FlowsheetTemplateType.PublicTemplate 

230 and not is_staff 

231 ): 

232 raise PublicTemplateRevertPermissionError( 

233 "You do not have permission to revert public templates" 

234 ) 

235 

236 flowsheet.flowsheet_template_type = FlowsheetTemplateType.NotTemplate 

237 flowsheet.save(update_fields=["flowsheet_template_type"]) 

238 _activate_reverted_flowsheet_if_needed(flowsheet) 

239 return flowsheet 

240 

241 

242def _lock_project_and_flowsheet( 

243 candidate: Flowsheet, 

244) -> tuple[Project | None, Flowsheet]: 

245 """Lock a stable project/flowsheet pair without using stale relations.""" 

246 

247 if candidate.project_id is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 flowsheet = Flowsheet.objects.select_for_update().get( 

249 pk=candidate.pk, 

250 project_id__isnull=True, 

251 ) 

252 return None, flowsheet 

253 

254 try: 

255 # Do not join nullable Project.owner into this locking query. PostgreSQL 

256 # rejects FOR UPDATE against the nullable side of that outer join; the 

257 # few callers needing ``project.owner`` can load it after this row lock. 

258 project = Project.objects.select_for_update().get( 

259 pk=candidate.project_id 

260 ) 

261 except Project.DoesNotExist as exc: 

262 raise Flowsheet.DoesNotExist from exc 

263 

264 flowsheet = Flowsheet.objects.select_for_update().get( 

265 pk=candidate.pk, 

266 project_id=project.pk, 

267 ) 

268 flowsheet.project = project 

269 return project, flowsheet 

270 

271 

272def _lock_template_transition_target( 

273 candidate: Flowsheet, 

274 *, 

275 lock_project_folder: bool, 

276) -> Flowsheet: 

277 """Lock and revalidate a target in folder, project, flowsheet order.""" 

278 

279 project_id = candidate.project_id 

280 if project_id is None: 

281 locked_flowsheet = Flowsheet.objects.select_for_update().get( 

282 pk=candidate.pk, 

283 project_id__isnull=True, 

284 ) 

285 if locked_flowsheet.owner_id != candidate.owner_id: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 raise Flowsheet.DoesNotExist 

287 return locked_flowsheet 

288 

289 owner_id = candidate.project.owner_id 

290 expected_folder_id = candidate.project.folder_id 

291 if lock_project_folder and expected_folder_id is not None: 

292 try: 

293 ProjectFolder.objects.select_for_update().get( 

294 pk=expected_folder_id, 

295 owner_id=owner_id, 

296 ) 

297 except ProjectFolder.DoesNotExist as exc: 

298 raise Flowsheet.DoesNotExist from exc 

299 

300 try: 

301 locked_project = Project.objects.select_for_update().get( 

302 pk=project_id, 

303 owner_id=owner_id, 

304 ) 

305 except Project.DoesNotExist as exc: 

306 raise Flowsheet.DoesNotExist from exc 

307 

308 if lock_project_folder and locked_project.folder_id != expected_folder_id: 308 ↛ 311line 308 didn't jump to line 311 because the condition on line 308 was never true

309 # A concurrent move won the race. Abort through the existing boundary 

310 # rather than acquiring the new folder after the project lock. 

311 raise Flowsheet.DoesNotExist 

312 

313 locked_flowsheet = Flowsheet.objects.select_for_update().get( 

314 pk=candidate.pk, 

315 project_id=project_id, 

316 ) 

317 # Keep the locked Project instance attached so every following mutation 

318 # uses the row protected by the transaction rather than a stale relation. 

319 locked_flowsheet.project = locked_project 

320 return locked_flowsheet 

321 

322 

323def _is_flowsheet_owned_by(flowsheet: Flowsheet, user_id: int) -> bool: 

324 """Return ownership using the project boundary when one exists.""" 

325 

326 if flowsheet.project_id is not None: 

327 return flowsheet.project.owner_id == user_id 

328 return flowsheet.owner_id == user_id 

329 

330 

331def _set_locked_project_active_flowsheet( 

332 project: Project, 

333 flowsheet: Flowsheet | None, 

334) -> None: 

335 """Write active state after callers acquire the canonical row locks.""" 

336 

337 if flowsheet is not None: 

338 if flowsheet.project_id != project.pk: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 raise Flowsheet.DoesNotExist 

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

341 flowsheet.flowsheet_template_type 

342 != FlowsheetTemplateType.NotTemplate 

343 ): 

344 raise FlowsheetNotRegularError( 

345 "Only regular flowsheets can be made active." 

346 ) 

347 project.active_flowsheet = flowsheet 

348 project.save(update_fields=["active_flowsheet"]) 

349 

350 

351def _lock_latest_regular_replacement(project: Project) -> Flowsheet | None: 

352 """Lock the canonical most-recent regular replacement for a project. 

353 

354 Callers hold the project row lock before selecting a replacement, which 

355 keeps the project-then-flowsheet lock order consistent across transitions. 

356 """ 

357 

358 return ( 

359 Flowsheet.objects.select_for_update() 

360 .filter( 

361 project_id=project.pk, 

362 flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

363 ) 

364 .order_by("-savedDate", "-created_at", "-pk") 

365 .first() 

366 ) 

367 

368 

369def _replace_active_template_flowsheet(flowsheet: Flowsheet) -> None: 

370 """Prefer a regular active flowsheet and detach template-only containers.""" 

371 

372 project = flowsheet.project 

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

374 project is None 

375 or project.active_flowsheet_id != flowsheet.pk 

376 or flowsheet.flowsheet_template_type == FlowsheetTemplateType.NotTemplate 

377 ): 

378 return 

379 

380 replacement = _lock_latest_regular_replacement(project) 

381 if replacement is not None: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 _set_locked_project_active_flowsheet(project, replacement) 

383 return 

384 

385 # A sole active template is displayed through the template view, not the 

386 # project menu. Clearing both folder and bin state also makes either order 

387 # of a concurrent folder-bin/template-conversion pair converge here. 

388 project.folder = None 

389 project.is_binned = False 

390 project.binned_at = None 

391 project.save( 

392 update_fields=[ 

393 "folder", 

394 "is_binned", 

395 "binned_at", 

396 "updated_at", 

397 ] 

398 ) 

399 

400 

401def _activate_reverted_flowsheet_if_needed(flowsheet: Flowsheet) -> None: 

402 """Restore a reverted template as the project active flowsheet when needed.""" 

403 

404 project = flowsheet.project 

405 if project is None: 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true

406 return 

407 

408 active_flowsheet = project.active_flowsheet 

409 if ( 

410 active_flowsheet is None 

411 or active_flowsheet.flowsheet_template_type 

412 != FlowsheetTemplateType.NotTemplate 

413 ): 

414 _set_locked_project_active_flowsheet(project, flowsheet)