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
2 changes: 1 addition & 1 deletion src/openedx_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"""

# The version for the entire repository
__version__ = "1.3.0"
__version__ = "1.4.0"
30 changes: 30 additions & 0 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 20 additions & 1 deletion 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 @@ -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,
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
47 changes: 46 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,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.

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.

The field values in the database only exist on the Taxonomy table though, right? The CompetencyTaxonomy table only has a pointer id

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.

Yes

"""
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
89 changes: 89 additions & 0 deletions tests/openedx_tagging/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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):
Expand Down