Skip to content
Open
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
1 change: 1 addition & 0 deletions src/openedx_learning/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
# This wildcard import is okay because the applet api module declares __all__.
# pylint: disable=wildcard-import
from .applets.cbe.api import *
from .applets.cbe.views import CompetencyTaxonomyView # pylint: disable=unused-import
39 changes: 39 additions & 0 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,55 @@
"""
from __future__ import annotations

from django.db import transaction
from django.db.models import QuerySet

from openedx_tagging.api import create_taxonomy
from openedx_tagging.models import Taxonomy

from .models import CompetencyTaxonomy

__all__ = [
"create_competency_taxonomy",
"is_competency_taxonomy",
"select_competency_taxonomies",
]


def create_competency_taxonomy( # pylint: disable=too-many-positional-arguments
name: str,
description: str | None = None,
enabled=True,
allow_multiple=True,
allow_free_text=False,
read_only=False,
export_id: str | None = None,
) -> CompetencyTaxonomy:
"""
Create, save, and return a new CompetencyTaxonomy with the given attributes.
"""
with transaction.atomic():
taxonomy = create_taxonomy(
name=name,
description=description,
enabled=enabled,
allow_multiple=allow_multiple,
allow_free_text=allow_free_text,
read_only=read_only,
export_id=export_id,
)
competency_taxonomy = CompetencyTaxonomy(taxonomy_ptr=taxonomy)
# Copy the parent's fields onto the child instance: save_base(raw=True) below writes
# only the child's own row and skips Taxonomy entirely, so it never reads these back
# off the DB itself the way a normal save() of the MTI chain would.
for field in Taxonomy._meta.fields:
setattr(competency_taxonomy, field.attname, getattr(taxonomy, field.attname))
competency_taxonomy.save_base(raw=True)
# competency_taxonomy carries every Taxonomy field too (copied above), so it's usable
# anywhere a Taxonomy is expected, without a second query to re-fetch the parent row.
return competency_taxonomy


def is_competency_taxonomy(taxonomy: Taxonomy) -> bool:
"""
Return True if ``taxonomy`` is competency-enabled, i.e. has a CompetencyTaxonomy row.
Expand Down
45 changes: 45 additions & 0 deletions src/openedx_learning/applets/cbe/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""
REST API views for Competency-Based Education (CBE).
"""
from __future__ import annotations

from django.core import exceptions
from rest_framework.exceptions import ValidationError

from openedx_tagging.api import TaxonomyType
from openedx_tagging.models import Taxonomy
from openedx_tagging.rest_api.v1.views import TaxonomyView

from .api import create_competency_taxonomy


class CompetencyTaxonomyView(TaxonomyView):
"""
TaxonomyView that also supports taxonomy_type="competency".
"""

def perform_create(self, serializer) -> None:
"""
Create a new taxonomy (competency or tags).
"""
taxonomy_type = serializer.validated_data.pop("taxonomy_type", TaxonomyType.TAGS.value)
if taxonomy_type == TaxonomyType.COMPETENCY.value:
try:
serializer.instance = create_competency_taxonomy(**serializer.validated_data)
except exceptions.ValidationError as e:
raise ValidationError() from e
else:
super().perform_create(serializer)

def _create_taxonomy_for_import(self, validated_data: dict) -> Taxonomy:
"""
Create a competency taxonomy if requested, otherwise defer to the base implementation.
"""
taxonomy_type = validated_data.get("taxonomy_type", TaxonomyType.TAGS.value)
if taxonomy_type == TaxonomyType.COMPETENCY.value:
return create_competency_taxonomy(
name=validated_data["taxonomy_name"],
description=validated_data["taxonomy_description"],
export_id=validated_data.get("taxonomy_export_id"),
)
return super()._create_taxonomy_for_import(validated_data)
13 changes: 13 additions & 0 deletions src/openedx_tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

from collections import defaultdict
from enum import Enum
from typing import Any, Counter, cast

from django.db import models, transaction
Expand All @@ -31,6 +32,15 @@
OBJECT_MAX_TAGS = 100


class TaxonomyType(Enum):
"""
Valid values for a taxonomy's type on create.
"""

TAGS = "tags"
COMPETENCY = "competency"


def create_taxonomy( # pylint: disable=too-many-positional-arguments
name: str,
description: str | None = None,
Expand All @@ -42,6 +52,9 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments
) -> Taxonomy:
"""
Creates, saves, and returns a new Taxonomy with the given attributes.

