feat: add language auto-tagging with feature flag (#32907)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
76
openedx/features/content_tagging/handlers.py
Normal file
76
openedx/features/content_tagging/handlers.py
Normal file
@@ -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))
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -9,5 +9,4 @@ from .base import (
|
||||
from .system_defined import (
|
||||
ContentLanguageTaxonomy,
|
||||
ContentAuthorTaxonomy,
|
||||
ContentOrganizationTaxonomy,
|
||||
)
|
||||
|
||||
@@ -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 TaxonomyOrg(models.Model):
|
||||
|
||||
@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 @@ class TaxonomyOrg(models.Model):
|
||||
@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 ContentObjectTag(ObjectTag):
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
169
openedx/features/content_tagging/tasks.py
Normal file
169
openedx/features/content_tagging/tasks.py
Normal file
@@ -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
|
||||
@@ -12,7 +12,6 @@ from openedx_tagging.core.tagging.api import create_taxonomy
|
||||
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(
|
||||
|
||||
240
openedx/features/content_tagging/tests/test_tasks.py
Normal file
240
openedx/features/content_tagging/tests/test_tasks.py
Normal file
@@ -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)
|
||||
17
openedx/features/content_tagging/toggles.py
Normal file
17
openedx/features/content_tagging/toggles.py
Normal file
@@ -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__)
|
||||
@@ -538,13 +538,15 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
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 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
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 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
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 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user