Repository navigation
feat: enforce one root criteria group per competency in the database #900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,10 @@ class CompetencyCriteriaGroup(models.Model): | |
| of child groups and leaf criteria, and only CompetencyCriteriaGroup carries an ``ordering`` | ||
| field, so that mix has no total order; #641 accepts this deliberately. See ADR-0002 Decision 2. | ||
|
|
||
| ``scope_code`` is derived in ``save()``, so writes that skip ``save()`` (``bulk_create()``, | ||
| ``QuerySet.update()``, ``loaddata``, raw SQL) can leave a root without a code or with a stale | ||
| one, and the one-root-per-competency rule then goes unenforced for that root. | ||
|
|
||
| .. no_pii: | ||
| """ | ||
|
|
||
|
|
@@ -104,8 +108,20 @@ class CompetencyCriteriaGroup(models.Model): | |
| "criteria and existing learner status rows stay resolvable." | ||
| ), | ||
| ) | ||
| # Recomputed in save(); set for roots only, archived or not, so an archived root keeps blocking a second one. | ||
| # Null below the root: a leaf has no natural key to put here, and the check constraint below only says a | ||
| # coded row is a root, not that every root is coded, so non-null would not make the rule any stronger. | ||
| scope_code = models.CharField( | ||
| max_length=255, | ||
| null=True, | ||
| editable=False, | ||
| help_text=_( | ||
| "Derived from tag and parent; \"tag:X\" for a root group, null for every other group." | ||
| ), | ||
| ) | ||
|
|
||
| history = HistoricalRecords() | ||
| # scope_code is derived bookkeeping; tag and parent, which it derives from, are already tracked. | ||
| history = HistoricalRecords(excluded_fields=["scope_code"]) | ||
|
|
||
| class Meta: | ||
| indexes = [ | ||
|
|
@@ -121,16 +137,56 @@ class Meta: | |
| constraints = [ | ||
| # Backstops _create_leaf_group()'s get_or_create() for course-level groups. It has no | ||
| # `condition` because MySQL has no partial indexes: a unique index allows repeated | ||
| # NULLs, and `course` is NULL for roots and leaves. | ||
| # NULLs, and `course` is NULL for roots and leaves. Roots are covered by scope_code instead. | ||
| models.UniqueConstraint( | ||
| fields=["tag", "course", "parent"], | ||
| name="oel_cbe_criteria_group_one_course_group_per_tag_course", | ||
| violation_error_message=_( | ||
| "A competency tag may have at most one course-level CompetencyCriteriaGroup per course." | ||
| ), | ||
| ), | ||
| # Over the derived scope_code column because MySQL has no partial unique index for `parent IS NULL`. | ||
| models.UniqueConstraint( | ||
| fields=["scope_code"], | ||
| name="oel_cbe_criteria_group_scope_code_uniq", | ||
| violation_error_message=_("A competency tag may have at most one root CompetencyCriteriaGroup."), | ||
| ), | ||
| # One-directional: on MySQL, which cannot defer constraint checks, Django's delete collector | ||
| # sets each descendant's nullable `parent` to NULL before deleting it, so "every root has a | ||
| # scope_code" would reject those rows mid-delete. | ||
| models.CheckConstraint( | ||
| condition=Q(scope_code__isnull=True) | Q(parent__isnull=True), | ||
| name="oel_cbe_criteria_group_scope_code_root_only_check", | ||
| violation_error_message=_("Only a root CompetencyCriteriaGroup may have a scope_code."), | ||
| ), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Is this more trouble than it's worth? I think I get the reasoning for this, in that the I realize this uses more space, and that it may complicate the migration a bit (create nullable field, backfill, make field no longer nullable). But it would reduce the code complexity and guard against things like bulk insertion later bypassing our constraints and causing data corruption. I can be argued out of this one. Please let me know if there's something I'm not seeing here. |
||
| ] | ||
|
|
||
| def _check_tree_position_immutable(self) -> None: | ||
| """Raise ValidationError if tag, parent or course no longer matches what is persisted for this row.""" | ||
| if self.pk is None: | ||
| return | ||
| # Queried rather than compared against a value cached at load time, so a deferred load or | ||
| # a refresh_from_db() cannot bypass the check. | ||
| persisted = ( | ||
| CompetencyCriteriaGroup.objects.filter(pk=self.pk).values_list("tag_id", "parent_id", "course_id").first() | ||
| ) | ||
| if persisted is not None and (self.tag_id, self.parent_id, self.course_id) != persisted: | ||
| raise ValidationError( | ||
| _("A CompetencyCriteriaGroup's tag, parent and course cannot be changed after creation.") | ||
| ) | ||
|
Comment on lines
+164
to
+176
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two of #681's tests reparent a group through Likewise, the comment above the walk in FWIW, I'm fine with |
||
|
|
||
| def _compute_scope_code(self) -> str | None: | ||
| """Return this group's scope_code: set for a root group, None for every other group.""" | ||
| if self.parent_id is not None: | ||
| return None | ||
| return f"tag:{self.tag_id}" | ||
|
|
||
| def save(self, *args, **kwargs): | ||
| """Reject a moved or re-tagged group, then set scope_code; tag and parent are fixed, so the code is too.""" | ||
| self._check_tree_position_immutable() | ||
| self.scope_code = self._compute_scope_code() | ||
| super().save(*args, **kwargs) | ||
|
|
||
|
|
||
| class CompetencyRuleProfile(models.Model): | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (optional): Please consider adding line here on why this stays null below the root (a leaf has no natural key to put here, and the one-way check below means non-null wouldn't buy a stronger constraint anyway) would save the next person from asking my question again.