diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab93446b..7ed2febe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,12 @@ Change Log Unreleased +[1.33.0] - 2023-01-03 +--------------------- +* https://github.com/openedx/openedx-events/pull/143 merged, so adding back + changes reverted in version 1.32.1 +* Added refresh_xblock_skills command to update skills for xblocks. +* Added handlers for openedx-events: XBLOCK_DELETED, XBLOCK_PUBLISHED and XBLOCK_PUBLISHED. [1.32.2] - 2023-01-02 --------------------- diff --git a/requirements/base.in b/requirements/base.in index 5787b770..0505121b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,9 +5,11 @@ django-solo pytz edx-rest-api-client edx-django-utils +edx-opaque-keys celery algoliasearch django-ses beautifulsoup4 django-choices django-filter +openedx-events diff --git a/requirements/ci.txt b/requirements/ci.txt index 15ff579f..5482cd47 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -10,7 +10,7 @@ charset-normalizer==2.1.1 # via requests codecov==2.1.12 # via -r requirements/ci.in -coverage==7.0.1 +coverage==7.0.2 # via codecov distlib==0.3.6 # via virtualenv diff --git a/requirements/dev.txt b/requirements/dev.txt index c2965d86..1aeab3ce 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -23,6 +23,7 @@ astroid==2.3.3 attrs==22.2.0 # via # -r requirements/test.txt + # openedx-events # pytest beautifulsoup4==4.11.1 # via -r requirements/test.txt @@ -78,7 +79,7 @@ code-annotations==1.3.0 # via -r requirements/test.txt codecov==2.1.12 # via -r requirements/ci.txt -coverage[toml]==7.0.1 +coverage[toml]==7.0.2 # via # -r requirements/ci.txt # -r requirements/test.txt @@ -105,6 +106,7 @@ django==3.2.16 # djangorestframework # edx-django-utils # edx-i18n-tools + # openedx-events django-choices==1.7.2 # via -r requirements/test.txt django-crum==0.7.9 @@ -135,6 +137,10 @@ edx-lint==1.5.2 # via # -c requirements/constraints.txt # -r requirements/dev.in +edx-opaque-keys[django]==2.3.0 + # via + # -r requirements/test.txt + # openedx-events edx-rest-api-client==5.5.0 # via -r requirements/test.txt exceptiongroup==1.1.0 @@ -147,6 +153,10 @@ faker==15.3.4 # via # -r requirements/test.txt # factory-boy +fastavro==1.7.0 + # via + # -r requirements/test.txt + # openedx-events filelock==3.9.0 # via # -r requirements/ci.txt @@ -193,6 +203,8 @@ newrelic==8.5.0 # via # -r requirements/test.txt # edx-django-utils +openedx-events==4.1.0 + # via -r requirements/test.txt packaging==22.0 # via # -r requirements/ci.txt @@ -242,7 +254,7 @@ pycparser==2.21 # via # -r requirements/test.txt # cffi -pydocstyle==6.1.1 +pydocstyle==6.2.1 # via -r requirements/dev.in pygments==2.14.0 # via diff-cover @@ -264,6 +276,10 @@ pylint-plugin-utils==0.7 # via # pylint-celery # pylint-django +pymongo==3.13.0 + # via + # -r requirements/test.txt + # edx-opaque-keys pynacl==1.5.0 # via # -r requirements/test.txt @@ -341,6 +357,7 @@ stevedore==4.1.1 # -r requirements/test.txt # code-annotations # edx-django-utils + # edx-opaque-keys testfixtures==7.0.4 # via -r requirements/test.txt text-unidecode==1.3 diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 69359d41..eb36cff0 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # diff --git a/requirements/pip.txt b/requirements/pip.txt index 4fad87a5..7555f4bb 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,14 +1,12 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -wheel==0.38.4 - # via -r requirements/pip.in - -# The following packages are considered to be unsafe in a requirements file: pip==22.3.1 # via -r requirements/pip.in setuptools==65.6.3 # via -r requirements/pip.in +wheel==0.38.4 + # via -r requirements/pip.in diff --git a/requirements/test.txt b/requirements/test.txt index 7ef989b9..81919300 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -13,7 +13,9 @@ amqp==2.6.1 asgiref==3.6.0 # via django attrs==22.2.0 - # via pytest + # via + # openedx-events + # pytest beautifulsoup4==4.11.1 # via -r requirements/base.in billiard==3.6.4.0 @@ -40,7 +42,7 @@ click==8.1.3 # edx-django-utils code-annotations==1.3.0 # via -r requirements/test.in -coverage[toml]==7.0.1 +coverage[toml]==7.0.2 # via pytest-cov ddt==1.6.0 # via -r requirements/test.in @@ -55,6 +57,7 @@ ddt==1.6.0 # django-solo # djangorestframework # edx-django-utils + # openedx-events django-choices==1.7.2 # via -r requirements/base.in django-crum==0.7.9 @@ -75,6 +78,10 @@ edx-django-utils==5.2.0 # via # -r requirements/base.in # edx-rest-api-client +edx-opaque-keys[django]==2.3.0 + # via + # -r requirements/base.in + # openedx-events edx-rest-api-client==5.5.0 # via -r requirements/base.in exceptiongroup==1.1.0 @@ -85,6 +92,8 @@ faker==15.3.4 # via # -r requirements/test.in # factory-boy +fastavro==1.7.0 + # via openedx-events idna==3.4 # via requests iniconfig==1.1.1 @@ -103,6 +112,8 @@ mock==5.0.0 # via -r requirements/test.in newrelic==8.5.0 # via edx-django-utils +openedx-events==4.1.0 + # via -r requirements/base.in packaging==22.0 # via pytest pbr==5.11.0 @@ -115,6 +126,8 @@ pycparser==2.21 # via cffi pyjwt==2.6.0 # via edx-rest-api-client +pymongo==3.13.0 + # via edx-opaque-keys pynacl==1.5.0 # via edx-django-utils pytest==7.2.0 @@ -164,6 +177,7 @@ stevedore==4.1.1 # via # code-annotations # edx-django-utils + # edx-opaque-keys testfixtures==7.0.4 # via -r requirements/test.in text-unidecode==1.3 diff --git a/taxonomy/exceptions.py b/taxonomy/exceptions.py index 2bebfaec..fb655f8b 100644 --- a/taxonomy/exceptions.py +++ b/taxonomy/exceptions.py @@ -22,6 +22,12 @@ class ProgramMetadataNotFoundError(Exception): """ +class XBlockMetadataNotFoundError(Exception): + """ + Exception to raise when metadata was not found for an XBlock. + """ + + class InvalidCommandOptionsError(Exception): """ Exception to raise when incorrect command options are provided. diff --git a/taxonomy/management/commands/refresh_xblock_skills.py b/taxonomy/management/commands/refresh_xblock_skills.py new file mode 100644 index 00000000..8ea73869 --- /dev/null +++ b/taxonomy/management/commands/refresh_xblock_skills.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +Management command for refreshing the skills associated with xblocks. +""" + +import logging + +from django.core.management.base import BaseCommand +from django.utils.translation import gettext as _ +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey + +from taxonomy import utils +from taxonomy.choices import ProductTypes +from taxonomy.exceptions import InvalidCommandOptionsError, XBlockMetadataNotFoundError +from taxonomy.models import RefreshXBlockSkillsConfig +from taxonomy.providers.utils import get_course_metadata_provider, get_xblock_metadata_provider + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Command to refresh skills associated with the XBlocks. + + Example usage: + $ ./manage.py refresh_xblock_skills --xblock 'xblock-usage-key1' --xblock 'xblock-usage-key2' --commit + $ # To refresh all xblock skills under given courses. + $ ./manage.py refresh_xblock_skills --course 'course-v1:edX+DemoX+1' --course 'course-v1:edX+DemoY+1' --commit + $ # args-from-database means command line arguments will be picked from the database. + $ ./manage.py refresh_xblock_skills --args-from-database + $ # To update all xblocks in all the courses + $ ./manage.py refresh_xblock_skills --all --commit + """ + help = 'Refreshes the skills associated with XBlocks.' + product_type = ProductTypes.XBlock + + def add_arguments(self, parser): + """ + Add arguments to the command parser. + """ + parser.add_argument( + '--course', + metavar=_('COURSE_KEY'), + action='append', + help=_('Update skills for XBlocks under given course keys. For eg. course-v1:edX+DemoX.1+2014'), + default=[], + ) + parser.add_argument( + '--xblock', + metavar=_('USAGE_KEY'), + action='append', + help=_('Update skills for given Xblock usage keys.'), + default=[], + ) + parser.add_argument( + '--args-from-database', + action='store_true', + help=_('Use arguments from the RefreshXBlockSkillsConfig model instead of the command line.'), + ) + parser.add_argument( + '--all', + action='store_true', + help=_('Create xblock skill mapping for all xblocks in all the courses.'), + ) + parser.add_argument( + '--commit', + action='store_true', + default=False, + help=_('Commits the skills to storage.') + ) + + def get_args_from_database(self): + """ + Return an options dictionary from the current RefreshXBlockSkillsConfig model. + """ + config = RefreshXBlockSkillsConfig.get_solo() + argv = config.arguments.split() + parser = self.create_parser('manage.py', 'refresh_xblock_skills') + return parser.parse_args(argv).__dict__ + + @staticmethod + def is_valid_key(key, key_cls, key_cls_str): + """ + Validates usage and course keys. + """ + try: + key_cls.from_string(key) + return True + except InvalidKeyError: + LOGGER.error('[TAXONOMY] Invalid %s: [%s]', key_cls_str, key) + return False + + def handle(self, *args, **options): + """ + Entry point for management command execution. + """ + if not (options['args_from_database'] or options['all'] or options['course'] or options['xblock']): + raise InvalidCommandOptionsError( + 'Either course, xblock, args_from_database or all argument must be provided.', + ) + + if options['args_from_database']: + options = self.get_args_from_database() + + if options['course'] and options['xblock']: + raise InvalidCommandOptionsError('Either course or xblock argument should be provided and not both.') + + LOGGER.info('[TAXONOMY] Refresh XBlock Skills. Options: [%s]', options) + + courses = [] + xblocks_from_args = [] + xblock_provider = get_xblock_metadata_provider() + if options['all']: + courses = get_course_metadata_provider().get_all_courses() + elif options['course']: + courses = [{"key": course} for course in options['course']] + elif options['xblock']: + valid_usage_keys = set(key for key in options['xblock'] if self.is_valid_key(key, UsageKey, "UsageKey")) + xblocks_from_args = xblock_provider.get_xblocks(xblock_ids=list(valid_usage_keys)) + if not xblocks_from_args: + raise XBlockMetadataNotFoundError( + 'No xblock metadata was found for following xblocks. {}'.format(options['xblock']) + ) + else: + raise InvalidCommandOptionsError('Either course, xblock or --all argument must be provided.') + + for course in courses: + course_key = course.get("key") + if self.is_valid_key(course_key, CourseKey, "CourseKey"): + xblocks = xblock_provider.get_all_xblocks_in_course(course_key) + LOGGER.info('[TAXONOMY] Refresh xblocks skills process started for course: {course_key}.') + utils.refresh_product_skills(xblocks, options['commit'], self.product_type) + + if xblocks_from_args: + LOGGER.info('[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', options['xblock']) + utils.refresh_product_skills(xblocks_from_args, options['commit'], self.product_type) diff --git a/taxonomy/migrations/0029_xblock_refresh_argmuments_table.py b/taxonomy/migrations/0029_xblock_refresh_argmuments_table.py new file mode 100644 index 00000000..1cedff2b --- /dev/null +++ b/taxonomy/migrations/0029_xblock_refresh_argmuments_table.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2022-12-06 04:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('taxonomy', '0028_xblock_skills'), + ] + + operations = [ + migrations.CreateModel( + name='RefreshXBlockSkillsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('arguments', models.TextField(blank=True, default='', help_text='Useful for manually running a Jenkins job. Specify like "--course=key1 --course=key2".')), + ], + options={ + 'verbose_name': 'refresh_xblock_skills argument', + }, + ), + ] diff --git a/taxonomy/models.py b/taxonomy/models.py index 6da96a44..3f34b3d6 100644 --- a/taxonomy/models.py +++ b/taxonomy/models.py @@ -367,6 +367,40 @@ def __repr__(self): return ''.format(self.id) +class RefreshXBlockSkillsConfig(SingletonModel): + """ + Configuration for the refresh_xblock_skills management command. + + .. no_pii: + """ + + class Meta: + """ + Meta configuration for RefreshXBlockSkillsConfig model. + """ + + app_label = 'taxonomy' + verbose_name = 'refresh_xblock_skills argument' + + arguments = models.TextField( + blank=True, + help_text='Useful for manually running a Jenkins job. Specify like "--course=key1 --course=key2".', + default='', + ) + + def __str__(self): + """ + Create a human-readable string representation of the object. + """ + return ''.format(self.arguments) + + def __repr__(self): + """ + Create a unique string representation of the object. + """ + return ''.format(self.id) + + class RefreshProgramSkillsConfig(SingletonModel): """ Configuration for the refresh_program_skills management command. diff --git a/taxonomy/signals/handlers.py b/taxonomy/signals/handlers.py index 9b72d7ae..3a16a8e7 100644 --- a/taxonomy/signals/handlers.py +++ b/taxonomy/signals/handlers.py @@ -5,8 +5,16 @@ import logging from django.dispatch import receiver +from openedx_events.content_authoring.data import DuplicatedXBlockData, XBlockData +from openedx_events.content_authoring.signals import XBLOCK_DELETED, XBLOCK_DUPLICATED, XBLOCK_PUBLISHED -from taxonomy.tasks import update_course_skills, update_program_skills, update_xblock_skills +from taxonomy.tasks import ( + delete_xblock_skills, + duplicate_xblock_skills, + update_course_skills, + update_program_skills, + update_xblock_skills +) from .signals import UPDATE_COURSE_SKILLS, UPDATE_PROGRAM_SKILLS, UPDATE_XBLOCK_SKILLS @@ -38,3 +46,42 @@ def handle_update_xblock_skills(sender, xblock_uuid, **kwargs): # pylint: disab """ LOGGER.info('[TAXONOMY] UPDATE_XBLOCK_SKILLS signal received') update_xblock_skills.delay([xblock_uuid]) + + +@receiver(XBLOCK_DELETED) +def handle_xblock_deleted(**kwargs): # pylint: disable=unused-argument + """ + Handle signal and trigger task to delete xblock skills. + """ + LOGGER.info('[TAXONOMY] XBLOCK_DELETED signal received') + xblock_data = kwargs.get('xblock_info', None) + if not xblock_data or not isinstance(xblock_data, XBlockData): + LOGGER.error('[TAXONOMY] Received null or incorrect data from XBLOCK_DELETED.') + return + delete_xblock_skills.delay([xblock_data.usage_key]) + + +@receiver(XBLOCK_DUPLICATED) +def handle_xblock_duplicated(**kwargs): # pylint: disable=unused-argument + """ + Handle signal and trigger task to duplicate xblock skills. + """ + LOGGER.info('[TAXONOMY] XBLOCK_DUPLICATED signal received') + xblock_data = kwargs.get('xblock_info', None) + if not xblock_data or not isinstance(xblock_data, DuplicatedXBlockData): + LOGGER.error('[TAXONOMY] Received null or incorrect data from XBLOCK_DUPLICATED.') + return + duplicate_xblock_skills.delay(xblock_data.source_usage_key, xblock_data.usage_key) + + +@receiver(XBLOCK_PUBLISHED) +def handle_xblock_published(**kwargs): # pylint: disable=unused-argument + """ + Handle signal and trigger task to update xblock skills. + """ + LOGGER.info('[TAXONOMY] XBLOCK_PUBLISHED signal received') + xblock_data = kwargs.get('xblock_info', None) + if not xblock_data or not isinstance(xblock_data, XBlockData): + LOGGER.error('[TAXONOMY] Received null or incorrect data from XBLOCK_PUBLISHED.') + return + update_xblock_skills.delay([xblock_data.usage_key]) diff --git a/taxonomy/tasks.py b/taxonomy/tasks.py index c107642c..f168260e 100644 --- a/taxonomy/tasks.py +++ b/taxonomy/tasks.py @@ -64,3 +64,29 @@ def update_xblock_skills(xblock_uuids): utils.refresh_product_skills(xblocks, True, ProductTypes.XBlock) else: LOGGER.warning('[TAXONOMY] No xblock found with uuids [%d] to update skills.', xblock_uuids) + + +@shared_task() +def delete_xblock_skills(xblock_uuids): + """ + Task to delete xblock skills. + + Arguments: + xblock_uuids (list): uuids of xblocks for which skills needs to be deleted. + """ + LOGGER.info('[TAXONOMY] delete_xblock_skills task triggered') + for xblock_uuid in xblock_uuids: + utils.delete_product(key_or_uuid=xblock_uuid, product_type=ProductTypes.XBlock) + + +@shared_task() +def duplicate_xblock_skills(source_xblock_uuid, xblock_uuid): + """ + Task to duplicate xblock skills. + + Arguments: + source_xblock_uuid (str): source xblock usage key. + xblock_uuid (str): uuid of xblock for which skills needs to be duplicated. + """ + LOGGER.info('[TAXONOMY] duplicate_xblock_skills task triggered') + utils.duplicate_xblock_skills(source_xblock_uuid, xblock_uuid) diff --git a/taxonomy/utils.py b/taxonomy/utils.py index b263804d..5acfac44 100644 --- a/taxonomy/utils.py +++ b/taxonomy/utils.py @@ -7,6 +7,7 @@ import boto3 from bs4 import BeautifulSoup +from django.utils.timezone import now from edx_django_utils.cache import get_cache_key, TieredCache from edx_django_utils.cache.utils import hashlib @@ -174,7 +175,7 @@ def process_skills_data(product, skills, should_commit_to_db, product_type, **kw except KeyError: message = f'[TAXONOMY] Missing keys in skills data for key: {product[key_or_uuid]}' LOGGER.error(message) - failures.append((product['uuid'], message)) + failures.append((product[key_or_uuid], message)) except (ValueError, TypeError): message = f'[TAXONOMY] Invalid type for `confidence` in skills for key: {product[key_or_uuid]}' LOGGER.error(message) @@ -305,7 +306,7 @@ def refresh_product_skills(products, should_commit_to_db, product_type): except TaxonomyAPIError: message = f'[TAXONOMY] API Error for key: {product[key_or_uuid]}' LOGGER.error(message) - all_failures.append((product['uuid'], message)) + all_failures.append((product[key_or_uuid], message)) continue try: @@ -601,3 +602,63 @@ def apply_batching_to_translate_large_text(key, source_text): result['TranslatedText'] = translated_text result['SourceLanguageCode'] = source_language_code return result + + +def duplicate_model_instance(instance): + """ + Duplicate passed django model as described in django docs. + + https://docs.djangoproject.com/en/4.1/topics/db/queries/#copying-model-instances + + source_block (model instance): django model instance to be duplicated. + """ + instance.id = None + instance.pk = None + instance._state.adding = True # pylint: disable=protected-access + if hasattr(instance, "created"): + instance.created = now() + if hasattr(instance, "modified"): + instance.modified = now() + instance.save() + return instance + + +def delete_product(key_or_uuid: str, product_type: ProductTypes): + """ + Delete product from database if it exists. + + key_or_uuid (str): Key or uuid of the product to be deleted. + product_type (ProductTypes): Product type. + """ + product_model, identifier = get_product_skill_model_and_identifier(product_type) + product_model.objects.filter(**{identifier: key_or_uuid}).delete() + + +def duplicate_xblock_skills(source_xblock_uuid, xblock_uuid): + """ + Duplicate xblock and its skills if source xblock exists. + + source_xblock_uuid (str): source xblock usage key. + xblock_uuid (str): new xblock usage key. + """ + # get source xblock_skill instance. + source_xblock = XBlockSkills.objects.filter(usage_key=source_xblock_uuid).first() + if not source_xblock: + LOGGER.info(f'[TAXONOMY] Source xblock: {source_xblock_uuid} not found') + return + + # just in case xblock_skill with new usage_key exists, stop execution. + if XBlockSkills.objects.filter(usage_key=xblock_uuid).exists(): + LOGGER.error(f'[TAXONOMY] XBlock with usage_key: {xblock_uuid} already exists!') + return + + # fetch source xblock skills. + source_xblock_skills = XBlockSkillData.objects.filter(xblock=source_xblock).all() + # copy source xblock with new usage_key. + source_xblock.usage_key = xblock_uuid + xblock = duplicate_model_instance(source_xblock) + + # copy source xblock skills and set relation with new xblock. + for source_xblock_skill in source_xblock_skills: + source_xblock_skill.xblock = xblock + duplicate_model_instance(source_xblock_skill) diff --git a/test_utils/factories.py b/test_utils/factories.py index 24fe09a1..484df261 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -11,8 +11,8 @@ from taxonomy.models import ( CourseSkills, Job, JobPostings, JobSkills, Skill, Translation, SkillCategory, SkillSubCategory, ProgramSkill, - SkillsQuiz, RefreshCourseSkillsConfig, RefreshProgramSkillsConfig, Industry, IndustryJobSkill, - XBlockSkillData, XBlockSkills + SkillsQuiz, RefreshCourseSkillsConfig, RefreshProgramSkillsConfig, RefreshXBlockSkillsConfig, Industry, + IndustryJobSkill, XBlockSkillData, XBlockSkills ) from taxonomy.choices import UserGoal @@ -56,6 +56,24 @@ class Meta: arguments = factory.LazyAttribute(lambda x: FAKER.word()) +# pylint: disable=no-member, invalid-name +class RefreshXBlockSkillsConfigFactory(factory.django.DjangoModelFactory): + """ + Factory class for RefreshXBlockSkillsConfig model. + """ + + class Meta: + """ + Meta for ``RefreshXBlockSkillsConfig``. + """ + + model = RefreshXBlockSkillsConfig + django_get_or_create = ('id',) + + id = factory.Sequence(lambda n: n) + arguments = factory.LazyAttribute(lambda x: FAKER.word()) + + # pylint: disable=no-member, invalid-name class SkillCategoryFactory(factory.django.DjangoModelFactory): """ diff --git a/test_utils/mocks.py b/test_utils/mocks.py index 7d6d15d0..27529645 100644 --- a/test_utils/mocks.py +++ b/test_utils/mocks.py @@ -27,7 +27,7 @@ def __init__( super().__init__(*args, spec=dict, **kwargs) self.uuid = uuid if uuid is not DEFAULT else uuid4() - self.key = key if key is not DEFAULT else 'course-id/{}'.format(FAKER.slug()) + self.key = key if key is not DEFAULT else 'course-v1:{}'.format("+".join(FAKER.words(3))) self.title = title if title is not DEFAULT else 'Test Course {}'.format(FAKER.sentence()) self.short_description = short_description if short_description is not DEFAULT else FAKER.sentence(nb_words=10) self.full_description = full_description if full_description is not DEFAULT else FAKER.sentence(nb_words=50) @@ -65,7 +65,10 @@ def __init__( """ super().__init__(*args, spec=dict, **kwargs) - self.key = key if key is not DEFAULT else 'xblock-id/{}'.format(FAKER.slug()) + self.key = key if key is not DEFAULT else 'block-v1:edx+D+D+type@{}+block@{}'.format( + FAKER.word(), + FAKER.uuid4(), + ) self.content_type = content_type if content_type is not DEFAULT else 'Video' self.content = content if content is not DEFAULT else FAKER.sentence(nb_words=50) diff --git a/tests/management/test_refresh_xblock_skills.py b/tests/management/test_refresh_xblock_skills.py new file mode 100644 index 00000000..a97617ae --- /dev/null +++ b/tests/management/test_refresh_xblock_skills.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- +""" +Tests for the django management command `refresh_xblock_skills`. +""" + +import logging +from uuid import uuid4 + +import mock +import responses +from pytest import mark +from testfixtures import LogCapture + +from django.core.management import call_command + +from taxonomy.exceptions import InvalidCommandOptionsError, TaxonomyAPIError, XBlockMetadataNotFoundError +from taxonomy.models import Skill, RefreshXBlockSkillsConfig, XBlockSkillData, XBlockSkills +from test_utils.mocks import MockCourse, MockXBlock, mock_as_dict +from test_utils.providers import DiscoveryCourseMetadataProvider, DiscoveryXBlockMetadataProvider +from test_utils.sample_responses.skills import MISSING_NAME_SKILLS, SKILLS_EMSI_CLIENT_RESPONSE, TYPE_ERROR_SKILLS +from test_utils.testcase import TaxonomyTestCase + + +@mark.django_db +class RefreshXBlockSkillsCommandTests(TaxonomyTestCase): + """ + Test command `refresh_xblock_skills`. + """ + command = 'refresh_xblock_skills' + + def setUp(self): + super().setUp() + self.skills_emsi_client_response = SKILLS_EMSI_CLIENT_RESPONSE + self.missing_skills = MISSING_NAME_SKILLS + self.type_error_skills = TYPE_ERROR_SKILLS + self.course_1 = mock_as_dict(MockCourse()) + self.course_2 = mock_as_dict(MockCourse()) + self.course_3 = mock_as_dict(MockCourse()) + self.xblock_1 = mock_as_dict(MockXBlock()) + self.xblock_2 = mock_as_dict(MockXBlock()) + self.xblock_3 = mock_as_dict(MockXBlock()) + self.mock_access_token() + + def assert_xblock_skill_count(self, skill_count, xblock_skill_count, xblock_skill_data_count): + """ + Asserts that the number of skills, xblock skills, and xblock skill data + objects in the database are as expected. + + Args: + skill_count (int): The expected number of skills in the database. + xblock_skill_count (int): The expected number of xblock skills in the database. + xblock_skill_data_count (int): The expected number of xblock skill data in the database. + """ + self.assertEqual(Skill.objects.count(), skill_count) + self.assertEqual(XBlockSkills.objects.count(), xblock_skill_count) + self.assertEqual(XBlockSkillData.objects.count(), xblock_skill_data_count) + + def test_missing_arguments(self): + """ + Test missing arguments. + """ + with self.assertRaisesRegex( + InvalidCommandOptionsError, + 'Either course, xblock, args_from_database or all argument must be provided.' + ): + call_command(self.command) + + def test_missing_arguments_from_database_config(self): + """ + Test missing arguments from --args-from-database. + """ + config = RefreshXBlockSkillsConfig.get_solo() + config.arguments = '' + config.save() + with self.assertRaisesRegex( + InvalidCommandOptionsError, + 'Either course, xblock or --all argument must be provided.', + ): + call_command(self.command, '--args-from-database') + + def test_course_and_xblock_argument_raise_error(self): + """ + Test that the command raises an error with both course and xblock arguments. + """ + with self.assertRaises(InvalidCommandOptionsError) as assert_context: + call_command(self.command, '--course', self.course_1.key, '--xblock', self.xblock_2.key) + self.assertEqual( + assert_context.exception.args[0], + 'Either course or xblock argument should be provided and not both.' + ) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + def test_non_existant_xblock(self, get_xblock_metadata_provider): + """ + Test that command throws XBlockMetadataNotFoundError if xblock does not + exist for a xblock key. + """ + xblock_key = str(uuid4()) + get_xblock_metadata_provider.return_value = DiscoveryXBlockMetadataProvider([]) + + with self.assertRaises(XBlockMetadataNotFoundError) as assert_context: + call_command(self.command, '--xblock', xblock_key) + self.assertEqual( + assert_context.exception.args[0], + 'No xblock metadata was found for following xblocks. {}'.format([xblock_key]) + ) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + def test_xblock_without_content(self, get_xblock_metadata_provider): + """ + Test that command work as expected if xblock content does not exist. + """ + self.xblock_1.content = '' + get_xblock_metadata_provider.return_value = DiscoveryXBlockMetadataProvider([self.xblock_1]) + + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--xblock', self.xblock_1.key, '--commit') + self.assertEqual(len(log_capture.records), 3) + messages = [record.msg for record in log_capture.records] + self.assertEqual( + messages, + [ + '[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', + '[TAXONOMY] Refresh %s skills process completed. \n' + 'Failures: %s \n' + 'Total %s Updated Successfully: %s \n' + 'Total %s Skipped: %s \n' + 'Total Failures: %s \n' + ] + ) + + self.assert_xblock_skill_count(0, 0, 0) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_course_metadata_provider') + def test_missing_course_key_with_all(self, get_course_metadata_provider, get_xblock_metadata_provider): + """ + Test that command logs error and skips processing for it if course key is missing. + """ + self.course_1.key = None + get_course_metadata_provider.return_value = DiscoveryCourseMetadataProvider([self.course_1]) + get_xblock_metadata_provider.return_value = DiscoveryXBlockMetadataProvider([self.xblock_1]) + + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--all', '--commit') + messages = [record.msg for record in log_capture.records] + self.assertEqual(len(log_capture.records), 2) + self.assertEqual( + messages, + [ + '[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Invalid %s: [%s]', + ] + ) + + self.assert_xblock_skill_count(0, 0, 0) + + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_course_xblock_skills_saved(self, get_product_skills_mock, get_xblock_provider_mock): + """ + Test that the command creates a Skill and many XBlockSkillData records. + """ + get_product_skills_mock.return_value = self.skills_emsi_client_response + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_1, self.xblock_2] + ) + self.assert_xblock_skill_count(0, 0, 0) + + call_command(self.command, '--course', self.course_1.key, '--course', self.course_2.key, '--commit') + + self.assert_xblock_skill_count(4, 2, 8) + + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_3, self.xblock_1] + ) + call_command(self.command, '--course', self.course_3.key, '--course', self.course_1.key, '--commit') + + self.assert_xblock_skill_count(4, 3, 12) + + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_course_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_course_xblock_skill_saved_with_all_param( + self, + get_product_skills_mock, + get_course_provider_mock, + get_xblock_provider_mock, + ): + """ + Test that the command creates a Skill and many XBlockSkillData records using --all param. + """ + get_product_skills_mock.return_value = self.skills_emsi_client_response + get_course_provider_mock.return_value = DiscoveryCourseMetadataProvider( + [self.course_1, self.course_2, self.course_3] + ) + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_3, self.xblock_1, self.xblock_2] + ) + self.assert_xblock_skill_count(0, 0, 0) + + call_command(self.command, '--all', '--commit') + + self.assert_xblock_skill_count(4, 3, 12) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_xblock_skill_not_saved_upon_exception(self, + get_product_skills_mock, + get_xblock_provider_mock): + """ + Test that the command does not create any records when the API throws an exception. + """ + get_product_skills_mock.side_effect = TaxonomyAPIError() + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_1, self.xblock_2] + ) + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--xblock', self.xblock_1.key, '--xblock', self.xblock_2.key, '--commit') + # Validate a descriptive and readable log message. + self.assertEqual(len(log_capture.records), 5) + messages = [record.msg for record in log_capture.records] + self.assertEqual( + messages, + [ + '[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', + '[TAXONOMY] API Error for key: {}'.format(self.xblock_1.key), + '[TAXONOMY] API Error for key: {}'.format(self.xblock_2.key), + '[TAXONOMY] Refresh %s skills process completed. \n' + 'Failures: %s \n' + 'Total %s Updated Successfully: %s \n' + 'Total %s Skipped: %s \n' + 'Total Failures: %s \n' + ] + ) + + self.assert_xblock_skill_count(0, 0, 0) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_args_from_database_config(self, get_product_skills_mock, get_xblock_provider_mock): + """ + Test that the command works via args from database config. + """ + config = RefreshXBlockSkillsConfig.get_solo() + config.arguments = ' --xblock {} --xblock {} --commit '.format(self.xblock_1.key, self.xblock_2.key) + config.save() + get_product_skills_mock.return_value = self.skills_emsi_client_response + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_1, self.xblock_2], + ) + self.assert_xblock_skill_count(0, 0, 0) + + call_command(self.command, '--args-from-database') + + self.assert_xblock_skill_count(4, 2, 8) + for skill_details in self.skills_emsi_client_response['data']: + assert Skill.objects.filter( + name=skill_details['skill']['name'], + description=skill_details['skill']['description'], + type_id=skill_details['skill']['type']['id'], + type_name=skill_details['skill']['type']['name'], + info_url=skill_details['skill']['infoUrl'], + ).exists() + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_xblock_skill_not_saved_for_key_error( + self, + get_product_skills_mock, + get_xblock_provider_mock + ): + """ + Test that the command does not create any records when a Skill key error occurs. + """ + get_product_skills_mock.return_value = self.missing_skills + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_1, self.xblock_2], + ) + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--xblock', self.xblock_1.key, '--xblock', self.xblock_2.key, '--commit') + # Validate a descriptive and readable log message. + messages = [record.msg for record in log_capture.records] + self.assertEqual(len(log_capture.records), 7) + self.assertEqual( + messages, + [ + '[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', + f'[TAXONOMY] Missing keys in skills data for key: {self.xblock_1.key}', + '[TAXONOMY] Skills data received from EMSI. Skills: [%s]', + f'[TAXONOMY] Missing keys in skills data for key: {self.xblock_2.key}', + '[TAXONOMY] Skills data received from EMSI. Skills: [%s]', + '[TAXONOMY] Refresh %s skills process completed. \n' + 'Failures: %s \n' + 'Total %s Updated Successfully: %s \n' + 'Total %s Skipped: %s \n' + 'Total Failures: %s \n' + ] + ) + + self.assert_xblock_skill_count(0, 0, 0) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + def test_xblock_skill_not_saved_for_type_error( + self, + get_product_skills_mock, + get_xblock_provider_mock + ): + """ + Test that the command does not create any records when a record value error occurs. + """ + get_product_skills_mock.return_value = self.type_error_skills + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider( + [self.xblock_1, self.xblock_2], + ) + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--xblock', self.xblock_1.key, '--xblock', self.xblock_2.key, '--commit') + # Validate a descriptive and readable log message. + self.assertEqual(len(log_capture.records), 7) + messages = [record.msg for record in log_capture.records] + self.assertEqual( + messages, + [ + '[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', + f'[TAXONOMY] Invalid type for `confidence` in skills for key: {self.xblock_1.key}', + '[TAXONOMY] Skills data received from EMSI. Skills: [%s]', + f'[TAXONOMY] Invalid type for `confidence` in skills for key: {self.xblock_2.key}', + '[TAXONOMY] Skills data received from EMSI. Skills: [%s]', + '[TAXONOMY] Refresh %s skills process completed. \n' + 'Failures: %s \n' + 'Total %s Updated Successfully: %s \n' + 'Total %s Skipped: %s \n' + 'Total Failures: %s \n' + ] + ) + + self.assert_xblock_skill_count(0, 0, 0) + + @responses.activate + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.get_xblock_metadata_provider') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.EMSISkillsApiClient.get_product_skills') + @mock.patch('taxonomy.management.commands.refresh_xblock_skills.utils.process_skills_data') + def test_xblock_skill_not_saved_for_exception( + self, + mock_process_xblock_skills_data, + get_product_skills_mock, + get_xblock_provider_mock, + + ): + """ + Test that the command does not create any records when a record value error occurs. + """ + get_product_skills_mock.return_value = self.skills_emsi_client_response + get_xblock_provider_mock.return_value = DiscoveryXBlockMetadataProvider([self.xblock_1]) + mock_process_xblock_skills_data.side_effect = Exception("UNKNOWN ERROR.") + self.assert_xblock_skill_count(0, 0, 0) + + with LogCapture(level=logging.INFO) as log_capture: + call_command(self.command, '--xblock', self.xblock_1.key, '--commit') + # Validate a descriptive and readable log message. + self.assertEqual(len(log_capture.records), 5) + messages = [record.msg for record in log_capture.records] + self.assertEqual( + messages, + ['[TAXONOMY] Refresh XBlock Skills. Options: [%s]', + '[TAXONOMY] Refresh XBlock skills process started for xblocks: [%s]', + '[TAXONOMY] Skills data received from EMSI. Skills: [%s]', + f'[TAXONOMY] Exception for key: {self.xblock_1.key} Error: UNKNOWN ERROR.', + '[TAXONOMY] Refresh %s skills process completed. \n' + 'Failures: %s \n' + 'Total %s Updated Successfully: %s \n' + 'Total %s Skipped: %s \n' + 'Total Failures: %s \n'] + ) + + self.assert_xblock_skill_count(0, 0, 0) diff --git a/tests/test_models.py b/tests/test_models.py index a7c2c114..ab669009 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -275,6 +275,24 @@ def test_string_representation(self): assert expected_repr == course_skill_config.__repr__() +@mark.django_db +class TestRefreshXBlockSkillConfig(TestCase): + """ + Tests for the ``RefreshXBlockSkillsConfig`` model. + """ + + def test_string_representation(self): + """ + Test the string representation of the RefreshXBlockSkillsConfig model. + """ + xblock_skill_config = factories.RefreshXBlockSkillsConfigFactory() + expected_str = ''.format(xblock_skill_config.arguments) + expected_repr = ''.format(xblock_skill_config.id) + + assert expected_str == xblock_skill_config.__str__() + assert expected_repr == xblock_skill_config.__repr__() + + @mark.django_db class TestJob(TestCase): """ diff --git a/tests/test_signals.py b/tests/test_signals.py index 8ba658ea..bb35a410 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,13 +1,21 @@ """ Tests for taxonomy signals. """ +import logging import unittest import mock +from openedx_events.content_authoring.data import DuplicatedXBlockData, XBlockData +from openedx_events.content_authoring.signals import XBLOCK_DELETED, XBLOCK_DUPLICATED, XBLOCK_PUBLISHED from pytest import mark +from testfixtures import LogCapture -from taxonomy.models import CourseSkills, Skill, ProgramSkill, XBlockSkills -from taxonomy.signals.signals import UPDATE_COURSE_SKILLS, UPDATE_PROGRAM_SKILLS, UPDATE_XBLOCK_SKILLS +from taxonomy.models import CourseSkills, Skill, ProgramSkill, XBlockSkillData, XBlockSkills +from taxonomy.signals.signals import ( + UPDATE_COURSE_SKILLS, + UPDATE_PROGRAM_SKILLS, + UPDATE_XBLOCK_SKILLS, +) from test_utils.mocks import MockCourse, MockProgram, MockXBlock from test_utils.providers import ( DiscoveryCourseMetadataProvider, @@ -72,9 +80,10 @@ def test_update_program_skills_signal_handler(self, get_product_skills_mock, get @mock.patch('taxonomy.tasks.get_xblock_metadata_provider') @mock.patch('taxonomy.tasks.utils.EMSISkillsApiClient.get_product_skills') - def test_update_xblock_skills_signal_handler(self, get_product_skills_mock, get_block_provider_mock): + def test_xblock_skill_update_and_deleted_signal_handlers(self, get_product_skills_mock, get_block_provider_mock): """ - Verify that `UPDATE_XBLOCK_SKILLS` signal work as expected. + Verify that `UPDATE_XBLOCK_SKILLS` and `XBLOCK_DELETED` signal work as + expected. """ get_product_skills_mock.return_value = self.skills_emsi_client_response get_block_provider_mock.return_value = DiscoveryXBlockMetadataProvider([self.xblock]) @@ -90,3 +99,82 @@ def test_update_xblock_skills_signal_handler(self, get_product_skills_mock, get_ self.assertEqual(skill.count(), 4) self.assertEqual(xblock_skill.count(), 1) self.assertEqual(xblock_skill.first().skills.count(), 4) + + XBLOCK_DELETED.send_event( + xblock_info=XBlockData( + usage_key=self.xblock.key, + block_type=self.xblock.content_type, + ), + ) + self.assertEqual(xblock_skill.count(), 0) + self.assertEqual(XBlockSkillData.objects.all().count(), 0) + self.assertEqual(skill.count(), 4) + + @mock.patch('taxonomy.tasks.get_xblock_metadata_provider') + @mock.patch('taxonomy.tasks.utils.EMSISkillsApiClient.get_product_skills') + def test_xblock_skill_published_and_duplicated_signals(self, get_product_skills_mock, get_block_provider_mock): + """ + Verify that `XBLOCK_PUBLISHED` & `XBLOCK_DUPLICATED` signal work as expected. + """ + get_product_skills_mock.return_value = self.skills_emsi_client_response + get_block_provider_mock.return_value = DiscoveryXBlockMetadataProvider([self.xblock]) + + # verify that no `Skill` and `XBlockSkills` records exist before executing the task + skill = Skill.objects.all() + xblock_skill = XBlockSkills.objects.all() + self.assertEqual(skill.count(), 0) + self.assertEqual(xblock_skill.count(), 0) + + XBLOCK_PUBLISHED.send_event( + xblock_info=XBlockData( + usage_key=self.xblock.key, + block_type=self.xblock.content_type, + ), + ) + + self.assertEqual(skill.count(), 4) + self.assertEqual(xblock_skill.count(), 1) + self.assertEqual(xblock_skill.first().skills.count(), 4) + self.assertEqual(XBlockSkillData.objects.all().count(), 4) + + dup_xblock = MockXBlock() + XBLOCK_DUPLICATED.send_event( + xblock_info=DuplicatedXBlockData( + usage_key=dup_xblock.key, + block_type=self.xblock.content_type, + source_usage_key=self.xblock.key, + ), + ) + self.assertEqual(skill.count(), 4) + self.assertEqual(xblock_skill.count(), 2) + new_xblock = xblock_skill.get(usage_key=dup_xblock.key) + self.assertEqual(XBlockSkillData.objects.filter(xblock=new_xblock).count(), 4) + + def test_empty_event_data_format_skips_processing(self): + """ + Verify that incorrect data passed via openedx_events skips processing. + """ + events = [ + (XBLOCK_PUBLISHED, "XBLOCK_PUBLISHED"), + (XBLOCK_DELETED, "XBLOCK_DELETED"), + (XBLOCK_DUPLICATED, "XBLOCK_DUPLICATED"), + ] + for event, name in events: + correct_init_data = event.init_data + # disable validation in publishing side + event.init_data = {} + with LogCapture(level=logging.INFO) as log_capture: + # send empty event + event.send_event() + # reset validation in events + event.init_data = correct_init_data + # Validate a descriptive and readable log message. + messages = [record.msg for record in log_capture.records] + self.assertEqual(len(log_capture.records), 3) + self.assertEqual( + [ + f'[TAXONOMY] {name} signal received', + f'[TAXONOMY] Received null or incorrect data from {name}.', + ], + messages[:-1] + ) diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 649e0f30..c0b4fa42 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -14,7 +14,7 @@ from taxonomy.choices import ProductTypes from taxonomy.constants import ENGLISH from taxonomy.exceptions import TaxonomyAPIError -from taxonomy.models import CourseSkills, JobSkills, Skill, Translation, XBlockSkills +from taxonomy.models import CourseSkills, JobSkills, Skill, Translation, XBlockSkillData, XBlockSkills from test_utils import factories from test_utils.constants import COURSE_KEY, PROGRAM_UUID, USAGE_KEY from test_utils.mocks import MockCourse, MockProgram, MockXBlock, mock_as_dict @@ -797,7 +797,7 @@ def test_refresh_xblock_skills_no_change_skipped( get_xblock_skills_mock ): """ - Validate that `refresh_product_skills` rate limits API calls to EMSI. + Validate that `refresh_product_skills` only calls API if content has changed. """ get_xblock_skills_mock.return_value = SKILLS_EMSI_CLIENT_RESPONSE get_translated_description_mock.return_value = None @@ -995,3 +995,44 @@ def test_get_whitelisted_serialized_skills_with_category_details(self): assert len(skill_details) == 3 # Skill 2 with missing category is not present in the results assert skill_details == expected_data + + def test_duplicate_xblock_skills(self): + """ + Validate that `duplicate_xblock_skills` works as expected. + """ + xblock = factories.XBlockSkillsFactory(usage_key=USAGE_KEY) + factories.XBlockSkillDataFactory.create_batch(4, xblock=xblock) + dup_usage_key = 'block-v1:edx+DemoX+Demo_course+type@video+block@dup-uuid' + utils.duplicate_xblock_skills(USAGE_KEY, dup_usage_key) + dup_xblock = XBlockSkills.objects.filter(usage_key=dup_usage_key).first() + assert dup_xblock is not None + assert XBlockSkillData.objects.filter(xblock=dup_xblock).count() == 4 + + def test_duplicate_xblock_skills_incorrect_source(self): + """ + Validate that `duplicate_xblock_skills` stops for incorrect source usage_key. + """ + xblock = factories.XBlockSkillsFactory(usage_key=USAGE_KEY) + factories.XBlockSkillDataFactory.create_batch(4, xblock=xblock) + dup_usage_key = 'block-v1:edx+DemoX+Demo_course+type@video+block@dup-uuid' + incorrect_source = xblock.usage_key[:-1] + with LogCapture(level=logging.INFO) as log_capture: + utils.duplicate_xblock_skills(incorrect_source, dup_usage_key) + messages = [record.msg for record in log_capture.records] + self.assertIn(f'[TAXONOMY] Source xblock: {incorrect_source} not found', messages[0]) + dup_xblock = XBlockSkills.objects.filter(usage_key=dup_usage_key).first() + self.assertIsNone(dup_xblock) + assert XBlockSkillData.objects.filter(xblock=dup_xblock).count() == 0 + + def test_duplicate_xblock_skills_existing_usage_key(self): + """ + Validate that `duplicate_xblock_skills` stops if usage_key already exists. + """ + xblock = factories.XBlockSkillsFactory(usage_key=USAGE_KEY) + existing_xblock = factories.XBlockSkillsFactory() + factories.XBlockSkillDataFactory.create_batch(4, xblock=xblock) + with LogCapture(level=logging.INFO) as log_capture: + utils.duplicate_xblock_skills(USAGE_KEY, existing_xblock.usage_key) + messages = [record.msg for record in log_capture.records] + self.assertIn(f'[TAXONOMY] XBlock with usage_key: {existing_xblock.usage_key} already exists!', messages[0]) + assert XBlockSkillData.objects.filter(xblock=existing_xblock).count() == 0