From c46d6346eb90e7f0e687acb01112f9fe265159c0 Mon Sep 17 00:00:00 2001 From: alezconsultant Date: Wed, 9 Sep 2026 23:30:26 +0400 Subject: [PATCH 1/3] feat: add taxonomy_type to TaxonomyOrgView perform_create() branches on taxonomy_type itself: competency calls create_competency_taxonomy(), anything else defers to super().perform_create(). _create_taxonomy_for_import() does the same for the import path; create_import() itself is unchanged, since virtual dispatch already routes through the override. See openedx/openedx-core#614 Co-Authored-By: Claude Sonnet 5 --- .../rest_api/v1/tests/test_views.py | 125 ++++++++++++++++++ .../content_tagging/rest_api/v1/views.py | 36 ++++- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 9562b76bca25..d9ab92b2077e 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -18,6 +18,7 @@ from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator, LibraryContainerLocator from openedx_authz.constants import permissions as authz_permissions from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF +from openedx_learning.models_api import CompetencyTaxonomy from openedx_tagging.models import Tag, Taxonomy from openedx_tagging.rest_api.v1.serializers import TaxonomySerializer from organizations.models import Organization @@ -520,6 +521,68 @@ def test_create_taxonomy(self, user_attr: str, expected_status: int) -> None: if user_attr == "staffA": assert response.data["orgs"] == [self.orgA.short_name] + def test_create_competency_taxonomy(self) -> None: + """ + Posting taxonomy_type="competency" creates a CompetencyTaxonomy linked to + the new Taxonomy. + """ + self.client.force_authenticate(user=self.staff) + response = self.client.post( + TAXONOMY_ORG_LIST_URL, + {"name": "Nursing Competencies", "export_id": "nursing-competencies", "taxonomy_type": "competency"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert CompetencyTaxonomy.objects.filter(pk=response.data["id"]).exists() + + def test_create_competency_taxonomy_duplicate_export_id_returns_400(self) -> None: + """ + A validation failure in the competency branch (duplicate export_id, via + full_clean()) returns a 400, like the "tags" branch, not an unhandled 500. + """ + self.client.force_authenticate(user=self.staff) + self.client.post( + TAXONOMY_ORG_LIST_URL, + {"name": "Existing", "export_id": "duplicate-export-id"}, + format="json", + ) + response = self.client.post( + TAXONOMY_ORG_LIST_URL, + {"name": "Nursing Competencies", "export_id": "duplicate-export-id", "taxonomy_type": "competency"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data("tags", None) + def test_create_taxonomy_tags_or_omitted_creates_no_competency_row(self, taxonomy_type: str | None) -> None: + """ + Posting taxonomy_type="tags", or omitting it, creates a plain Taxonomy and + no CompetencyTaxonomy row. + """ + create_data = {"name": "Plain Taxonomy", "export_id": "plain-taxonomy"} + if taxonomy_type is not None: + create_data["taxonomy_type"] = taxonomy_type + + self.client.force_authenticate(user=self.staff) + response = self.client.post(TAXONOMY_ORG_LIST_URL, create_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert not CompetencyTaxonomy.objects.filter(pk=response.data["id"]).exists() + + def test_create_taxonomy_rejects_invalid_type(self) -> None: + """ + An unsupported taxonomy_type (e.g. "system") 400s and names taxonomy_type + as the invalid field. + """ + self.client.force_authenticate(user=self.staff) + response = self.client.post( + TAXONOMY_ORG_LIST_URL, + {"name": "Rejected", "export_id": "rejected", "taxonomy_type": "system"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "taxonomy_type" in response.data + @ddt.data( ('staff', 10), ("content_creatorA", 22), @@ -2502,6 +2565,68 @@ def test_import_no_file(self) -> None: # Check if the taxonomy was not created assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + def test_import_competency_taxonomy(self) -> None: + """ + Importing with taxonomy_type="competency" creates a CompetencyTaxonomy + linked to the new Taxonomy. + """ + file = self._get_file([{"id": "tag_1", "value": "Tag 1"}], "json") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + TAXONOMY_CREATE_IMPORT_URL, + { + "taxonomy_name": "Imported Competency", + "taxonomy_description": "Imported Competency description", + "taxonomy_export_id": "imported-competency", + "taxonomy_type": "competency", + "file": file, + }, + format="multipart", + ) + assert response.status_code == status.HTTP_201_CREATED + assert CompetencyTaxonomy.objects.filter(pk=response.data["id"]).exists() + + def test_import_without_type_creates_no_competency_row(self) -> None: + """ + Importing without taxonomy_type creates a plain Taxonomy and no + CompetencyTaxonomy row. + """ + file = self._get_file([{"id": "tag_1", "value": "Tag 1"}], "json") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + TAXONOMY_CREATE_IMPORT_URL, + { + "taxonomy_name": "Imported Plain", + "taxonomy_description": "Imported Plain description", + "taxonomy_export_id": "imported-plain", + "file": file, + }, + format="multipart", + ) + assert response.status_code == status.HTTP_201_CREATED + assert not CompetencyTaxonomy.objects.filter(pk=response.data["id"]).exists() + + def test_import_rejects_invalid_type(self) -> None: + """ + An unsupported taxonomy_type (e.g. "system") 400s on import and names + taxonomy_type as the invalid field. + """ + file = self._get_file([{"id": "tag_1", "value": "Tag 1"}], "json") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + TAXONOMY_CREATE_IMPORT_URL, + { + "taxonomy_name": "Imported Rejected", + "taxonomy_description": "Imported Rejected description", + "taxonomy_export_id": "imported-rejected", + "taxonomy_type": "system", + "file": file, + }, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "taxonomy_type" in response.data + @ddt.data( "csv", "json", diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index 1d3bad7643be..28b357afd48c 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -6,12 +6,15 @@ import functools from typing import TYPE_CHECKING +from django.core import exceptions from django.db.models import Count from django.http import StreamingHttpResponse from openedx_authz import api as authz_api from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE +from openedx_learning.api import create_competency_taxonomy from openedx_tagging import rules as oel_tagging_rules -from openedx_tagging.api import TagDoesNotExist +from openedx_tagging.api import TagDoesNotExist, TaxonomyType +from openedx_tagging.models import Taxonomy from openedx_tagging.rest_api.v1.views import ObjectTagView, TaxonomyView from rest_framework import status from rest_framework.decorators import action @@ -24,7 +27,6 @@ from ...api import ( InvalidOrgException, - create_taxonomy, generate_csv_rows, get_taxonomies, get_taxonomies_for_org, @@ -100,12 +102,36 @@ def get_queryset(self): return queryset - def perform_create(self, serializer): + def perform_create(self, serializer) -> None: """ - Create a new taxonomy. + 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) user_admin_orgs = get_admin_orgs(self.request.user) - serializer.instance = create_taxonomy(**serializer.validated_data, orgs=user_admin_orgs) + set_taxonomy_orgs(taxonomy=serializer.instance, all_orgs=False, orgs=user_admin_orgs) + + 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: + try: + return create_competency_taxonomy( + name=validated_data["taxonomy_name"], + description=validated_data["taxonomy_description"], + export_id=validated_data.get("taxonomy_export_id"), + ) + except exceptions.ValidationError as e: + raise ValidationError() from e + return super()._create_taxonomy_for_import(validated_data) @action(detail=False, url_path="import", methods=["post"]) def create_import(self, request: RestRequest, **kwargs) -> Response: # type: ignore From 75308dd6efd7d7749526fb9b812194cbfbd6e22b Mon Sep 17 00:00:00 2001 From: alezconsultant Date: Fri, 11 Sep 2026 23:09:00 +0400 Subject: [PATCH 2/3] fix: update openedx-core version --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 74dcae4d3d8c..1c19f9b35298 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -826,7 +826,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core==1.3.0 +openedx-core==1.4.0 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 37e0d79208db..a1d7be32549a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -928,7 +928,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core==1.3.0 +openedx-core==1.4.0 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock From 62a9e7a22fcef9c6f999b0bc881f90c00f927cb6 Mon Sep 17 00:00:00 2001 From: alezconsultant Date: Fri, 11 Sep 2026 23:43:46 +0400 Subject: [PATCH 3/3] fix: update openedx-core version --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 310e9c56fb20..ea5f426ce011 100644 --- a/uv.lock +++ b/uv.lock @@ -4288,7 +4288,7 @@ wheels = [ [[package]] name = "openedx-core" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -4302,9 +4302,9 @@ dependencies = [ { name = "rules" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/4b/677fc3dd1abffea9460e63c5d1e4c7a3f5c3b646de5b9d81cb1a2a9ee782/openedx_core-1.3.0.tar.gz", hash = "sha256:a68b2ee246bda682711c10bb19b4820037103058b3de7263b2ca92b410e1b376", size = 235342, upload-time = "2026-08-27T17:38:11.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/3e/ed8e012f309cea40ed2e95bf62e24437d61b78b985a653bad77eba28793e/openedx_core-1.4.0.tar.gz", hash = "sha256:b14a6dba9daf641d5b59cc6d96352c60f56960e620d57a48978762bfdb43db71", size = 235679, upload-time = "2026-09-11T18:52:40.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/b3/9f5e5b045fabbb131418afcc4944a245ce0bfb2b572a95990a808e3524c1/openedx_core-1.3.0-py2.py3-none-any.whl", hash = "sha256:d25b94f837bad1150e3776b0e7b8e65570c5ed189a479a18f0d2018a5b2c2871", size = 321356, upload-time = "2026-08-27T17:38:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/7e/53/51c7f89d7ce719aacf4f75d8a4b2dbda4cb7502f243606e25ea8daf5597e/openedx_core-1.4.0-py2.py3-none-any.whl", hash = "sha256:972471eb6f80250ce25f049fa56d6629f5c2a22a9c9382863c9d876462561183", size = 321965, upload-time = "2026-09-11T18:52:38.934Z" }, ] [[package]]