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
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1"""Transactional state transitions for project-backed flowsheets.
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"""
10import re
12from django.db import connection, transaction
13from django.utils import timezone
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
22_DEFAULT_FLOWSHEET_NAME = re.compile(r"^Flowsheet-\d+$")
25class FlowsheetNotRegularError(ValueError):
26 """Raised when an operation requires a regular, non-template flowsheet."""
29class FlowsheetNotTemplateError(ValueError):
30 """Raised when a caller tries to revert a regular flowsheet."""
33class PublicTemplateRevertPermissionError(PermissionError):
34 """Raised when a non-staff user tries to revert a public template."""
37def lock_flowsheet_content_mutation(*, candidate: Flowsheet) -> Flowsheet:
38 """Prelock fresh project/flowsheet state before an outer child mutation.
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 """
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
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.
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 """
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
71 if candidate is None:
72 _set_locked_project_active_flowsheet(locked_project, None)
73 return None
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
83 if (
84 locked_flowsheet.flowsheet_template_type
85 != FlowsheetTemplateType.NotTemplate
86 ):
87 raise FlowsheetNotRegularError(
88 "Only regular flowsheets can be made active."
89 )
91 _set_locked_project_active_flowsheet(locked_project, locked_flowsheet)
92 return locked_flowsheet
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."""
103 project, flowsheet = _lock_project_and_flowsheet(candidate)
104 flowsheet.savedDate = timezone.now()
105 flowsheet.save(update_fields=["savedDate"])
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
117@transaction.atomic
118def delete_regular_flowsheet(*, candidate: Flowsheet) -> Flowsheet:
119 """Delete a fresh regular flowsheet and return its active replacement.
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 """
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.")
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 )
142 _set_locked_project_active_flowsheet(project, replacement)
143 return replacement
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.
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 """
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
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
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
197def _template_name(flowsheet: Flowsheet) -> str:
198 """Prefer an explicit flowsheet name, then its project name."""
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
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."""
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 )
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
242def _lock_project_and_flowsheet(
243 candidate: Flowsheet,
244) -> tuple[Project | None, Flowsheet]:
245 """Lock a stable project/flowsheet pair without using stale relations."""
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
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
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
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."""
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
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
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
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
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
323def _is_flowsheet_owned_by(flowsheet: Flowsheet, user_id: int) -> bool:
324 """Return ownership using the project boundary when one exists."""
326 if flowsheet.project_id is not None:
327 return flowsheet.project.owner_id == user_id
328 return flowsheet.owner_id == user_id
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."""
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"])
351def _lock_latest_regular_replacement(project: Project) -> Flowsheet | None:
352 """Lock the canonical most-recent regular replacement for a project.
354 Callers hold the project row lock before selecting a replacement, which
355 keeps the project-then-flowsheet lock order consistent across transitions.
356 """
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 )
369def _replace_active_template_flowsheet(flowsheet: Flowsheet) -> None:
370 """Prefer a regular active flowsheet and detach template-only containers."""
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
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
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 )
401def _activate_reverted_flowsheet_if_needed(flowsheet: Flowsheet) -> None:
402 """Restore a reverted template as the project active flowsheet when needed."""
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
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)