Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 269 additions & 3 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,34 @@
"""
from __future__ import annotations

from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey

from openedx_tagging.api import create_taxonomy
from openedx_tagging.models import Taxonomy
from openedx_catalog.api import get_course_run
from openedx_catalog.models_api import CourseRun
from openedx_tagging.api import create_taxonomy, get_object_tags, tag_object
from openedx_tagging.models import ObjectTag, Tag, Taxonomy

from .models import CompetencyRuleProfile, CompetencyTaxonomy
from .models import (
CompetencyCriteriaGroup,
CompetencyCriterion,
CompetencyRuleProfile,
CompetencyTaxonomy,
LogicOperator,
)

__all__ = [
"associate_competency_criterion",
"create_competency_criterion",
"create_competency_taxonomy",
"get_competency_rule_profiles",
"is_competency_taxonomy",
"resolve_competency_tag",
"resolve_supplied_leaf_group",
"select_competency_taxonomies",
]

Expand Down Expand Up @@ -79,3 +96,252 @@ def select_competency_taxonomies(taxonomies: QuerySet[Taxonomy]) -> QuerySet[Tax
so the check costs no additional query per row.
"""
return taxonomies.select_related("competencytaxonomy")


def resolve_competency_tag(tag_id: int) -> Tag:
"""
Return the competency Tag identified by ``tag_id``.

UNSTABLE: the criteria-authoring family is incomplete, so its validation and group update
and archive behavior may change this function's shape without a deprecation cycle.

Raises Tag.DoesNotExist if no such Tag exists, or if it isn't on a CompetencyTaxonomy.
Shared by the view and :func:`associate_competency_criterion` so both agree on what counts
as valid.
"""
tag = Tag.objects.get(pk=tag_id)
# Explicit None check narrows the type for mypy.
if tag.taxonomy is None or not is_competency_taxonomy(tag.taxonomy):
raise Tag.DoesNotExist("Tag is not on a CompetencyTaxonomy.")
return tag


def _create_leaf_group(
tag: Tag,
course_run: CourseRun,
logic_operator: str | None = None,
) -> CompetencyCriteriaGroup:
"""
Return a fresh leaf CompetencyCriteriaGroup under ``tag``'s root/course-level groups.

Gets or creates the root and course-level groups, then always creates a new leaf: a
tag/course pair can hold more than one leaf, one per criterion. The root and course-level
group names are truncated to fit the name column; they are never shown in the UI.

Must run inside a transaction holding the tag row lock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a sentence here that both get_or_create()s match archived rows deliberately and that #717 decides what happens to them.

"""
name_max_length = CompetencyCriteriaGroup._meta.get_field("name").max_length
# Both get_or_create() lookups match archived rows deliberately, so an archived container is
# never replaced; rejecting or reactivating it is the archived-group checks' decision.
root, _ = CompetencyCriteriaGroup.objects.get_or_create(
tag=tag,
parent=None,
defaults={"name": f"{tag.value} (root)"[:name_max_length]},
)
course_level_group, _ = CompetencyCriteriaGroup.objects.get_or_create(
tag=tag,
course=course_run,
parent=root,
defaults={"name": f"{tag.value} — {course_run.title}"[:name_max_length]},
)
return CompetencyCriteriaGroup.objects.create(
tag=tag,
course=None,
parent=course_level_group,
logic_operator=logic_operator or LogicOperator.OR,
)


def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) -> CompetencyCriteriaGroup:
"""
Return the CompetencyCriteriaGroup identified by ``group_id``, validated as a usable leaf.

UNSTABLE: the criteria-authoring family is incomplete, so its validation and group update
and archive behavior may change this function's shape without a deprecation cycle.

Raises CompetencyCriteriaGroup.DoesNotExist if it doesn't exist, or ValidationError (keyed
"group_id") if it isn't a leaf, doesn't belong to ``tag``, or its course doesn't match
``course_run``.
"""
group = CompetencyCriteriaGroup.objects.get(pk=group_id)
if group.parent_id is None or group.course_id is not None:
raise ValidationError({"group_id": _("group_id must reference a leaf CompetencyCriteriaGroup.")})
if group.tag_id != tag.id:
raise ValidationError({"group_id": _("group_id must belong to the given competency tag.")})
if group.parent.course_id != course_run.id:
raise ValidationError({"group_id": _("group_id must belong to the given course.")})
return group


