Coverage for backend/django/core/auxiliary/models/ProjectFolder.py: 90%
78 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
1from django.core.exceptions import ValidationError
2from django.db import models, router
3from django.db.models.functions import Lower
5from authentication.user.models import User
8PROJECT_FOLDER_STRUCTURE_FIELDS = frozenset(
9 {"owner", "owner_id", "parent", "parent_id"}
10)
13class ProjectFolderQuerySet(models.QuerySet):
14 """Keep structural folder writes behind the model's validation boundary."""
16 @staticmethod
17 def _reject_structural_fields(fields) -> None:
18 """Reject bulk operations that bypass immutable owner/parent checks."""
20 structural_fields = PROJECT_FOLDER_STRUCTURE_FIELDS.intersection(fields)
21 if structural_fields:
22 field_names = ", ".join(sorted(structural_fields))
23 raise ValidationError(
24 f"Project folder owner and parent are immutable ({field_names})."
25 )
27 def update(self, **kwargs):
28 """Allow lifecycle updates while blocking direct hierarchy rewrites."""
30 self._reject_structural_fields(kwargs)
31 return super().update(**kwargs)
33 def bulk_update(self, objs, fields, batch_size=None):
34 """Allow bulk lifecycle updates but never bulk structural changes."""
36 self._reject_structural_fields(fields)
37 return super().bulk_update(objs, fields, batch_size=batch_size)
39 def bulk_create(
40 self,
41 objs,
42 batch_size=None,
43 ignore_conflicts=False,
44 update_conflicts=False,
45 update_fields=None,
46 unique_fields=None,
47 ):
48 """Validate every direct bulk insert against the parent-owner invariant."""
50 self._reject_structural_fields(update_fields or ())
51 folders = list(objs)
52 self.model.validate_bulk_structures(folders, using=self.db)
53 return super().bulk_create(
54 folders,
55 batch_size=batch_size,
56 ignore_conflicts=ignore_conflicts,
57 update_conflicts=update_conflicts,
58 update_fields=update_fields,
59 unique_fields=unique_fields,
60 )
63class ProjectFolderManager(models.Manager.from_queryset(ProjectFolderQuerySet)):
64 """Manager exposing the guarded ProjectFolder queryset."""
67class ProjectFolder(models.Model):
68 """A stable, owner-scoped folder in the project hierarchy."""
70 owner = models.ForeignKey(
71 User,
72 on_delete=models.CASCADE,
73 related_name="project_folders",
74 )
75 parent = models.ForeignKey(
76 "self",
77 null=True,
78 blank=True,
79 on_delete=models.CASCADE,
80 related_name="children",
81 )
82 name = models.CharField(max_length=64)
83 is_binned = models.BooleanField(default=False)
84 binned_at = models.DateTimeField(null=True, blank=True)
85 created_at = models.DateTimeField(auto_now_add=True)
86 updated_at = models.DateTimeField(auto_now=True)
88 objects = ProjectFolderManager()
90 class Meta:
91 constraints = [
92 models.UniqueConstraint(
93 Lower("name"),
94 "owner",
95 condition=models.Q(parent__isnull=True),
96 name="unique_root_project_folder_name_per_owner",
97 ),
98 models.UniqueConstraint(
99 Lower("name"),
100 "owner",
101 "parent",
102 condition=models.Q(parent__isnull=False),
103 name="unique_child_project_folder_name_per_owner",
104 ),
105 models.CheckConstraint(
106 check=~models.Q(parent=models.F("id")),
107 name="project_folder_cannot_parent_itself",
108 ),
109 ]
110 indexes = [
111 models.Index(
112 fields=["owner", "is_binned", "parent"],
113 name="folder_owner_bin_parent_idx",
114 )
115 ]
117 @classmethod
118 def validate_bulk_structures(cls, folders, *, using: str) -> None:
119 """Validate parent ownership for inserts that bypass ``save()``."""
121 parent_ids = {
122 folder.parent_id for folder in folders if folder.parent_id is not None
123 }
124 parent_owners = dict(
125 cls.objects.using(using)
126 .filter(pk__in=parent_ids)
127 .values_list("pk", "owner_id")
128 )
129 for folder in folders:
130 if folder.owner_id is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 raise ValidationError({"owner": "A project folder requires an owner."})
132 if folder.parent_id is None:
133 continue
134 parent_owner_id = parent_owners.get(folder.parent_id)
135 if parent_owner_id is None: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 raise ValidationError({"parent": "Project folder parent not found."})
137 if parent_owner_id != folder.owner_id:
138 raise ValidationError(
139 {
140 "parent": (
141 "A project folder must have the same owner as its parent."
142 )
143 }
144 )
146 def _validate_structure(self, *, using: str) -> None:
147 """Enforce immutable ownership and same-owner parentage before a write."""
149 if self.owner_id is None: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 raise ValidationError({"owner": "A project folder requires an owner."})
152 queryset = type(self).objects.using(using)
153 # ``Model(pk=existing_id, ...).save(force_update=True)`` still has
154 # ``_state.adding=True``. Look up any existing primary key regardless
155 # of that in-memory flag so detached instances cannot rewrite the
156 # immutable hierarchy.
157 if self.pk is not None:
158 persisted = (
159 queryset.filter(pk=self.pk)
160 .values("owner_id", "parent_id")
161 .first()
162 )
163 if persisted is not None and ( 163 ↛ 171line 163 didn't jump to line 171 because the condition on line 163 was always true
164 persisted["owner_id"] != self.owner_id
165 or persisted["parent_id"] != self.parent_id
166 ):
167 raise ValidationError(
168 "Project folder owner and parent cannot be changed after creation."
169 )
171 if self.parent_id is None:
172 return
173 parent_owner_id = (
174 queryset.filter(pk=self.parent_id)
175 .values_list("owner_id", flat=True)
176 .first()
177 )
178 if parent_owner_id is None: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 raise ValidationError({"parent": "Project folder parent not found."})
180 if parent_owner_id != self.owner_id:
181 raise ValidationError(
182 {"parent": "A project folder must have the same owner as its parent."}
183 )
185 def clean(self):
186 """Validate structural invariants during explicit model validation."""
188 super().clean()
189 using = self._state.db or router.db_for_write(type(self), instance=self)
190 self._validate_structure(using=using)
192 def save(self, *args, **kwargs):
193 """Make direct ORM saves obey the folder structural boundary."""
195 update_fields = kwargs.get("update_fields")
196 validates_structure = (
197 self._state.adding
198 or update_fields is None
199 or bool(PROJECT_FOLDER_STRUCTURE_FIELDS.intersection(update_fields))
200 )
201 if validates_structure:
202 using = (
203 kwargs.get("using")
204 or self._state.db
205 or router.db_for_write(type(self), instance=self)
206 )
207 self._validate_structure(using=using)
208 return super().save(*args, **kwargs)
210 def __str__(self) -> str:
211 return self.name