diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index c24f3dacb..5881dfd19 100644 --- a/src/openedx_core/__init__.py +++ b/src/openedx_core/__init__.py @@ -6,4 +6,4 @@ """ # The version for the entire repository -__version__ = "1.3.0" +__version__ = "1.4.0" diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index 8fc4861dc..a137901e1 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -5,14 +5,44 @@ 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. + """ + 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, + taxonomy_cls=CompetencyTaxonomy, + ) + assert isinstance(taxonomy, CompetencyTaxonomy) + return taxonomy + + def is_competency_taxonomy(taxonomy: Taxonomy) -> bool: """ Return True if ``taxonomy`` is competency-enabled, i.e. has a CompetencyTaxonomy row. diff --git a/src/openedx_tagging/api.py b/src/openedx_tagging/api.py index 48ea82a2a..54addd7d8 100644 --- a/src/openedx_tagging/api.py +++ b/src/openedx_tagging/api.py @@ -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 @@ -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, @@ -39,14 +49,23 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments allow_free_text=False, read_only=False, export_id: str | None = None, + *, + taxonomy_cls: type[Taxonomy] = Taxonomy, ) -> 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`. + + Pass `taxonomy_cls` to create a subclass instance instead (e.g. a + multi-table-inheritance child): building it fresh with every field, in one + full_clean()+save(), writes both tables correctly without a separate step. """ if not export_id: export_id = f"{Taxonomy.objects.count() + 1}-{slugify(name, allow_unicode=True)}" - taxonomy = Taxonomy( + taxonomy = taxonomy_cls( name=name, description=description or "", enabled=enabled, diff --git a/src/openedx_tagging/rest_api/v1/serializers.py b/src/openedx_tagging/rest_api/v1/serializers.py index fcab256a3..18dd34e59 100644 --- a/src/openedx_tagging/rest_api/v1/serializers.py +++ b/src/openedx_tagging/rest_api/v1/serializers.py @@ -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 @@ -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 @@ -91,6 +97,7 @@ class Meta: "can_delete_taxonomy", "can_tag_object", "export_id", + "taxonomy_type", ] def get_tags_count(self, instance): @@ -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): diff --git a/src/openedx_tagging/rest_api/v1/views.py b/src/openedx_tagging/rest_api/v1/views.py index f079a048d..9bf57b1bb 100644 --- a/src/openedx_tagging/rest_api/v1/views.py +++ b/src/openedx_tagging/rest_api/v1/views.py @@ -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: @@ -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: """ @@ -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) diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 8ebe712bf..53ceff16a 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -3,13 +3,58 @@ """ 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. + """ + 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. diff --git a/tests/openedx_tagging/test_views.py b/tests/openedx_tagging/test_views.py index 99322dfb8..da933f7c0 100644 --- a/tests/openedx_tagging/test_views.py +++ b/tests/openedx_tagging/test_views.py @@ -396,6 +396,44 @@ def test_create_taxonomy_read_only(self, create_data): assert response.status_code == status.HTTP_201_CREATED assert response.data["read_only"] is True + @ddt.data( + ("tags", status.HTTP_201_CREATED), + ("competency", status.HTTP_201_CREATED), + (None, status.HTTP_201_CREATED), + ) + @ddt.unpack + def test_create_taxonomy_type_tags_or_omitted(self, taxonomy_type, expected_status): + """ + Posting any accepted taxonomy_type (or omitting it) to this raw TaxonomyView + succeeds and creates a Taxonomy row. + """ + url = TAXONOMY_LIST_URL + create_data = {"name": "Taxonomy Type Test", "export_id": "taxonomy-type-test"} + if taxonomy_type is not None: + create_data["taxonomy_type"] = taxonomy_type + + self.client.force_authenticate(user=self.staff) + response = self.client.post(url, create_data, format="json") + assert response.status_code == expected_status + assert Taxonomy.objects.filter(name="Taxonomy Type Test").exists() + + @ddt.data("system", "bogus") + def test_create_taxonomy_type_invalid_value_rejected(self, taxonomy_type): + """ + An unsupported taxonomy_type value still 400s via the ChoiceField itself. + """ + url = TAXONOMY_LIST_URL + + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + {"name": "Rejected Invalid", "export_id": "rejected-invalid", "taxonomy_type": taxonomy_type}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "taxonomy_type" in response.data + assert not Taxonomy.objects.filter(name="Rejected Invalid").exists() + @ddt.data( (None, status.HTTP_401_UNAUTHORIZED), ("user", status.HTTP_403_FORBIDDEN), @@ -3237,6 +3275,57 @@ def test_import_no_export_id(self, file_format) -> None: for i, tag in enumerate(tags): assert tag["value"] == new_tags[i]["value"] + @ddt.data( + ("tags", status.HTTP_201_CREATED), + ("competency", status.HTTP_201_CREATED), + (None, status.HTTP_201_CREATED), + ) + @ddt.unpack + def test_import_taxonomy_type_tags_or_omitted(self, taxonomy_type, expected_status) -> None: + """ + Posting any accepted taxonomy_type (or omitting it) to this raw create/import + endpoint succeeds and creates a Taxonomy row. + """ + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [{"id": "tag_1", "value": "Tag 1"}] + file = self._get_file(new_tags, "json") + data = { + "taxonomy_name": "Taxonomy Type Import Test", + "taxonomy_description": "Imported Taxonomy description", + "file": file, + } + if taxonomy_type is not None: + data["taxonomy_type"] = taxonomy_type + + self.client.force_authenticate(user=self.staff) + response = self.client.post(url, data, format="multipart") + assert response.status_code == expected_status + assert Taxonomy.objects.filter(name="Taxonomy Type Import Test").exists() + + @ddt.data("system", "bogus") + def test_import_taxonomy_type_invalid_value_rejected(self, taxonomy_type) -> None: + """ + An unsupported taxonomy_type value still 400s via the ChoiceField itself. + """ + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [{"id": "tag_1", "value": "Tag 1"}] + file = self._get_file(new_tags, "json") + + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Rejected Invalid Import", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_type": taxonomy_type, + "file": file, + }, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "taxonomy_type" in response.data + assert not Taxonomy.objects.filter(name="Rejected Invalid Import").exists() + @ddt.ddt class TestImportTagsView(ImportTaxonomyMixin, APITestCase):