def create_competency_criterion(
group: CompetencyCriteriaGroup,
object_id: str,
rule_profile_id: int | None = None,
rule_type_override: str | None = None,
rule_payload_override: dict | None = None,
) -> CompetencyCriterion:
"""
Create and return a CompetencyCriterion under ``group``, tagging ``object_id`` along the way.

UNSTABLE: the criteria-authoring family is incomplete, so its validation and group update
and archive behavior may change this function's shape without a deprecation cycle.

``tag_object()`` replaces an object's full tag list for a taxonomy rather than appending, so
this reads the object's existing tags first and unions in the new one, rather than silently
dropping a sibling competency's tag under the same taxonomy. Raises ValidationError (keyed
"object_id") when the taxonomy refuses the tag, e.g. it allows only one tag per object.

When no rule fields are supplied, resolves to the live profile scoped to the tag's taxonomy,
else the live system default, rather than persisting null: ADR-0002 Decision 4 always
resolves to a concrete profile, and the model's own xor-override constraint forbids leaving
all three fields null. Course- and organization-scoped profiles are not resolved here, since
a criterion is authored at taxonomy scope.
"""
tag = group.tag
# tag.taxonomy_id is nullable at the model level; already guaranteed set by the caller.
assert tag.taxonomy_id is not None
existing_values = [existing_tag.value for existing_tag in get_object_tags(object_id, taxonomy_id=tag.taxonomy_id)]
if tag.value not in existing_values:
existing_values.append(tag.value)
try:
tag_object(object_id, tag.taxonomy, existing_values)
except ValueError as exc:
raise ValidationError({"object_id": str(exc)}) from exc
object_tag = ObjectTag.objects.get(object_id=object_id, taxonomy_id=tag.taxonomy_id, tag_id=tag.id)

if rule_profile_id is None and rule_type_override is None and rule_payload_override is None:
live = CompetencyRuleProfile.objects.filter(archived=False, organization__isnull=True, course__isnull=True)
# CompetencyTaxonomy shares its pk with its parent Taxonomy row.
profile = live.filter(competency_taxonomy_id=tag.taxonomy_id).first() or live.get(
competency_taxonomy__isnull=True
)
rule_profile_id = profile.id

# Parent-competency dominance/containment validation belongs here, before creation.

return CompetencyCriterion.objects.create(
group=group,
object_tag=object_tag,
rule_profile_id=rule_profile_id,
rule_type_override=rule_type_override,
rule_payload_override=rule_payload_override,
)


def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) -> None:
"""
Raise ValidationError if ``object_id`` is already associated with ``tag`` (in ``group_id``, when supplied).

Must run inside a transaction holding the tag row lock.
"""
assert tag.taxonomy_id is not None # narrowing for mypy; callers resolve competency tags only
existing_object_tag = ObjectTag.objects.filter(
object_id=object_id, taxonomy_id=tag.taxonomy_id, tag_id=tag.id,
).first()
if existing_object_tag is None:
return
duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag)
if group_id is None:
# Can't know which group slot is intended, so any existing criterion is a duplicate.
if duplicate_criteria.exists():
raise ValidationError(
{"object_id": _("A CompetencyCriterion already associates this tag with this object_id.")}
)
elif duplicate_criteria.filter(group_id=group_id).exists():
# A different explicit group is a deliberate second association (ADR-0002), not a duplicate.
raise ValidationError(
{"group_id": _("A CompetencyCriterion already associates this tag with this object_id in this group.")}
)


def _validate_rule_fields(
rule_profile_id: int | None,
rule_type_override: str | None,
rule_payload_override: dict | None,
) -> None:
"""
Raise ValidationError, keyed by the offending field, unless the rule fields form a valid combination.

Valid: a live ``rule_profile_id`` alone, both overrides without a profile, or none of the three.
"""
if rule_profile_id is not None and (rule_type_override is not None or rule_payload_override is not None):
raise ValidationError({"rule_profile_id": _(
"rule_profile_id cannot be combined with rule_type_override or rule_payload_override."
)})
if (rule_type_override is None) != (rule_payload_override is None):
supplied_field = "rule_type_override" if rule_type_override is not None else "rule_payload_override"
raise ValidationError({supplied_field: _(
"rule_type_override and rule_payload_override must be supplied together."
)})
if rule_profile_id is None:
return
archived = CompetencyRuleProfile.objects.filter(pk=rule_profile_id).values_list("archived", flat=True).first()
if archived is None:
raise ValidationError({"rule_profile_id": _("No CompetencyRuleProfile matches rule_profile_id.")})
if archived:
# Archived profiles stay resolvable for existing criteria but are hidden from new associations.
raise ValidationError({"rule_profile_id": _("rule_profile_id references an archived CompetencyRuleProfile.")})


