-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add content auto-tagging (language)
- Loading branch information
Showing
6 changed files
with
340 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
""" | ||
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 | ||
) | ||
|
||
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 | ||
|
||
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 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 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)) |
29 changes: 29 additions & 0 deletions
29
openedx/features/content_tagging/migrations/0004_system_defined_org.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
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 | ||
""" | ||
TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") | ||
|
||
TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) | ||
|
||
|
||
def revert_system_defined_org_taxonomies(apps, _schema_editor): | ||
""" | ||
Deletes association of system defined taxonomy Language (id=-1) to all orgs | ||
""" | ||
TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") | ||
|
||
TaxonomyOrg.objects.get(id=-1).delete() | ||
|
||
|
||
class Migration(migrations.Migration): | ||
dependencies = [ | ||
("content_tagging", "0003_system_defined_fixture"), | ||
] | ||
|
||
operations = [ | ||
migrations.RunPython(load_system_defined_org_taxonomies, revert_system_defined_org_taxonomies), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
""" | ||
Defines asynchronous celery task for auto-tagging content | ||
""" | ||
|
||
import logging | ||
|
||
from celery import shared_task | ||
from celery_utils.logged_task import LoggedTask | ||
from django.contrib.auth import get_user_model | ||
from edx_django_utils.monitoring import set_code_owner_attribute | ||
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) -> bool: | ||
""" | ||
Return True if this Taxonomy have some Tag set in the content_object | ||
""" | ||
_exausted = object() | ||
|
||
content_tags = api.get_content_tags(object_id=content_object, taxonomy_id=taxonomy.id) | ||
return next(content_tags, _exausted) is not _exausted | ||
|
||
|
||
def _update_tags(content_object, lang) -> None: | ||
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) | ||
lang_tag = next(tag for tag in tags if tag.external_id == lang) | ||
api.tag_content_object(lang_taxonomy, [lang_tag.id], content_object) | ||
|
||
|
||
def _delete_tags(content_object) -> None: | ||
api.delete_object_tags(content_object) | ||
|
||
|
||
@shared_task(base=LoggedTask) | ||
@set_code_owner_attribute | ||
def update_course_tags(course_key_str: str) -> bool: | ||
""" | ||
Updates the tags for a Course. | ||
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) | ||
lang = course.language | ||
|
||
_update_tags(course_key, lang) | ||
|
||
return True | ||
except Exception as e: | ||
log.error("Error updating tags for Course with id: %s. %s", course_key, e) | ||
return False | ||
|
||
|
||
@shared_task(base=LoggedTask) | ||
@set_code_owner_attribute | ||
def delete_course_tags(course_key_str: str): | ||
""" | ||
Delete the tags for a Course. | ||
Params: | ||
course_key_str (str): identifier of the Course | ||
""" | ||
course_key = CourseKey.from_string(course_key_str) | ||
|
||
log.info("Deleting tags for Course with id: %s", course_key) | ||
|
||
_delete_tags(course_key) | ||
|
||
|
||
@shared_task(base=LoggedTask) | ||
@set_code_owner_attribute | ||
def update_xblock_tags(usage_key_str: str): | ||
""" | ||
Updates the tags for a XBlock. | ||
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) | ||
lang = course.language | ||
else: | ||
return False | ||
|
||
_update_tags(usage_key, lang) | ||
|
||
return True | ||
except Exception as e: | ||
log.error("Error updating tags for XBlock with id: %s. %s", usage_key, e) | ||
return False, e | ||
|
||
|
||
@shared_task(base=LoggedTask) | ||
@set_code_owner_attribute | ||
def delete_xblock_tags(usage_key_str: str): | ||
""" | ||
Delete the tags for a XBlock. | ||
Params: | ||
usage_key_str (str): identifier of the XBlock | ||
""" | ||
usage_key = UsageKey.from_string(usage_key_str) | ||
|
||
log.info("Deleting tags for XBlock with id: %s", usage_key) | ||
|
||
_delete_tags(usage_key) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
""" | ||
Test for auto-tagging content | ||
""" | ||
from unittest.mock import patch | ||
|
||
import ddt | ||
from django.conf import settings | ||
from django.core.management import call_command | ||
from django.test.utils import override_settings | ||
from openedx_tagging.core.tagging.models import ObjectTag | ||
from organizations.models import Organization | ||
|
||
from openedx.core.lib.tests import attr | ||
from xmodule.modulestore import ModuleStoreEnum | ||
from xmodule.modulestore.tests.test_mixed_modulestore import CommonMixedModuleStoreSetup | ||
|
||
from ..tasks import delete_xblock_tags, update_course_tags, update_xblock_tags | ||
|
||
if not settings.configured: | ||
settings.configure() | ||
|
||
LANGUAGE_TAXONOMY_ID = -1 | ||
|
||
|
||
@ddt.ddt | ||
@attr("mongo") | ||
@override_settings( | ||
CELERY_EAGER_PROPAGATES_EXCEPTIONS=True, | ||
CELERY_ALWAYS_EAGER=True, | ||
BROKER_BACKEND="memory", | ||
) | ||
class TestCourseAutoTagging(CommonMixedModuleStoreSetup): | ||
""" | ||
Test if the handlers are callend and if they call the right tasks | ||
""" | ||
|
||
def _check_tag(self, object_id, taxonomy, value): | ||
object_tag = ObjectTag.objects.filter(object_id=object_id, taxonomy=taxonomy).first() | ||
assert object_tag, "Tag not 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 | ||
super().setUpClass() | ||
call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml") | ||
call_command("loaddata", "--app=content_tagging", "system_defined.yaml") | ||
|
||
def setUp(self): | ||
super().setUp() | ||
self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") | ||
|
||
@ddt.data( | ||
ModuleStoreEnum.Type.mongo, | ||
ModuleStoreEnum.Type.split, | ||
) | ||
@patch("openedx.features.content_tagging.tasks.modulestore") | ||
def test_create_course_with_xblock(self, default_ms, mock_modulestore): | ||
with patch.object(update_course_tags, "delay") as mock_update_course_tags: | ||
self.initdb(default_ms) | ||
mock_modulestore.return_value = self.store | ||
# initdb will create a Course and trigger mock_update_course_tags, so we need to reset it | ||
mock_update_course_tags.reset_mock() | ||
|
||
# Create course | ||
course = self.store.create_course( | ||
self.orgA.short_name, "test_course", "test_run", self.user_id, fields={"language": "pt"} | ||
) | ||
course_key_str = str(course.id) | ||
# Check if task was called | ||
mock_update_course_tags.assert_called_with(course_key_str) | ||
|
||
# Make the actual call synchronously | ||
assert update_course_tags(course_key_str) == True | ||
|
||
# Check if the tags are created in the Course | ||
assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") | ||
|
||
with patch.object(update_xblock_tags, "delay") as mock_update_xblock_tags: | ||
# Create XBlock | ||
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") | ||
|
||
# publish sequential changes | ||
self.store.publish(sequential.location, self.user_id) | ||
|
||
usage_key_str = str(vertical.location) | ||
# Check if task was called | ||
mock_update_xblock_tags.assert_any_call(usage_key_str) | ||
|
||
# Make the actual call synchronously | ||
assert update_xblock_tags(usage_key_str) == True | ||
|
||
# Check if the tags are created in the XBlock | ||
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Portuguese") | ||
|
||
# Update course language | ||
with patch.object(update_course_tags, "delay") as mock_update_course_tags: | ||
course.language = "en" | ||
self.store.update_item(course, self.user_id) | ||
# Check if task was called | ||
mock_update_course_tags.assert_called_with(course_key_str) | ||
|
||
# Make the actual call synchronously | ||
assert update_course_tags(course_key_str) == True | ||
|
||
self.store.publish(sequential.location, self.user_id) | ||
with patch.object(delete_xblock_tags, "delay") as mock_delete_xblock_tags: | ||
self.store.delete_item(vertical.location, self.user_id) | ||
mock_delete_xblock_tags.assert_called_with(usage_key_str) |