If `export_id` is not given, one is auto-generated from the current
Taxonomy count and a slug of `name`.
"""
if not export_id:
export_id = f"{Taxonomy.objects.count() + 1}-{slugify(name, allow_unicode=True)}"
Expand Down
12 changes: 12 additions & 0 deletions src/openedx_tagging/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from rest_framework.request import Request
from rest_framework.reverse import reverse

from openedx_tagging.api import TaxonomyType
from openedx_tagging.data import TagData
from openedx_tagging.import_export.parsers import ParserFormat
from openedx_tagging.models import ObjectTag, Tag, TagImportTask, Taxonomy
Expand Down Expand Up @@ -74,6 +75,11 @@ class TaxonomySerializer(UserPermissionsSerializerMixin, serializers.ModelSerial
can_delete_taxonomy = serializers.SerializerMethodField(method_name='get_can_delete')
can_tag_object = serializers.SerializerMethodField()
export_id = serializers.CharField(required=False)
taxonomy_type = serializers.ChoiceField(
choices=[TaxonomyType.TAGS.value, TaxonomyType.COMPETENCY.value],
default=TaxonomyType.TAGS.value,
write_only=True,
)

class Meta:
model = Taxonomy
Expand All @@ -91,6 +97,7 @@ class Meta:
"can_delete_taxonomy",
"can_tag_object",
"export_id",
"taxonomy_type",
]

def get_tags_count(self, instance):
Expand Down Expand Up @@ -429,6 +436,11 @@ class TaxonomyImportNewBodySerializer(TaxonomyImportBodySerializer): # pylint:
taxonomy_name = serializers.CharField(required=True)
taxonomy_description = serializers.CharField(default="")
taxonomy_export_id = serializers.CharField(required=False)
taxonomy_type = serializers.ChoiceField(
choices=[TaxonomyType.TAGS.value, TaxonomyType.COMPETENCY.value],
default=TaxonomyType.TAGS.value,
write_only=True,
)


class TagImportTaskSerializer(serializers.ModelSerializer):
Expand Down
23 changes: 13 additions & 10 deletions src/openedx_tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ def perform_create(self, serializer) -> None:
"""
Create a new taxonomy.
"""
serializer.validated_data.pop("taxonomy_type", None)
try:
serializer.instance = create_taxonomy(**serializer.validated_data)
except exceptions.ValidationError as e:
Expand Down Expand Up @@ -298,6 +299,17 @@ def export(self, request, **_kwargs) -> HttpResponse:

return HttpResponse(tags, content_type=content_type)

def _create_taxonomy_for_import(self, validated_data: dict) -> Taxonomy:
"""
Create the taxonomy for create_import(). Override to support other taxonomy_type values.
"""
validated_data.pop("taxonomy_type", None)
return create_taxonomy(
validated_data["taxonomy_name"],
validated_data["taxonomy_description"],
export_id=validated_data.get("taxonomy_export_id"),
)

@action(detail=False, url_path="import", methods=["post"])
def create_import(self, request: Request, **_kwargs) -> Response:
"""
Expand All @@ -306,18 +318,9 @@ def create_import(self, request: Request, **_kwargs) -> Response:
body = TaxonomyImportNewBodySerializer(data=request.data)
body.is_valid(raise_exception=True)

taxonomy_name = body.validated_data["taxonomy_name"]
taxonomy_export_id = body.validated_data.get("taxonomy_export_id")
taxonomy_description = body.validated_data["taxonomy_description"]
file = body.validated_data["file"].file
parser_format = body.validated_data["parser_format"]

# If no taxonomy_export_id provided, a unique export id will be generated
taxonomy = create_taxonomy(
taxonomy_name,
taxonomy_description,
export_id=taxonomy_export_id,
)
taxonomy = self._create_taxonomy_for_import(body.validated_data)

try:
import_success, task, _plan = import_tags(taxonomy, file, parser_format)
Expand Down
52 changes: 51 additions & 1 deletion tests/openedx_learning/applets/cbe/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,63 @@
"""
import pytest

from openedx_learning.api import is_competency_taxonomy, select_competency_taxonomies
from openedx_learning.api import create_competency_taxonomy, is_competency_taxonomy, select_competency_taxonomies
from openedx_learning.models import CompetencyTaxonomy
from openedx_tagging.models import Taxonomy

pytestmark = pytest.mark.django_db


def test_create_competency_taxonomy_saves_both_rows() -> None:
"""
create_competency_taxonomy() saves a CompetencyTaxonomy and Taxonomy row that both
carry the given field values.

Re-fetching from Taxonomy.objects (not just CompetencyTaxonomy.objects) is the
regression check for using save_base(raw=True): a plain save() on the child
instance would re-save the parent Taxonomy row with blank/default field values,
which this assertion would catch.
"""
result = create_competency_taxonomy(
name="Nursing",
description="Nursing competencies",
enabled=False,
allow_multiple=False,
allow_free_text=True,
read_only=True,
export_id="nursing-v1",
)

assert isinstance(result, CompetencyTaxonomy)
assert is_competency_taxonomy(result) is True

for taxonomy in (
CompetencyTaxonomy.objects.get(pk=result.pk),
Taxonomy.objects.get(pk=result.pk),
):
assert taxonomy.name == "Nursing"
assert taxonomy.description == "Nursing competencies"
assert taxonomy.enabled is False
assert taxonomy.allow_multiple is False
assert taxonomy.allow_free_text is True
assert taxonomy.read_only is True
assert taxonomy.export_id == "nursing-v1"


def test_create_competency_taxonomy_defaults() -> None:
"""
create_competency_taxonomy() applies the same defaults as create_taxonomy() when
only name is given, including an auto-generated export_id.
"""
result = create_competency_taxonomy(name="Welding")

assert result.enabled is True
assert result.allow_multiple is True
assert result.allow_free_text is False
assert result.read_only is False
assert result.export_id


def test_is_competency_taxonomy() -> None:
"""
is_competency_taxonomy() is True for a competency taxonomy, False for a plain one.
Expand Down
Loading