def associate_competency_criterion(
Comment thread
ormsbee marked this conversation as resolved.
tag_id: int,
object_id: str,
*,
group_id: int | None = None,
logic_operator: str | None = None,
rule_profile_id: int | None = None,
rule_type_override: str | None = None,
rule_payload_override: dict | None = None,
) -> CompetencyCriterion:
"""
Associate ``object_id`` with the competency ``tag_id`` names, creating a criterion for it.

UNSTABLE: the criteria-authoring family is incomplete, so its validation and group update
and archive behavior may change this function's shape without a deprecation cycle.

The public entry point for the create-criterion endpoint; the caller is expected to have
already checked ``oel_tagging.can_tag_object``. Rejects re-targeting the same group a
(tag_id, object_id) pair already uses, and rejects a duplicate via the derive-or-create
path; a *different* explicit group is a deliberate second association per ADR-0002, not a
duplicate. Branches on ``group_id`` to resolve or derive/create a leaf, then creates the
criterion, all inside one transaction so a downstream failure rolls back any group just
created.

Raises ValidationError (never DRF's) on rejected input, including an invalid combination of
rule fields or a missing or archived ``rule_profile_id``; Tag.DoesNotExist if ``tag_id``
doesn't resolve to a competency tag; CompetencyCriteriaGroup.DoesNotExist if ``group_id``
doesn't resolve.
"""
tag = resolve_competency_tag(tag_id)
# tag.taxonomy is already confirmed set; re-asserted since that doesn't cross function boundaries for mypy.
assert tag.taxonomy_id is not None

try:
usage_key = UsageKey.from_string(object_id)
except InvalidKeyError as exc:
raise ValidationError({"object_id": _("object_id is not a valid usage key.")}) from exc

try:
course_run = get_course_run(usage_key.course_key)
except CourseRun.DoesNotExist as exc:
raise ValidationError({"object_id": _("No course run matches object_id's course.")}) from exc

if group_id is not None and logic_operator is not None:
raise ValidationError({"logic_operator": _("group_id and logic_operator cannot both be supplied.")})
_validate_rule_fields(rule_profile_id, rule_type_override, rule_payload_override)

with transaction.atomic():
# Serializes the duplicate check and root creation; MySQL has no partial unique index for one root per tag.
Tag.objects.select_for_update().get(pk=tag.pk)
_reject_duplicate_criterion(tag, object_id, group_id)
if group_id is not None:
group = resolve_supplied_leaf_group(group_id, tag, course_run)
else:
group = _create_leaf_group(tag, course_run, logic_operator)
return create_competency_criterion(
group=group,
object_id=object_id,
rule_profile_id=rule_profile_id,
rule_type_override=rule_type_override,
rule_payload_override=rule_payload_override,
)
15 changes: 15 additions & 0 deletions src/openedx_learning/applets/cbe/models/criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ class Meta:
# indexes every ForeignKey column by default, so a second explicit one here would only
# cost write throughput without adding any read benefit.
]
# No constraint tying `logic_operator` to child count, and no UniqueConstraint on (parent,
# ordering): a child group cannot be saved until its parent's primary key exists, so
# neither has a single-row state to check at save time. See ADR-0002 Decision 2.
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.
models.UniqueConstraint(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] I think I may be misunderstanding something more fundamental here. How is this supposed to interact with archived? #857 has merged, so the flag is on both models now (nothing sets it yet; that's #675). ADR-0002 Decision 2 says archived groups are hidden from authoring and new associations, but on the rebased tree neither create_leaf_group()'s get_or_create() nor resolve_supplied_leaf_group() looks at the flag, so a new criterion would nest a live leaf under an archived course-level group, or land in an archived leaf that the client named by group_id. And if the lookups do filter on archived=False, there's no room for a replacement row, because this constraint has no condition (for the MySQL reason you give in the comment above it).

