Skip to content

Commit

Permalink
feat: add content auto-tagging (language)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Aug 23, 2023
1 parent 4ed15f5 commit 2bc5070
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 1 deletion.
2 changes: 1 addition & 1 deletion openedx/features/content_tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ def tag_content_object(


# Expose the oel_tagging APIs

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
4 changes: 4 additions & 0 deletions openedx/features/content_tagging/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions openedx/features/content_tagging/handlers.py
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))
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),
]
129 changes: 129 additions & 0 deletions openedx/features/content_tagging/tasks.py
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)
111 changes: 111 additions & 0 deletions openedx/features/content_tagging/tests/test_tasks.py
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)

0 comments on commit 2bc5070

Please sign in to comment.