From 6e28ba329e0a5354d7264ea834861bf0cae4ceb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 1 Sep 2023 15:09:46 -0300 Subject: [PATCH] feat: add language auto-tagging with feature flag (#32907) --- openedx/features/content_tagging/api.py | 14 +- openedx/features/content_tagging/apps.py | 4 + .../fixtures/system_defined.yaml | 22 -- openedx/features/content_tagging/handlers.py | 76 ++++++ .../migrations/0003_system_defined_fixture.py | 41 ++- .../migrations/0004_system_defined_org.py | 49 ++++ .../migrations/0005_auto_20230830_1517.py | 19 ++ .../content_tagging/models/__init__.py | 1 - .../features/content_tagging/models/base.py | 10 +- .../content_tagging/models/system_defined.py | 48 ---- openedx/features/content_tagging/tasks.py | 169 ++++++++++++ .../content_tagging/tests/test_models.py | 4 - .../content_tagging/tests/test_tasks.py | 240 ++++++++++++++++++ openedx/features/content_tagging/toggles.py | 17 ++ .../tests/test_mixed_modulestore.py | 24 +- 15 files changed, 636 insertions(+), 102 deletions(-) delete mode 100644 openedx/features/content_tagging/fixtures/system_defined.yaml create mode 100644 openedx/features/content_tagging/handlers.py create mode 100644 openedx/features/content_tagging/migrations/0004_system_defined_org.py create mode 100644 openedx/features/content_tagging/migrations/0005_auto_20230830_1517.py create mode 100644 openedx/features/content_tagging/tasks.py create mode 100644 openedx/features/content_tagging/tests/test_tasks.py create mode 100644 openedx/features/content_tagging/toggles.py diff --git a/openedx/features/content_tagging/api.py b/openedx/features/content_tagging/api.py index b2cb4653b2f5..bc420b908489 100644 --- a/openedx/features/content_tagging/api.py +++ b/openedx/features/content_tagging/api.py @@ -1,12 +1,13 @@ """ Content Tagging APIs """ -from typing import Iterator, List, Type, Union +from __future__ import annotations + +from typing import Iterator, List, Type import openedx_tagging.core.tagging.api as oel_tagging from django.db.models import QuerySet -from opaque_keys.edx.keys import LearningContextKey -from opaque_keys.edx.locator import BlockUsageLocator +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization @@ -117,9 +118,9 @@ def get_content_tags( def tag_content_object( taxonomy: Taxonomy, - tags: List, - object_id: Union[BlockUsageLocator, LearningContextKey], -) -> List[ContentObjectTag]: + tags: list, + object_id: CourseKey | UsageKey, +) -> list[ContentObjectTag]: """ This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or course). @@ -150,4 +151,5 @@ def tag_content_object( get_taxonomy = oel_tagging.get_taxonomy get_taxonomies = oel_tagging.get_taxonomies get_tags = oel_tagging.get_tags +delete_object_tags = oel_tagging.delete_object_tags resync_object_tags = oel_tagging.resync_object_tags diff --git a/openedx/features/content_tagging/apps.py b/openedx/features/content_tagging/apps.py index 29f9c5005f43..29952b7bc33d 100644 --- a/openedx/features/content_tagging/apps.py +++ b/openedx/features/content_tagging/apps.py @@ -10,3 +10,7 @@ class ContentTaggingConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "openedx.features.content_tagging" + + def ready(self): + # Connect signal handlers + from . import handlers # pylint: disable=unused-import diff --git a/openedx/features/content_tagging/fixtures/system_defined.yaml b/openedx/features/content_tagging/fixtures/system_defined.yaml deleted file mode 100644 index 07445346272f..000000000000 --- a/openedx/features/content_tagging/fixtures/system_defined.yaml +++ /dev/null @@ -1,22 +0,0 @@ -- model: oel_tagging.taxonomy - pk: -2 - fields: - name: Organizations - description: Allows tags for any organization ID created on the instance. - enabled: true - required: true - allow_multiple: false - allow_free_text: false - visible_to_authors: false - _taxonomy_class: openedx.features.content_tagging.models.ContentAuthorTaxonomy -- model: oel_tagging.taxonomy - pk: -3 - fields: - name: Content Authors - description: Allows tags for any user ID created on the instance. - enabled: true - required: true - allow_multiple: false - allow_free_text: false - visible_to_authors: false - _taxonomy_class: openedx.features.content_tagging.models.ContentOrganizationTaxonomy diff --git a/openedx/features/content_tagging/handlers.py b/openedx/features/content_tagging/handlers.py new file mode 100644 index 000000000000..211c8f4fbf5c --- /dev/null +++ b/openedx/features/content_tagging/handlers.py @@ -0,0 +1,76 @@ +""" +Automatic tagging of content +""" + +import logging + +from django.dispatch import receiver +from openedx_events.content_authoring.data import CourseData, XBlockData +from openedx_events.content_authoring.signals import COURSE_CREATED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED + +from .tasks import delete_course_tags +from .tasks import ( + delete_xblock_tags, + update_course_tags, + update_xblock_tags +) +from .toggles import CONTENT_TAGGING_AUTO + +log = logging.getLogger(__name__) + + +@receiver(COURSE_CREATED) +def auto_tag_course(**kwargs): + """ + Automatically tag course based on their metadata + """ + course_data = kwargs.get("course", None) + if not course_data or not isinstance(course_data, CourseData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(course_data.course_key): + return + + update_course_tags.delay(str(course_data.course_key)) + + +@receiver(XBLOCK_CREATED) +@receiver(XBLOCK_UPDATED) +def auto_tag_xblock(**kwargs): + """ + Automatically tag XBlock based on their metadata + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(xblock_info.usage_key.course_key): + return + + if xblock_info.block_type == "course": + # Course update is handled by XBlock of course type + update_course_tags.delay(str(xblock_info.usage_key.course_key)) + + update_xblock_tags.delay(str(xblock_info.usage_key)) + + +@receiver(XBLOCK_DELETED) +def delete_tag_xblock(**kwargs): + """ + Automatically delete XBlock auto tags. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(xblock_info.usage_key.course_key): + return + + if xblock_info.block_type == "course": + # Course deletion is handled by XBlock of course type + delete_course_tags.delay(str(xblock_info.usage_key.course_key)) + + delete_xblock_tags.delay(str(xblock_info.usage_key)) diff --git a/openedx/features/content_tagging/migrations/0003_system_defined_fixture.py b/openedx/features/content_tagging/migrations/0003_system_defined_fixture.py index c155b341518c..7846b907c4e6 100644 --- a/openedx/features/content_tagging/migrations/0003_system_defined_fixture.py +++ b/openedx/features/content_tagging/migrations/0003_system_defined_fixture.py @@ -1,37 +1,62 @@ # Generated by Django 3.2.20 on 2023-07-11 22:57 from django.db import migrations -from django.core.management import call_command -from openedx.features.content_tagging.models import ContentLanguageTaxonomy def load_system_defined_taxonomies(apps, schema_editor): """ Creates system defined taxonomies - """ + """ # Create system defined taxonomy instances - call_command('loaddata', '--app=content_tagging', 'system_defined.yaml') + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + author_taxonomy = Taxonomy( + pk=-2, + name="Content Authors", + description="Allows tags for any user ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentAuthorTaxonomy = apps.get_model("content_tagging", "ContentAuthorTaxonomy") + author_taxonomy.taxonomy_class = ContentAuthorTaxonomy + author_taxonomy.save() + + org_taxonomy = Taxonomy( + pk=-3, + name="Organizations", + description="Allows tags for any organization ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentOrganizationTaxonomy = apps.get_model("content_tagging", "ContentOrganizationTaxonomy") + org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy + org_taxonomy.save() # Adding taxonomy class to the language taxonomy - Taxonomy = apps.get_model('oel_tagging', 'Taxonomy') language_taxonomy = Taxonomy.objects.get(id=-1) + ContentLanguageTaxonomy = apps.get_model("content_tagging", "ContentLanguageTaxonomy") language_taxonomy.taxonomy_class = ContentLanguageTaxonomy + language_taxonomy.save() def revert_system_defined_taxonomies(apps, schema_editor): """ Deletes all system defined taxonomies """ - Taxonomy = apps.get_model('oel_tagging', 'Taxonomy') + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") Taxonomy.objects.get(id=-2).delete() Taxonomy.objects.get(id=-3).delete() class Migration(migrations.Migration): - dependencies = [ - ('content_tagging', '0002_system_defined_taxonomies'), + ("content_tagging", "0002_system_defined_taxonomies"), ] operations = [ diff --git a/openedx/features/content_tagging/migrations/0004_system_defined_org.py b/openedx/features/content_tagging/migrations/0004_system_defined_org.py new file mode 100644 index 000000000000..852d67ae4ab6 --- /dev/null +++ b/openedx/features/content_tagging/migrations/0004_system_defined_org.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +def load_system_defined_org_taxonomies(apps, _schema_editor): + """ + Associates the system defined taxonomy Language (id=-1) to all orgs and + removes the ContentOrganizationTaxonomy (id=-3) from the database + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) + + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + Taxonomy.objects.get(id=-3).delete() + + + + +def revert_system_defined_org_taxonomies(apps, _schema_editor): + """ + Deletes association of system defined taxonomy Language (id=-1) to all orgs and + creates the ContentOrganizationTaxonomy (id=-3) in the database + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.get(id=-1).delete() + + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + org_taxonomy = Taxonomy( + pk=-3, + name="Organizations", + description="Allows tags for any organization ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentOrganizationTaxonomy = apps.get_model("content_tagging", "ContentOrganizationTaxonomy") + org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy + org_taxonomy.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("content_tagging", "0003_system_defined_fixture"), + ] + + operations = [ + migrations.RunPython(load_system_defined_org_taxonomies, revert_system_defined_org_taxonomies), + ] diff --git a/openedx/features/content_tagging/migrations/0005_auto_20230830_1517.py b/openedx/features/content_tagging/migrations/0005_auto_20230830_1517.py new file mode 100644 index 000000000000..6252594e8bff --- /dev/null +++ b/openedx/features/content_tagging/migrations/0005_auto_20230830_1517.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-08-30 15:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0004_system_defined_org'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentOrganizationTaxonomy', + ), + migrations.DeleteModel( + name='OrganizationModelObjectTag', + ), + ] diff --git a/openedx/features/content_tagging/models/__init__.py b/openedx/features/content_tagging/models/__init__.py index dc748c355b2d..4606c5b85386 100644 --- a/openedx/features/content_tagging/models/__init__.py +++ b/openedx/features/content_tagging/models/__init__.py @@ -9,5 +9,4 @@ from .system_defined import ( ContentLanguageTaxonomy, ContentAuthorTaxonomy, - ContentOrganizationTaxonomy, ) diff --git a/openedx/features/content_tagging/models/base.py b/openedx/features/content_tagging/models/base.py index 16df0d3752e0..38dcfb5b7572 100644 --- a/openedx/features/content_tagging/models/base.py +++ b/openedx/features/content_tagging/models/base.py @@ -1,7 +1,7 @@ """ Content Tagging models """ -from typing import List, Union +from __future__ import annotations from django.db import models from django.db.models import Exists, OuterRef, Q, QuerySet @@ -49,7 +49,7 @@ class Meta: @classmethod def get_relationships( - cls, taxonomy: Taxonomy, rel_type: RelType, org_short_name: Union[str, None] = None + cls, taxonomy: Taxonomy, rel_type: RelType, org_short_name: str | None = None ) -> QuerySet: """ Returns the relationships of the given rel_type and taxonomy where: @@ -68,7 +68,7 @@ def get_relationships( @classmethod def get_organizations( cls, taxonomy: Taxonomy, rel_type: RelType - ) -> List[Organization]: + ) -> list[Organization]: """ Returns the list of Organizations which have the given relationship to the taxonomy. """ @@ -91,7 +91,7 @@ class Meta: proxy = True @property - def object_key(self) -> Union[BlockUsageLocator, LearningContextKey]: + def object_key(self) -> BlockUsageLocator | LearningContextKey: """ Returns the object ID parsed as a UsageKey or LearningContextKey. Raises InvalidKeyError object_id cannot be parse into one of those key types. @@ -115,7 +115,7 @@ class ContentTaxonomyMixin: def taxonomies_for_org( cls, queryset: QuerySet, - org: Organization = None, + org: Organization | None = None, ) -> QuerySet: """ Filters the given QuerySet to those ContentTaxonomies which are available for the given organization. diff --git a/openedx/features/content_tagging/models/system_defined.py b/openedx/features/content_tagging/models/system_defined.py index 642e1c08b03d..9948625455e7 100644 --- a/openedx/features/content_tagging/models/system_defined.py +++ b/openedx/features/content_tagging/models/system_defined.py @@ -1,62 +1,14 @@ """ System defined models """ -from typing import Type - from openedx_tagging.core.tagging.models import ( - ModelSystemDefinedTaxonomy, - ModelObjectTag, UserSystemDefinedTaxonomy, LanguageTaxonomy, ) -from organizations.models import Organization from .base import ContentTaxonomyMixin -class OrganizationModelObjectTag(ModelObjectTag): - """ - ObjectTags for the OrganizationSystemDefinedTaxonomy. - """ - - class Meta: - proxy = True - - @property - def tag_class_model(self) -> Type: - """ - Associate the organization model - """ - return Organization - - @property - def tag_class_value(self) -> str: - """ - Returns the organization name to use it on Tag.value when creating Tags for this taxonomy. - """ - return "name" - - -class ContentOrganizationTaxonomy(ContentTaxonomyMixin, ModelSystemDefinedTaxonomy): - """ - Organization system-defined taxonomy that accepts ContentTags - - Side note: The organization of an object is already encoded in its usage ID, - but a Taxonomy with Organization as Tags is being used so that the objects can be - indexed and can be filtered in the same tagging system, without any special casing. - """ - - class Meta: - proxy = True - - @property - def object_tag_class(self) -> Type: - """ - Returns OrganizationModelObjectTag as ObjectTag subclass associated with this taxonomy. - """ - return OrganizationModelObjectTag - - class ContentLanguageTaxonomy(ContentTaxonomyMixin, LanguageTaxonomy): """ Language system-defined taxonomy that accepts ContentTags diff --git a/openedx/features/content_tagging/tasks.py b/openedx/features/content_tagging/tasks.py new file mode 100644 index 000000000000..a328634f0735 --- /dev/null +++ b/openedx/features/content_tagging/tasks.py @@ -0,0 +1,169 @@ +""" +Defines asynchronous celery task for auto-tagging content +""" +from __future__ import annotations + +import logging + +from celery import shared_task +from celery_utils.logged_task import LoggedTask +from django.conf import settings +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_tagging.core.tagging.models import Taxonomy + +from xmodule.modulestore.django import modulestore + +from . import api + +LANGUAGE_TAXONOMY_ID = -1 + +log = logging.getLogger(__name__) +User = get_user_model() + + +def _has_taxonomy(taxonomy: Taxonomy, content_object: CourseKey | UsageKey) -> bool: + """ + Return True if this Taxonomy have some Tag set in the content_object + """ + _exausted = object() + + content_tags = api.get_content_tags(object_id=str(content_object), taxonomy_id=taxonomy.id) + return next(content_tags, _exausted) is not _exausted + + +def _set_initial_language_tag(content_object: CourseKey | UsageKey, lang: str) -> None: + """ + Create a tag for the language taxonomy in the content_object if it doesn't exist. + + If the language is not configured in the plataform or the language tag doesn't exist, + use the default language of the platform. + """ + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + + if lang and not _has_taxonomy(lang_taxonomy, content_object): + tags = api.get_tags(lang_taxonomy) + is_language_configured = any(lang_code == lang for lang_code, _ in settings.LANGUAGES) is not None + if not is_language_configured: + logging.warning( + "Language not configured in the plataform: %s. Using default language: %s", + lang, + settings.LANGUAGE_CODE, + ) + lang = settings.LANGUAGE_CODE + + lang_tag = next((tag for tag in tags if tag.external_id == lang), None) + if lang_tag is None: + if not is_language_configured: + logging.error( + "Language tag not found for default language: %s. Skipping", lang + ) + return + + logging.warning( + "Language tag not found for language: %s. Using default language: %s", lang, settings.LANGUAGE_CODE + ) + lang_tag = next(tag for tag in tags if tag.external_id == settings.LANGUAGE_CODE) + + api.tag_content_object(lang_taxonomy, [lang_tag.id], content_object) + + +def _delete_tags(content_object: CourseKey | UsageKey) -> None: + api.delete_object_tags(str(content_object)) + + +@shared_task(base=LoggedTask) +def update_course_tags(course_key_str: str) -> bool: + """ + Updates the automatically-managed tags for a course + (whenever a course is created or updated) + + Params: + course_key_str (str): identifier of the Course + """ + try: + course_key = CourseKey.from_string(course_key_str) + + log.info("Updating tags for Course with id: %s", course_key) + + course = modulestore().get_course(course_key) + if course: + lang = course.language + _set_initial_language_tag(course_key, lang) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error updating tags for Course with id: %s. %s", course_key, e) + return False + + +@shared_task(base=LoggedTask) +def delete_course_tags(course_key_str: str) -> bool: + """ + Delete the tags for a Course (when the course itself has been deleted). + + Params: + course_key_str (str): identifier of the Course + """ + try: + course_key = CourseKey.from_string(course_key_str) + + log.info("Deleting tags for Course with id: %s", course_key) + + _delete_tags(course_key) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error deleting tags for Course with id: %s. %s", course_key, e) + return False + + +@shared_task(base=LoggedTask) +def update_xblock_tags(usage_key_str: str) -> bool: + """ + Updates the automatically-managed tags for a XBlock + (whenever an XBlock is created/updated). + + Params: + usage_key_str (str): identifier of the XBlock + """ + try: + usage_key = UsageKey.from_string(usage_key_str) + + log.info("Updating tags for XBlock with id: %s", usage_key) + + if usage_key.course_key.is_course: + course = modulestore().get_course(usage_key.course_key) + if course is None: + return True + lang = course.language + else: + return True + + _set_initial_language_tag(usage_key, lang) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error updating tags for XBlock with id: %s. %s", usage_key, e) + return False + + +@shared_task(base=LoggedTask) +def delete_xblock_tags(usage_key_str: str) -> bool: + """ + Delete the tags for a XBlock (when the XBlock itself is deleted). + + Params: + usage_key_str (str): identifier of the XBlock + """ + try: + usage_key = UsageKey.from_string(usage_key_str) + + log.info("Deleting tags for XBlock with id: %s", usage_key) + + _delete_tags(usage_key) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error deleting tags for XBlock with id: %s. %s", usage_key, e) + return False diff --git a/openedx/features/content_tagging/tests/test_models.py b/openedx/features/content_tagging/tests/test_models.py index a0b358ea3131..81c5da86413c 100644 --- a/openedx/features/content_tagging/tests/test_models.py +++ b/openedx/features/content_tagging/tests/test_models.py @@ -12,7 +12,6 @@ from ..models import ( ContentLanguageTaxonomy, ContentAuthorTaxonomy, - ContentOrganizationTaxonomy, ) @@ -29,9 +28,6 @@ class TestSystemDefinedModels(TestCase): (ContentAuthorTaxonomy, "taxonomy"), # Invalid object key (ContentAuthorTaxonomy, "tag"), # Invalid external_id, User don't exits (ContentAuthorTaxonomy, "object"), # Invalid object key - (ContentOrganizationTaxonomy, "taxonomy"), # Invalid object key - (ContentOrganizationTaxonomy, "tag"), # Invalid external_id, Organization don't exits - (ContentOrganizationTaxonomy, "object"), # Invalid object key ) @ddt.unpack def test_validations( diff --git a/openedx/features/content_tagging/tests/test_tasks.py b/openedx/features/content_tagging/tests/test_tasks.py new file mode 100644 index 000000000000..47bf864951f6 --- /dev/null +++ b/openedx/features/content_tagging/tests/test_tasks.py @@ -0,0 +1,240 @@ +""" +Test for auto-tagging content +""" +from __future__ import annotations + +from unittest.mock import patch + +from django.core.management import call_command +from django.test import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from organizations.models import Organization + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase + +from .. import api +from ..models import ContentLanguageTaxonomy, TaxonomyOrg +from ..toggles import CONTENT_TAGGING_AUTO + +LANGUAGE_TAXONOMY_ID = -1 + + +@skip_unless_cms # Auto-tagging is only available in the CMS +@override_waffle_flag(CONTENT_TAGGING_AUTO, active=True) +class TestAutoTagging(ModuleStoreTestCase): + """ + Test if the Course and XBlock tags are automatically created + """ + + MODULESTORE = TEST_DATA_MIXED_MODULESTORE + + def _check_tag(self, object_id: str, taxonomy_id: int, value: str | None): + """ + Check if the ObjectTag exists for the given object_id and taxonomy_id + + If value is None, check if the ObjectTag does not exists + """ + object_tag = ObjectTag.objects.filter(object_id=object_id, taxonomy_id=taxonomy_id).first() + if value is None: + assert not object_tag, f"Expected no tag for taxonomy_id={taxonomy_id}, " \ + f"but one found with value={object_tag.value}" + else: + assert object_tag, f"Tag for taxonomy_id={taxonomy_id} with value={value} with expected, but none found" + assert object_tag.value == value, f"Tag value mismatch {object_tag.value} != {value}" + + return True + + @classmethod + def setUpClass(cls): + # Run fixtures to create the system defined tags + call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml") + + # Configure language taxonomy + language_taxonomy = Taxonomy.objects.get(id=-1) + language_taxonomy.taxonomy_class = ContentLanguageTaxonomy + language_taxonomy.save() + + # Enable Language taxonomy for all orgs + TaxonomyOrg.objects.create(id=-1, taxonomy=language_taxonomy, org=None) + + super().setUpClass() + + def setUp(self): + super().setUp() + # Create user + self.user = UserFactory.create() + self.user_id = self.user.id + + self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") + self.patcher = patch("openedx.features.content_tagging.tasks.modulestore", return_value=self.store) + self.addCleanup(self.patcher.stop) + self.patcher.start() + + def test_create_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # Check if the tags are created in the Course + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + + @override_settings(LANGUAGE_CODE='pt') + def test_create_course_invalid_language(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "11"}, + ) + + # Check if the tags are created in the Course is the system default + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + + @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_CODE='pt') + def test_create_course_unsuported_language(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "en"}, + ) + + # Check if the tags are created in the Course is the system default + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + + @override_settings(LANGUAGE_CODE='pt') + def test_create_course_no_tag_language(self): + # Remove English tag + Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="English").delete() + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "en"}, + ) + + # Check if the tags are created in the Course is the system default + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + + @override_settings(LANGUAGE_CODE='pt') + def test_create_course_no_tag_default_language(self): + # Remove Portuguese tag + Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="Portuguese").delete() + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "11"}, + ) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + def test_update_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # Simulates user manually changing a tag + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + api.tag_content_object(lang_taxonomy, ["Spanish"], course.id) + + # Update course language + course.language = "en" + self.store.update_item(course, self.user_id) + + # Does not automatically update the tag + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Spanish") + + def test_create_delete_xblock(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # Create XBlocks + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + + usage_key_str = str(vertical.location) + + # Check if the tags are created in the XBlock + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Portuguese") + + # Delete the XBlock + self.store.delete_item(vertical.location, self.user_id) + + # Check if the tags are deleted + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) + def test_waffle_disabled_create_update_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + # Update course language + course.language = "en" + self.store.update_item(course, self.user_id) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) + def test_waffle_disabled_create_delete_xblock(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # Create XBlocks + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + + usage_key_str = str(vertical.location) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + # Delete the XBlock + self.store.delete_item(vertical.location, self.user_id) + + # Still no tags + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) diff --git a/openedx/features/content_tagging/toggles.py b/openedx/features/content_tagging/toggles.py new file mode 100644 index 000000000000..30a21cf77e51 --- /dev/null +++ b/openedx/features/content_tagging/toggles.py @@ -0,0 +1,17 @@ + +""" +Toggles for content tagging +""" + +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +# .. toggle_name: content_tagging.auto +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Setting this enables automatic tagging of content +# .. toggle_type: feature_flag +# .. toggle_category: admin +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-08-30 +# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/79 +CONTENT_TAGGING_AUTO = CourseWaffleFlag('content_tagging.auto', __name__) diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py index 7e35217a1064..a7b31d2b29ae 100644 --- a/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -538,13 +538,15 @@ def test_get_items_include_orphans(self, default_ms, expected_items_in_tree, orp assert len(items_in_tree) == expected_items_in_tree # draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: get draft, get ancestors up to course (2-6), compute inheritance # sends: update problem and then each ancestor up to course (edit info) # split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: definitions (calculator field), structures # sends: 2 sends to update index & structure (note, it would also be definition if a content field changed) - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 6, 5), (ModuleStoreEnum.Type.split, 3, 2, 2)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 6, 5), (ModuleStoreEnum.Type.split, 4, 2, 2)) @ddt.unpack def test_update_item(self, default_ms, num_mysql, max_find, max_send): """ @@ -1069,15 +1071,17 @@ def test_has_changes_missing_child(self, default_ms, default_branch): assert self.store.has_changes(parent) # Draft + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # Find: find parents (definition.children query), get parent, get course (fill in run?), # find parents of the parent (course), get inheritance items, # get item (to delete subtree), get inheritance again. # Sends: delete item, update parent # Split - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # Find: active_versions, 2 structures (published & draft), definition (unnecessary) # Sends: updated draft and published structures and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 6, 2), (ModuleStoreEnum.Type.split, 4, 2, 3)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 6, 2), (ModuleStoreEnum.Type.split, 5, 2, 3)) @ddt.unpack def test_delete_item(self, default_ms, num_mysql, max_find, max_send): """ @@ -1099,14 +1103,16 @@ def test_delete_item(self, default_ms, num_mysql, max_find, max_send): self.store.get_item(self.writable_chapter_location, revision=ModuleStoreEnum.RevisionOption.published_only) # Draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: find parent (definition.children), count versions of item, get parent, count grandparents, # inheritance items, draft item, draft child, inheritance # sends: delete draft vertical and update parent # Split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: draft and published structures, definition (unnecessary) # sends: update published (why?), draft, and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 8, 2), (ModuleStoreEnum.Type.split, 4, 3, 3)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 8, 2), (ModuleStoreEnum.Type.split, 5, 3, 3)) @ddt.unpack def test_delete_private_vertical(self, default_ms, num_mysql, max_find, max_send): """ @@ -1154,13 +1160,15 @@ def test_delete_private_vertical(self, default_ms, num_mysql, max_find, max_send assert vert_loc not in course.children # Draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: find parent (definition.children) 2x, find draft item, get inheritance items # send: one delete query for specific item # Split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: structure (cached) # send: update structure and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 3, 1), (ModuleStoreEnum.Type.split, 4, 1, 2)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 3, 1), (ModuleStoreEnum.Type.split, 5, 1, 2)) @ddt.unpack def test_delete_draft_vertical(self, default_ms, num_mysql, max_find, max_send): """