Coverage for backend/django/core/auxiliary/services/project_folders.py: 90%
266 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"""Relational project-folder selectors and transactional workflows."""
3from __future__ import annotations
5import unicodedata
6from collections import defaultdict
7from collections.abc import Iterable, Mapping, Sequence
8from dataclasses import dataclass
9from datetime import datetime
11from django.db import IntegrityError, connection, transaction
12from django.db.models import Value
13from django.db.models.functions import Lower
14from django.utils import timezone
15from pydantic import BaseModel, ConfigDict
17from authentication.user.models import User
18from core.auxiliary.enums.FlowsheetTemplateType import FlowsheetTemplateType
19from core.auxiliary.models.Flowsheet import Flowsheet
20from core.auxiliary.models.Project import Project
21from core.auxiliary.models.ProjectFolder import ProjectFolder
24class ProjectFolderError(ValueError):
25 """Base error for rejected project-folder operations."""
28class ProjectFolderNotFoundError(ProjectFolderError):
29 """Raised when an active, owner-scoped folder cannot be found."""
32class ProjectFolderConflictError(ProjectFolderError):
33 """Raised when an active sibling already uses a requested name."""
36class ProjectFolderHierarchyError(ProjectFolderError):
37 """Raised when persisted folder ownership or ancestry is inconsistent."""
40class ProjectUpdateError(ProjectFolderError):
41 """Raised when an authoritative project update cannot be applied safely."""
43 def __init__(self, *, field: str, message: str):
44 super().__init__(message)
45 self.field = field
48PROJECT_FOLDER_REGULAR_PROJECT_ERROR = (
49 "Only projects with a regular active flowsheet can be organized into folders."
50)
53class BinProjectFolderResult(BaseModel):
54 """Summary returned after binning one relational folder subtree."""
56 model_config = ConfigDict(extra="forbid", frozen=True)
58 folder_id: int
59 binned_project_count: int
60 binned_folder_count: int
63def _lock_project_folder_tree(owner_id: int) -> None:
64 """Serialize hierarchy mutations for one owner for this transaction.
66 Negative advisory-lock keys are reserved here for project-folder trees.
67 This stable mutex prevents child-insert phantoms without coordinating
68 through mutable User fields or holding unrelated folder rows.
69 """
71 if not connection.in_atomic_block: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 raise RuntimeError("Project folder tree locks require an atomic transaction.")
73 with connection.cursor() as cursor:
74 cursor.execute("SELECT pg_advisory_xact_lock(%s)", [-owner_id])
77def normalize_project_folder_name(value: str) -> str:
78 """Normalize a folder label without interpreting it as a path."""
80 normalized = unicodedata.normalize("NFC", value).strip()
81 if not normalized:
82 raise ValueError("Folder names cannot be empty.")
83 if any(unicodedata.category(character).startswith("C") for character in normalized):
84 raise ValueError("Folder names cannot contain control characters.")
85 if len(normalized) > ProjectFolder._meta.get_field("name").max_length:
86 raise ValueError("Folder names cannot exceed 64 characters.")
87 return normalized
90def build_project_folder_adjacency(
91 folders: Iterable[ProjectFolder],
92) -> dict[int | None, list[int]]:
93 """Build a deterministic parent-to-child map from owner-scoped rows."""
95 adjacency: dict[int | None, list[int]] = defaultdict(list)
96 for folder in folders:
97 adjacency[folder.parent_id].append(folder.pk)
98 for child_ids in adjacency.values():
99 child_ids.sort()
100 return dict(adjacency)
103def select_project_folder_subtree_ids(
104 *,
105 folders: Sequence[ProjectFolder],
106 root_id: int,
107) -> list[int]:
108 """Return a folder subtree by stable IDs, including its root."""
110 folder_ids = {folder.pk for folder in folders}
111 if root_id not in folder_ids:
112 raise ProjectFolderNotFoundError("Project folder not found.")
114 adjacency = build_project_folder_adjacency(folders)
115 subtree_ids: list[int] = []
116 pending = [root_id]
117 visited: set[int] = set()
118 while pending:
119 folder_id = pending.pop()
120 if folder_id in visited: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 raise ProjectFolderHierarchyError("Project folder hierarchy contains a cycle.")
122 visited.add(folder_id)
123 subtree_ids.append(folder_id)
124 pending.extend(reversed(adjacency.get(folder_id, [])))
125 return subtree_ids
128def select_project_folder_ancestor_ids(
129 *,
130 folders: Sequence[ProjectFolder],
131 folder_id: int,
132) -> list[int]:
133 """Return root-to-leaf ancestor IDs for one owner-scoped folder."""
135 folders_by_id = {folder.pk: folder for folder in folders}
136 ancestor_ids: list[int] = []
137 visited: set[int] = set()
138 current_id: int | None = folder_id
139 while current_id is not None:
140 if current_id in visited: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 raise ProjectFolderHierarchyError("Project folder hierarchy contains a cycle.")
142 visited.add(current_id)
143 folder = folders_by_id.get(current_id)
144 if folder is None: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 raise ProjectFolderHierarchyError(
146 "Project folder hierarchy crosses an ownership boundary."
147 )
148 ancestor_ids.append(folder.pk)
149 current_id = folder.parent_id
150 ancestor_ids.reverse()
151 return ancestor_ids
154def _matching_sibling(
155 *,
156 owner_id: int,
157 parent_id: int | None,
158 name: str,
159) -> ProjectFolder | None:
160 """Lock and return the case-insensitive sibling using a requested name."""
162 return (
163 ProjectFolder.objects.select_for_update()
164 .alias(normalized_name=Lower("name"))
165 .filter(
166 owner_id=owner_id,
167 parent_id=parent_id,
168 normalized_name=Lower(Value(name)),
169 )
170 .order_by("id")
171 .first()
172 )
175def _reactivate_folder(folder: ProjectFolder) -> ProjectFolder:
176 """Reactivate one locked folder without restoring projects or descendants."""
178 folder.is_binned = False
179 folder.binned_at = None
180 folder.save(update_fields=["is_binned", "binned_at", "updated_at"])
181 return folder
184@transaction.atomic
185def create_project_folder(
186 *,
187 user: User,
188 name: str,
189 parent_id: int | None,
190) -> ProjectFolder:
191 """Create or reactivate a relational folder beneath an active parent."""
193 normalized_name = normalize_project_folder_name(name)
194 _lock_project_folder_tree(user.pk)
195 if parent_id is not None:
196 parent = (
197 ProjectFolder.objects.select_for_update()
198 .filter(pk=parent_id, owner=user)
199 .first()
200 )
201 if parent is None or parent.is_binned:
202 raise ProjectFolderNotFoundError("Parent project folder not found.")
204 sibling = _matching_sibling(
205 owner_id=user.pk,
206 parent_id=parent_id,
207 name=normalized_name,
208 )
209 if sibling is not None:
210 if not sibling.is_binned:
211 raise ProjectFolderConflictError(
212 "A folder with this name already exists here."
213 )
214 return _reactivate_folder(sibling)
216 try:
217 # Isolate an integrity race in a savepoint so the outer transaction can
218 # resolve the winning sibling into the domain result below.
219 with transaction.atomic():
220 return ProjectFolder.objects.create(
221 owner=user,
222 parent_id=parent_id,
223 name=normalized_name,
224 )
225 except IntegrityError:
226 sibling = _matching_sibling(
227 owner_id=user.pk,
228 parent_id=parent_id,
229 name=normalized_name,
230 )
231 if sibling is None:
232 raise
233 if sibling.is_binned:
234 return _reactivate_folder(sibling)
235 raise ProjectFolderConflictError(
236 "A folder with this name already exists here."
237 )
240@transaction.atomic
241def rename_project_folder(
242 *,
243 user: User,
244 folder_id: int,
245 name: str,
246) -> ProjectFolder:
247 """Rename one active owned folder while preserving sibling uniqueness."""
249 normalized_name = normalize_project_folder_name(name)
250 _lock_project_folder_tree(user.pk)
251 folder = lock_active_project_folder(owner_id=user.pk, folder_id=folder_id)
252 sibling = _matching_sibling(
253 owner_id=user.pk,
254 parent_id=folder.parent_id,
255 name=normalized_name,
256 )
257 if sibling is not None and sibling.pk != folder.pk:
258 raise ProjectFolderConflictError(
259 "A folder with this name already exists here."
260 )
261 if folder.name == normalized_name: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 return folder
264 folder.name = normalized_name
265 try:
266 folder.save(update_fields=["name", "updated_at"])
267 except IntegrityError as exc:
268 raise ProjectFolderConflictError(
269 "A folder with this name already exists here."
270 ) from exc
271 return folder
274def lock_active_project_folder(*, owner_id: int, folder_id: int) -> ProjectFolder:
275 """Lock an active destination folder and enforce its ownership boundary."""
277 try:
278 return ProjectFolder.objects.select_for_update().get(
279 pk=folder_id,
280 owner_id=owner_id,
281 is_binned=False,
282 )
283 except ProjectFolder.DoesNotExist as exc:
284 raise ProjectFolderNotFoundError("Project folder not found.") from exc
287def _load_project_folder_ancestry(
288 *, owner_id: int, folder_id: int
289) -> list[ProjectFolder]:
290 """Load only one folder's owner-scoped ancestry using a recursive CTE."""
292 table = connection.ops.quote_name(ProjectFolder._meta.db_table)
293 sql = f"""
294 WITH RECURSIVE folder_ancestry AS (
295 SELECT id, parent_id, owner_id
296 FROM {table}
297 WHERE id = %s AND owner_id = %s
298 UNION
299 SELECT parent.id, parent.parent_id, parent.owner_id
300 FROM {table} AS parent
301 INNER JOIN folder_ancestry AS child
302 ON parent.id = child.parent_id
303 WHERE parent.owner_id = %s
304 )
305 SELECT id, parent_id, owner_id
306 FROM folder_ancestry
307 """
308 return list(ProjectFolder.objects.raw(sql, [folder_id, owner_id, owner_id]))
311def _load_project_folder_subtree(
312 *, owner_id: int, folder_id: int
313) -> list[ProjectFolder]:
314 """Load only one owner-scoped folder subtree using a recursive CTE."""
316 table = connection.ops.quote_name(ProjectFolder._meta.db_table)
317 sql = f"""
318 WITH RECURSIVE folder_subtree AS (
319 SELECT id, parent_id, owner_id
320 FROM {table}
321 WHERE id = %s AND owner_id = %s
322 UNION
323 SELECT child.id, child.parent_id, child.owner_id
324 FROM {table} AS child
325 INNER JOIN folder_subtree AS parent
326 ON child.parent_id = parent.id
327 WHERE child.owner_id = %s
328 )
329 SELECT id, parent_id, owner_id
330 FROM folder_subtree
331 """
332 return list(ProjectFolder.objects.raw(sql, [folder_id, owner_id, owner_id]))
335def lock_project_folder_ancestry(
336 *,
337 owner_id: int,
338 folder_id: int,
339) -> list[ProjectFolder]:
340 """Lock and revalidate an owned folder ancestry before locking its project."""
342 _lock_project_folder_tree(owner_id)
343 ancestor_relations = _load_project_folder_ancestry(
344 owner_id=owner_id,
345 folder_id=folder_id,
346 )
347 ancestor_ids = select_project_folder_ancestor_ids(
348 folders=ancestor_relations,
349 folder_id=folder_id,
350 )
351 locked_folders = list(
352 ProjectFolder.objects.select_for_update()
353 .filter(owner_id=owner_id, pk__in=ancestor_ids)
354 .order_by("id")
355 )
356 if {folder.pk for folder in locked_folders} != set(ancestor_ids): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 raise ProjectFolderHierarchyError(
358 "Project folder hierarchy changed; retry the update."
359 )
360 locked_by_id = {folder.pk: folder for folder in locked_folders}
361 return [locked_by_id[ancestor_id] for ancestor_id in ancestor_ids]
364def reactivate_locked_project_folders(folders: Sequence[ProjectFolder]) -> None:
365 """Reactivate a locked root-to-leaf folder ancestry in one write."""
367 folders_to_update = [folder for folder in folders if folder.is_binned]
368 if not folders_to_update:
369 return
370 updated_at = timezone.now()
371 for folder in folders_to_update:
372 folder.is_binned = False
373 folder.binned_at = None
374 folder.updated_at = updated_at
375 ProjectFolder.objects.bulk_update(
376 folders_to_update,
377 ["is_binned", "binned_at", "updated_at"],
378 )
381def project_has_regular_active_flowsheet(project: Project) -> bool:
382 """Return whether a project currently points at a regular flowsheet."""
384 if project.active_flowsheet_id is None: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 return False
386 return Flowsheet.objects.filter(
387 pk=project.active_flowsheet_id,
388 flowsheet_template_type=FlowsheetTemplateType.NotTemplate,
389 ).exists()
392def _apply_project_bin_state(
393 project: Project,
394 *,
395 is_binned: bool,
396 transition_time: datetime,
397) -> None:
398 """Apply the canonical project bin state to an already locked instance.
400 Callers remain responsible for persisting the instance. Keeping this policy
401 write-free lets both individual updates and recursive folder operations use
402 identical lifecycle fields while retaining their respective bulk-write paths.
403 """
405 if is_binned:
406 if not project.is_binned or project.binned_at is None:
407 project.binned_at = transition_time
408 project.is_binned = True
409 else:
410 project.is_binned = False
411 project.binned_at = None
412 project.updated_at = transition_time
413 if is_binned:
414 project.is_starred = False
417@dataclass(frozen=True)
418class _ProjectUpdateFolderLocks:
419 """Folder state acquired before an individual project row is locked."""
421 ancestors: tuple[ProjectFolder, ...]
422 expected_restore_folder_id: int | None
425def _lock_project_update_folders(
426 *,
427 stale_project: Project,
428 validated_changes: dict[str, object],
429 touches_folder: bool,
430 requests_restore: bool,
431) -> _ProjectUpdateFolderLocks:
432 """Plan and acquire destination or restoration-ancestry folder locks.
434 The supplied change mapping is updated with the locked destination model so
435 persistence cannot reuse the serializer's stale folder instance.
436 """
438 destination = validated_changes.get("folder")
439 restores_current_folder = bool(
440 requests_restore
441 and destination is not None
442 and destination.pk == stale_project.folder_id
443 )
444 locked_ancestors: list[ProjectFolder] = []
445 locked_destination = None
446 if touches_folder and destination is not None and restores_current_folder:
447 locked_ancestors = lock_project_folder_ancestry(
448 owner_id=stale_project.owner_id,
449 folder_id=destination.pk,
450 )
451 locked_destination = locked_ancestors[-1]
452 validated_changes["folder"] = locked_destination
453 elif touches_folder and destination is not None:
454 locked_destination = lock_active_project_folder(
455 owner_id=stale_project.owner_id,
456 folder_id=destination.pk,
457 )
458 validated_changes["folder"] = locked_destination
460 retained_folder_id = (
461 stale_project.folder_id
462 if requests_restore
463 and not touches_folder
464 and stale_project.folder_id is not None
465 else None
466 )
467 if retained_folder_id is not None:
468 locked_ancestors = lock_project_folder_ancestry(
469 owner_id=stale_project.owner_id,
470 folder_id=retained_folder_id,
471 )
473 expected_restore_folder_id = (
474 locked_destination.pk
475 if restores_current_folder and locked_destination is not None
476 else retained_folder_id
477 )
478 return _ProjectUpdateFolderLocks(
479 ancestors=tuple(locked_ancestors),
480 expected_restore_folder_id=expected_restore_folder_id,
481 )
484def _lock_fresh_project(stale_project: Project) -> Project:
485 """Lock fresh project state and reject deletion or ownership drift."""
487 try:
488 locked_project = Project.objects.select_for_update().get(pk=stale_project.pk)
489 except Project.DoesNotExist as exc:
490 raise ProjectUpdateError(
491 field="detail",
492 message="Project changed; retry the update.",
493 ) from exc
494 if locked_project.owner_id != stale_project.owner_id: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 raise ProjectUpdateError(
496 field="detail",
497 message="Project ownership changed; retry the update.",
498 )
499 return locked_project
502def _validate_locked_project_update(
503 project: Project,
504 *,
505 touches_folder: bool,
506 requests_binning: bool,
507 expected_restore_folder_id: int | None,
508) -> None:
509 """Revalidate active-flowsheet and stale-folder policy under row locks."""
511 if (touches_folder or requests_binning) and not (
512 project_has_regular_active_flowsheet(project)
513 ):
514 raise ProjectUpdateError(
515 field="folder" if touches_folder else "is_binned",
516 message=PROJECT_FOLDER_REGULAR_PROJECT_ERROR,
517 )
518 if ( 518 ↛ 522line 518 didn't jump to line 522 because the condition on line 518 was never true
519 expected_restore_folder_id is not None
520 and project.folder_id != expected_restore_folder_id
521 ):
522 raise ProjectUpdateError(
523 field="folder",
524 message="Project folder changed; retry the update.",
525 )
528def _persist_locked_project_update(
529 project: Project,
530 *,
531 validated_changes: dict[str, object],
532 requests_binning: bool,
533 requests_restore: bool,
534 locked_ancestors: Sequence[ProjectFolder],
535) -> Project:
536 """Apply ordinary fields plus canonical lifecycle state and persist once."""
538 # Lifecycle timestamps are always server-owned, even if this service is
539 # called from a path other than the serializer.
540 validated_changes.pop("binned_at", None)
541 for field, value in validated_changes.items():
542 setattr(project, field, value)
544 transition_time = timezone.now()
545 if requests_binning:
546 _apply_project_bin_state(
547 project,
548 is_binned=True,
549 transition_time=transition_time,
550 )
551 elif requests_restore:
552 reactivate_locked_project_folders(locked_ancestors)
553 _apply_project_bin_state(
554 project,
555 is_binned=False,
556 transition_time=transition_time,
557 )
559 project.save()
560 return project
563@transaction.atomic
564def apply_project_update(
565 *,
566 stale_project: Project,
567 changes: Mapping[str, object],
568) -> Project:
569 """Apply serializer-validated fields under project-folder lifecycle locks.
571 Folder locks are acquired before the project lock so this path follows the
572 same ordering as recursive folder binning. The fresh project row is then
573 updated once, with server-owned bin timestamps and restoration of any
574 retained folder ancestry.
575 """
577 validated_changes = dict(changes)
578 touches_folder = "folder" in validated_changes
579 requests_restore = validated_changes.get("is_binned") is False
580 requests_binning = validated_changes.get("is_binned") is True
582 folder_locks = _lock_project_update_folders(
583 stale_project=stale_project,
584 validated_changes=validated_changes,
585 touches_folder=touches_folder,
586 requests_restore=requests_restore,
587 )
588 locked_project = _lock_fresh_project(stale_project)
589 _validate_locked_project_update(
590 locked_project,
591 touches_folder=touches_folder,
592 requests_binning=requests_binning,
593 expected_restore_folder_id=folder_locks.expected_restore_folder_id,
594 )
595 return _persist_locked_project_update(
596 locked_project,
597 validated_changes=validated_changes,
598 requests_binning=requests_binning,
599 requests_restore=requests_restore,
600 locked_ancestors=folder_locks.ancestors,
601 )
604@transaction.atomic
605def bin_project_folder(*, user: User, folder_id: int) -> BinProjectFolderResult:
606 """Soft-bin an owned folder subtree and reconcile its project membership."""
608 _lock_project_folder_tree(user.pk)
609 subtree_relations = _load_project_folder_subtree(
610 owner_id=user.pk,
611 folder_id=folder_id,
612 )
613 subtree_ids = select_project_folder_subtree_ids(
614 folders=subtree_relations,
615 root_id=folder_id,
616 )
617 subtree_folders = list(
618 ProjectFolder.objects.select_for_update()
619 .filter(owner=user, pk__in=subtree_ids)
620 .order_by("id")
621 )
622 if {folder.pk for folder in subtree_folders} != set(subtree_ids): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 raise ProjectFolderNotFoundError("Project folder not found.")
624 selected_folder = next(
625 folder for folder in subtree_folders if folder.pk == folder_id
626 )
627 if selected_folder.is_binned: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true
628 raise ProjectFolderNotFoundError("Project folder not found.")
630 binned_at = timezone.now()
631 for folder in subtree_folders:
632 folder.is_binned = True
633 folder.binned_at = binned_at
634 folder.updated_at = binned_at
635 ProjectFolder.objects.bulk_update(
636 subtree_folders,
637 ["is_binned", "binned_at", "updated_at"],
638 )
640 projects = list(
641 Project.objects.select_for_update()
642 .filter(owner=user, folder_id__in=subtree_ids)
643 .order_by("id")
644 )
645 regular_flowsheet_ids = set(
646 Flowsheet.objects.filter(
647 pk__in=[
648 project.active_flowsheet_id
649 for project in projects
650 if project.active_flowsheet_id is not None
651 ],
652 flowsheet_template_type=FlowsheetTemplateType.NotTemplate,
653 ).values_list("id", flat=True)
654 )
656 binned_project_count = 0
657 regular_projects_to_update: list[Project] = []
658 project_ids_to_clear_folder: list[int] = []
659 for project in projects:
660 if project.active_flowsheet_id in regular_flowsheet_ids:
661 was_binned = project.is_binned
662 _apply_project_bin_state(
663 project,
664 is_binned=True,
665 transition_time=binned_at,
666 )
667 if not was_binned:
668 binned_project_count += 1
669 regular_projects_to_update.append(project)
670 else:
671 project_ids_to_clear_folder.append(project.pk)
673 if regular_projects_to_update:
674 Project.objects.bulk_update(
675 regular_projects_to_update,
676 ["is_binned", "is_starred", "binned_at", "updated_at"],
677 )
678 if project_ids_to_clear_folder:
679 Project.objects.filter(pk__in=project_ids_to_clear_folder).update(
680 folder=None,
681 updated_at=binned_at,
682 )
684 return BinProjectFolderResult(
685 folder_id=folder_id,
686 binned_project_count=binned_project_count,
687 binned_folder_count=len(subtree_ids),
688 )