That's the same problem CompetencyRuleProfile solved with scope_code, and I'd note that a derived column here (something like "tag:5,course:" for a root, "tag:5,course:7" for a course-level group, null for leaves and for archived rows) would also give you the one-root-per-tag constraint that the commit message says MySQL can't express, which would let the select_for_update() on the tag go away. I don't think it has to be solved in this PR, but I want the decision written down in this comment before this merges: either container groups are never archived (only leaves and criteria are, and the containers get reused), or they can be and there's a column that makes that work.

@alezconsultant alezconsultant Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ormsbee The tickets #674, #675 and #717 suggest that we should archive every level: #674's cascade "must run all the way to the root group, not just the immediate parent, stopping only once a level still has active content of its own".

I think the option with a scope_code field is good here, we can use that field in get_or_create to create a replacement group when the existing one is archived: the lookup matches only live groups, and an archived group's scope_code is null, (like in CompetencyRuleProfile now) so it doesn't block the new row.

As for cases you mention (a new leaf under an archived course-level group, and a group_id pointing to an archived leaf) will be rejected with a 400 by #717 ticket. The tag lock needed even with scope_code, because two concurrent requests without group_id each create their own leaf, so no unique index can catch that duplicate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, please create a follow-up issue for the scope_code. I don't want to drag out this PR any longer, but I also don't want us to rely on locking the Tag to ensure data integrity in the long term. Thank you.

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."
),
),
]


class CompetencyRuleProfile(models.Model):
Expand Down
47 changes: 46 additions & 1 deletion src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
"""
from __future__ import annotations

from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from rest_framework import serializers

from ...models import CompetencyRuleProfile
from ...models import CompetencyCriterion, CompetencyRuleProfile, LogicOperator


class CompetencyRuleProfileSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -54,3 +57,45 @@ def get_scope_type(self, profile: CompetencyRuleProfile) -> str:
if profile.organization_id is not None:
return "organization"
return "system_default"


class CompetencyCriterionSerializer(serializers.ModelSerializer):
"""
Doubles as the request-body parser and the response representation for a criterion.

``object_id`` and ``logic_operator`` are not CompetencyCriterion fields at all (``object_id``
isn't stored anywhere on this model; ``logic_operator`` belongs to CompetencyCriteriaGroup),
so they're declared as plain write_only fields the view reads out of ``validated_data``, not
model-bound fields. Every other field's JSON name matches its model attribute exactly
(``group_id``, ``rule_profile_id``, ``object_tag_id``), so none of them need a ``source=``.
"""

object_id = serializers.CharField(write_only=True)
Comment thread
ormsbee marked this conversation as resolved.
group_id = serializers.IntegerField(required=False, allow_null=True)
# Only ever applies on the derive-or-create path (group_id omitted): it sets the AND/OR
# operator on the brand-new leaf group this request creates. Rejected alongside an explicit
# group_id because changing an existing leaf's operator is a future group-update endpoint's
# job, not this one's -- a caller can't use this field to silently change an existing
# group's behavior.
logic_operator = serializers.ChoiceField(
choices=LogicOperator.choices, write_only=True, required=False, allow_null=True,
)
rule_profile_id = serializers.IntegerField(required=False, allow_null=True)
Comment thread
ormsbee marked this conversation as resolved.
object_tag_id = serializers.IntegerField(read_only=True)

class Meta:
model = CompetencyCriterion
fields = [
"id", "object_id", "group_id", "logic_operator",
"rule_profile_id", "rule_type_override", "rule_payload_override", "object_tag_id",
]
read_only_fields = ["id", "object_tag_id"]

def validate_object_id(self, value: str) -> str:
"""Reject an object_id that isn't a parseable usage key."""
# Checked before the view's permission check, which denies an unparseable key as a 403 rather than a 400.
try:
UsageKey.from_string(value)
except InvalidKeyError as exc:
raise serializers.ValidationError(_("object_id is not a valid usage key.")) from exc
return value
5 changes: 5 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@

urlpatterns = [
path("", include(router.urls)),
path(
"competencies/<int:tag_id>/criteria/",
views.CompetencyCriterionCreateView.as_view(),
name="criterion-create",
),
]
Loading
Loading