From 67a65cf5eda4b3374fe015082c7c4c2da0918e6e Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Wed, 16 Sep 2026 20:19:12 +0300 Subject: [PATCH] feat: add taxonomy_type to the taxonomy Get/List response TaxonomyOrgSerializer now reports a read-only taxonomy_type ("tags"/"competency") on GET, computed via openedx_learning.api's is_competency_taxonomy(), so Studio can badge Competency Taxonomies and gate the Competency Management page. TaxonomyOrgView.get_queryset() selects the related CompetencyTaxonomy row so this costs no extra query per row. Per ADR 0013 (openedx-core), computed instead of naming the CompetencyTaxonomy relation directly. Co-Authored-By: Claude Sonnet 5 --- .../rest_api/v1/serializers.py | 17 ++++++ .../rest_api/v1/tests/test_views.py | 52 +++++++++++++++++++ .../content_tagging/rest_api/v1/views.py | 6 ++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py index 22d875670817..d61ea5a67a41 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py @@ -6,6 +6,8 @@ from openedx_authz import api as authz_api from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS +from openedx_learning.api import is_competency_taxonomy +from openedx_tagging.api import TaxonomyType from openedx_tagging.rest_api.v1.serializers import ( ObjectTagMinimalSerializer, ObjectTagsByTaxonomySerializer, @@ -98,6 +100,21 @@ class Meta: fields = TaxonomySerializer.Meta.fields + ["orgs", "all_orgs"] read_only_fields = ["orgs", "all_orgs"] + def to_representation(self, instance) -> dict: + """ + Serialize the taxonomy, adding the computed ``taxonomy_type`` (read side). + + ``taxonomy_type`` is also a write-only field inherited from ``TaxonomySerializer``, + used only as create-time input, so DRF excludes it from the base representation. + This adds it back for reads, computed from whether the taxonomy is backed by a + CompetencyTaxonomy, via the CBE app's own public API. + """ + data = super().to_representation(instance) + data["taxonomy_type"] = ( + TaxonomyType.COMPETENCY.value if is_competency_taxonomy(instance) else TaxonomyType.TAGS.value + ) + return data + class ObjectTagOrgByTaxonomySerializer(ObjectTagsByTaxonomySerializer): """ 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 d9ab92b2077e..81cbb440b032 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.api import create_competency_taxonomy from openedx_learning.models_api import CompetencyTaxonomy from openedx_tagging.models import Tag, Taxonomy from openedx_tagging.rest_api.v1.serializers import TaxonomySerializer @@ -611,6 +612,24 @@ def test_list_taxonomy_query_count(self, user_attr: str, expected_queries: int): assert taxonomy["can_delete_taxonomy"] == user.is_staff # not the metadata about the taxonomy. assert taxonomy["can_tag_object"] + def test_list_taxonomy_type_mixed(self) -> None: + """ + The list endpoint reports "taxonomy_type" per row for a mixed set of taxonomies: + plain taxonomies as "tags", a competency taxonomy as "competency", and never null. + """ + competency_taxonomy = create_competency_taxonomy(name="Nursing Competencies", export_id="nursing-competencies") + set_taxonomy_orgs(taxonomy=competency_taxonomy, all_orgs=False, orgs=[self.orgA]) + + self.client.force_authenticate(user=self.staff) + response = self.client.get(TAXONOMY_ORG_LIST_URL, {"org": self.orgA.short_name, "page_size": 20}) + assert response.status_code == status.HTTP_200_OK + + taxonomy_types_by_name = {t["name"]: t["taxonomy_type"] for t in response.data["results"]} + assert taxonomy_types_by_name["t1"] == "tags" + assert taxonomy_types_by_name["ro1"] == "tags" + assert taxonomy_types_by_name["Nursing Competencies"] == "competency" + assert None not in taxonomy_types_by_name.values() + @ddt.ddt class TestTaxonomyDetailExportMixin(TestTaxonomyObjectsMixin): @@ -931,6 +950,39 @@ def _test_api_call(self, **kwargs) -> None: **(TaxonomySerializer(taxonomy, context=context)).data, ) + def test_detail_taxonomy_type_tags_for_plain_taxonomy(self) -> None: + """ + A plain taxonomy's detail response reports taxonomy_type "tags". + """ + self.client.force_authenticate(user=self.staff) + response = self.client.get(TAXONOMY_ORG_DETAIL_URL.format(pk=self.t1.pk)) + assert response.status_code == status.HTTP_200_OK + assert response.data["taxonomy_type"] == "tags" + + def test_detail_taxonomy_type_tags_for_read_only_taxonomy(self) -> None: + """ + A read-only taxonomy (maintained by the system or an external integration, per + Taxonomy.read_only's docstring) still reports taxonomy_type "tags": is_competency_taxonomy() + keys off the presence of a CompetencyTaxonomy row, not off read_only. + """ + self.client.force_authenticate(user=self.staff) + response = self.client.get(TAXONOMY_ORG_DETAIL_URL.format(pk=self.ro1.pk)) + assert response.status_code == status.HTTP_200_OK + assert response.data["taxonomy_type"] == "tags" + assert response.data["read_only"] is True + + def test_detail_taxonomy_type_competency(self) -> None: + """ + A competency taxonomy's detail response reports taxonomy_type "competency". + """ + competency_taxonomy = create_competency_taxonomy(name="Nursing Competencies", export_id="nursing-competencies") + set_taxonomy_orgs(taxonomy=competency_taxonomy, all_orgs=True) + + self.client.force_authenticate(user=self.staff) + response = self.client.get(TAXONOMY_ORG_DETAIL_URL.format(pk=competency_taxonomy.pk)) + assert response.status_code == status.HTTP_200_OK + assert response.data["taxonomy_type"] == "competency" + @skip_unless_cms class TestTaxonomyExportViewSet(TestTaxonomyDetailExportMixin, APITestCase): 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 e485aeb07192..d402bb8fa143 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -11,7 +11,7 @@ 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_learning.api import create_competency_taxonomy, select_competency_taxonomies from openedx_tagging import rules as oel_tagging_rules from openedx_tagging.api import TagDoesNotExist, TaxonomyType from openedx_tagging.models import Taxonomy @@ -106,6 +106,10 @@ def get_queryset(self): # Annotate with tags_count to avoid selecting all the tags queryset = queryset.annotate(tags_count=Count("tag", distinct=True)) + # Select the competency taxonomy relation (if any) so is_competency_taxonomy() + # costs no extra query per row when serializing taxonomy_type. + queryset = select_competency_taxonomies(queryset) + return queryset def perform_create(self, serializer) -> None: