diff --git a/cms/djangoapps/contentstore/management/commands/migrate_cert_config.py b/cms/djangoapps/contentstore/management/commands/migrate_cert_config.py new file mode 100644 index 000000000000..f1b6b28d0c60 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/migrate_cert_config.py @@ -0,0 +1,255 @@ +""" +Script to transfer course certificate configuration to Credential IDA from modulestore (old mongo (draft) and slit). +The script is a one-time action. +The context for this decision can be read here +lms/djangoapps/certificates/docs/decisions/007-transfer-certificate-signatures-from-mongo.rst +""" + +import attr +from itertools import chain +from typing import Dict, Iterator, List, Union + +from django.core.management.base import BaseCommand, CommandError + +from common.djangoapps.course_modes.models import CourseMode +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.credentials.utils import send_course_certificate_configuration +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseBlock +from cms.djangoapps.contentstore.signals.handlers import ( + create_course_certificate_config_data, + get_certificate_signature_assets, +) +from cms.djangoapps.contentstore.views.certificates import CertificateManager + + +class FakedUser: + def __init__(self, id_: int): + self.id = id_ + + +class FakedRequest: + def __init__(self, user_id: int): + self.user = FakedUser(user_id) + + +class Command(BaseCommand): + """ + Management command to transfer course certificate configuration from modulestore to Credentials IDA. + + Examples: + + ./manage.py migrate_cert_config - transfer courses with provided keys + ./manage.py migrate_cert_config --all - transfer all available courses + ./manage.py migrate_cert_config --draft - transfer all mongo(old approach) modulestore available courses + ./manage.py migrate_cert_config --draft - transfer all split(new approach) modulestore available courses + ./manage.py migrate_cert_config --all --delete-after - transfer all available courses + and delete course certificate configuration, signature assets from modulestore after successfull transfer. + """ + + help = 'Allows manual transfer course certificate configuration from modulestore to Credentials IDA.' + + def add_arguments(self, parser): + parser.add_argument('course_ids', nargs='*', metavar='course_id') + parser.add_argument( + '--course_storage_type', + type=str.lower, + default=None, + choices=['all', 'draft', 'split'], + help='Course storage types whose certificate configurations are to be migrated.', + ) + parser.add_argument( + '--delete-after', + help='Boolean value to delete course certificate configuration, signature assets from modulestore.', + action='store_true', + ) + + def _parse_course_key(self, raw_value: str) -> CourseKey: + """ + Parses course key from string + """ + try: + result = CourseKey.from_string(raw_value) + except InvalidKeyError: + raise CommandError(f'Invalid course_key: {raw_value}.') + if not isinstance(result, CourseLocator): + raise CommandError(f'Argument {raw_value} is not a course key') + + return result + + def get_mongo_courses(self) -> Iterator[CourseKey]: + """ + Return objects for any mongo(old approach) modulestore backend course that has a certificate configuration. + """ + # N.B. This code breaks many abstraction barriers. That's ok, because + # it's a one-time cleanup command. + mongo_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) + old_mongo_courses = mongo_modulestore.collection.find( + {'_id.category': 'course', 'metadata.certificates': {'$exists': 1}}, + { + '_id': True, + }, + ) + for course in old_mongo_courses: + yield mongo_modulestore.make_course_key( + course['_id']['org'], + course['_id']['course'], + course['_id']['name'], + ) + + def get_split_courses(self) -> Iterator[CourseKey]: + """ + Return objects for any split modulestore backend course that has a certificate configuration. + """ + # N.B. This code breaks many abstraction barriers. That's ok, because + # it's a one-time cleanup command. + split_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split) + active_version_collection = split_modulestore.db_connection.course_index + structure_collection = split_modulestore.db_connection.structures + branches = list( + active_version_collection.aggregate( + [ + { + '$group': { + '_id': 1, + 'draft': {'$push': '$versions.draft-branch'}, + 'published': {'$push': '$versions.published-branch'}, + } + }, + {'$project': {'_id': 1, 'branches': {'$setUnion': ['$draft', '$published']}}}, + ] + ) + )[0]['branches'] + + structures = structure_collection.find( + { + '_id': {'$in': branches}, + 'blocks': { + '$elemMatch': { + '$and': [ + {'block_type': 'course'}, + {'fields.certificates': {'$exists': True}}, + ] + } + }, + }, + { + '_id': True, + }, + ) + + structure_ids = [struct['_id'] for struct in structures] + split_mongo_courses = list( + active_version_collection.find( + { + '$or': [ + {'versions.draft-branch': {'$in': structure_ids}}, + {'versions.published': {'$in': structure_ids}}, + ] + }, + { + 'org': True, + 'course': True, + 'run': True, + 'versions': True, + }, + ) + ) + for course in split_mongo_courses: + yield split_modulestore.make_course_key( + course['org'], + course['course'], + course['run'], + ) + + def send_to_credentials( + self, + course_key: CourseKey, + mode: CourseMode, + certificate_data: Dict[str, Union[str, List[str]]] + ): + """ + Sends certificate configuration data to Credential IDA via http request. + """ + certificate_config = create_course_certificate_config_data(str(course_key), mode.slug, certificate_data) + files_to_upload = dict(get_certificate_signature_assets(certificate_config)) + certificate_config_data = attr.asdict(certificate_config) + send_course_certificate_configuration(str(course_key), certificate_config_data, files_to_upload) + + def delete_from_store(self, course: CourseBlock, certificates: List[Dict[str, str]]): + """ + Deletes certificate configuration from modulestore storage. + """ + store = modulestore() + request = FakedRequest(ModuleStoreEnum.UserID.mgmt_command) + for cert in certificates: + CertificateManager.remove_certificate( + request=request, store=store, course=course, certificate_id=cert['id'] + ) + + def validate_input(self, options: Dict[str, str]): + """ + Makes manage-command input validation. Raises CommandError if has conflicts. + """ + if (not len(options['course_ids']) and not options.get('course_storage_type')) or ( + len(options['course_ids']) and options.get('course_storage_type') + ): + raise CommandError( + 'Certificate configurations migration requires one or more s ' + 'OR the --course_storage_type choice.' + ) + + def get_course_keys_by_option(self, options: Dict[str, str]) -> Iterator[CourseKey]: + storage_type = options['course_storage_type'] + course_ids = options['course_ids'] + if storage_type: + if storage_type == 'all': + return chain(self.get_mongo_courses(), self.get_split_courses()) + elif storage_type == 'draft': + return self.get_mongo_courses() + elif storage_type == 'split': + return self.get_split_courses() + if course_ids: + return map(self._parse_course_key, course_ids) + + def migrate(self, course_keys: List[CourseKey], options: Dict[str, str]): + """ + Main entry point for executiong all migration-related actions. + + Sending to Credential and/or removal from storage. + If there are problems with some course, i.e. or it does not exist, or the course is not set to mode, + which allows to have a certificate, or no certificate configuration, + then an user of this command will be notified by a message. + """ + for course_key in course_keys: + if course := modulestore().get_course(course_key): + if course_modes := CourseMode.objects.filter( + course_id=course_key, mode_slug__in=CourseMode.CERTIFICATE_RELEVANT_MODES + ): + if certificates := CertificateManager.get_certificates(course): + for certificate_data in certificates: + for mode in course_modes: + try: + self.send_to_credentials(course_key, mode, certificate_data) + except Exception as exc: + self.stderr.write(str(exc)) + else: + if options.get('delete_after'): + self.delete_from_store(course, certificates) + else: + self.stderr.write(f'The course {course_key} does not have any configured certificates.') + else: + self.stderr.write(f'The course {course_key} does not have certificate relevant modes.') + else: + self.stderr.write(f'The course {course_key} does not exist.') + + def handle(self, *args, **options): + """ + Executes the command. + """ + self.validate_input(options) + course_keys_to_migrate = self.get_course_keys_by_option(options) + self.migrate(course_keys_to_migrate, options) diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index 1cb58733273c..27db336e765f 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -1,10 +1,11 @@ """ receivers of course_published and library_updated events in order to trigger indexing task """ +import attr import logging from datetime import datetime -from functools import wraps -from typing import Optional +from functools import partial, wraps +from typing import BinaryIO, Dict, Iterator, List, Optional, Tuple, Union from django.conf import settings from django.core.cache import cache @@ -12,9 +13,19 @@ from django.dispatch import receiver from edx_toggles.toggles import SettingToggle from opaque_keys.edx.keys import CourseKey -from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED from openedx_events.event_bus import get_producer +from openedx_events.content_authoring.data import ( + CertificateConfigData, + CertificateSignatoryData, + CourseCatalogData, + CourseScheduleData, +) +from openedx_events.content_authoring.signals import ( + COURSE_CATALOG_INFO_CHANGED, + COURSE_CERTIFICATE_CONFIG_DELETED, + COURSE_CERTIFICATE_CONFIG_CHANGED, +) +from openedx_events.tooling import OpenEdxPublicSignal from pytz import UTC from cms.djangoapps.contentstore.courseware_index import ( @@ -22,11 +33,19 @@ CoursewareSearchIndexer, LibrarySearchIndexer, ) +from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type from common.djangoapps.util.module_utils import yield_dynamic_descriptor_descendants from lms.djangoapps.grades.api import task_compute_all_grades_for_course from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task +from openedx.core.djangoapps.credentials.utils import ( + get_credentials_api_base_url, + get_credentials_api_client, + delete_course_certificate_configuration, + send_course_certificate_configuration, +) +from openedx.core.djangoapps.olx_rest_api.adapters import get_asset_content_from_path from openedx.core.lib.gating import api as gating_api from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import SignalHandler, modulestore @@ -66,6 +85,16 @@ def wrapper(*args, **kwargs): SEND_CATALOG_INFO_SIGNAL = SettingToggle('SEND_CATALOG_INFO_SIGNAL', default=False, module_name=__name__) +def get_certificate_signature_assets(certificate_config: CertificateConfigData) -> Iterator[Tuple[str, BinaryIO]]: + """ + Get certificates' signatures from asset content storage. + """ + course_key = CourseKey.from_string(certificate_config.course_id) + for signatory in certificate_config.signatories: + if content := get_asset_content_from_path(course_key, signatory.image): + yield signatory.image, content.data + + def create_catalog_data_for_signal(course_key: CourseKey) -> Optional[CourseCatalogData]: """ Creates data for catalog-info-changed signal when course is published. @@ -98,6 +127,31 @@ def create_catalog_data_for_signal(course_key: CourseKey) -> Optional[CourseCata ) +def create_course_certificate_config_data( + course_id: str, + certificate_type: str, + certificate_data: Dict[str, Union[str, List[str]]] +) -> CourseCatalogData: + """ + Creates data for course-certificate-config signal + when either course is imported or course certificate config is changed. + """ + signatories = [CertificateSignatoryData( + name=item['name'], + title=item['title'], + organization=item['organization'], + image=item['signature_image_path'] + ) for item in certificate_data['signatories']] + + return CertificateConfigData( + course_id=course_id, + title=certificate_data['name'], + is_active=certificate_data['is_active'], + signatories=signatories, + certificate_type=certificate_type + ) + + def emit_catalog_info_changed_signal(course_key: CourseKey): """ Given the key of a recently published course, send course data to catalog-info-changed signal. @@ -108,6 +162,32 @@ def emit_catalog_info_changed_signal(course_key: CourseKey): COURSE_CATALOG_INFO_CHANGED.send_event(catalog_info=catalog_info) +def _emit_course_certificate_config_signal( + course_id: str, + certificate_data: Dict[str, Union[str, List[str]]], + course_certificate_config_signal: OpenEdxPublicSignal +): + """ + Given the key of a recently changed course certificate config, + send course certificate config data to course-certificate-config-changed signal. + """ + course_modes = CourseMode.objects.filter(course_id=course_id, mode_slug__in=CourseMode.CERTIFICATE_RELEVANT_MODES) + for mode in course_modes: + certificate_config = create_course_certificate_config_data(course_id, mode.slug, certificate_data) + course_certificate_config_signal.send_event(certificate_config=certificate_config) + + +emit_course_certificate_config_changed_signal = partial( + _emit_course_certificate_config_signal, + course_certificate_config_signal=COURSE_CERTIFICATE_CONFIG_CHANGED +) + +emit_course_certificate_config_deleted_signal = partial( + _emit_course_certificate_config_signal, + course_certificate_config_signal=COURSE_CERTIFICATE_CONFIG_DELETED +) + + @receiver(SignalHandler.course_published) def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ @@ -163,6 +243,28 @@ def listen_for_course_catalog_info_changed(sender, signal, **kwargs): ) +@receiver(COURSE_CERTIFICATE_CONFIG_CHANGED) +def listen_for_course_certificate_config_changed(sender, signal, **kwargs): + """ + Send course certificate config data onto the Credentials service to create or change one. + """ + certificate_config = kwargs['certificate_config'] + files_to_upload = dict(get_certificate_signature_assets(certificate_config)) + certificate_config_data = attr.asdict(certificate_config) + course_id = certificate_config_data.get('course_id') + send_course_certificate_configuration(course_id, certificate_config_data, files_to_upload) + + +@receiver(COURSE_CERTIFICATE_CONFIG_DELETED) +def listen_for_course_certificate_config_deleted(sender, signal, **kwargs): + """ + Send course certificate config data onto the Credentials service to delete one. + """ + certificate_config = attr.asdict(kwargs['certificate_config']) + course_id = certificate_config.get('course_id') + delete_course_certificate_configuration(course_id, certificate_config) + + @receiver(SignalHandler.course_deleted) def listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument """ diff --git a/cms/djangoapps/contentstore/signals/tests/test_handlers.py b/cms/djangoapps/contentstore/signals/tests/test_handlers.py index 8cb4c60018c5..d3c13b66b421 100644 --- a/cms/djangoapps/contentstore/signals/tests/test_handlers.py +++ b/cms/djangoapps/contentstore/signals/tests/test_handlers.py @@ -2,14 +2,26 @@ Tests for signal handlers in the contentstore. """ +import attr from datetime import datetime from unittest.mock import patch - +from django.test import TestCase from django.test.utils import override_settings from opaque_keys.edx.locator import CourseLocator, LibraryLocator -from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData - +from openedx_events.content_authoring.data import ( + CertificateSignatoryData, + CertificateConfigData, + CourseCatalogData, + CourseScheduleData, +) +from openedx_events.content_authoring.signals import ( + COURSE_CERTIFICATE_CONFIG_DELETED, + COURSE_CERTIFICATE_CONFIG_CHANGED, +) import cms.djangoapps.contentstore.signals.handlers as sh +from cms.djangoapps.contentstore.tests.utils import CERTIFICATE_JSON_WITH_SIGNATORIES +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.django import SignalHandler from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import SampleCourseFactory @@ -79,3 +91,123 @@ def test_disabled(self, mock_signal): """When toggle is disabled, don't send.""" sh.emit_catalog_info_changed_signal(self.course_key) mock_signal.send_event.assert_not_called() + + +class TestCourseCertificateConfigSignal(ModuleStoreTestCase): + """ + Test functionality of triggering course certificate config signals (and events). + """ + + def setUp(self): + super().setUp() + signatory = CERTIFICATE_JSON_WITH_SIGNATORIES['signatories'][0] + self.course = SampleCourseFactory.create( + org='TestU', + number='sig101', + display_name='Signals 101', + run='Summer2022', + ) + self.course_key = self.course.id + self.expected_data = CertificateConfigData( + course_id=str(self.course_key), + title=CERTIFICATE_JSON_WITH_SIGNATORIES['name'], + is_active=CERTIFICATE_JSON_WITH_SIGNATORIES['is_active'], + certificate_type='verified', + signatories=[ + CertificateSignatoryData( + name=signatory['name'], + title=signatory['title'], + organization=signatory['organization'], + image=signatory['signature_image_path'] + ) + ], + ) + + @patch('cms.djangoapps.contentstore.signals.handlers.COURSE_CERTIFICATE_CONFIG_CHANGED.send_event', autospec=True) + def test_course_certificate_config_changed(self, mock_signal): + """ + Test that the change event is fired when course is in state where a certificate is available. + """ + CourseModeFactory.create(mode_slug='verified', course_id=self.course.id) + sh.emit_course_certificate_config_changed_signal(str(self.course_key), CERTIFICATE_JSON_WITH_SIGNATORIES) + mock_signal.assert_called_once_with(certificate_config=self.expected_data) + + @patch('cms.djangoapps.contentstore.signals.handlers.COURSE_CERTIFICATE_CONFIG_CHANGED.send_event', autospec=True) + def test_course_certificate_config_changed_does_not_emit(self, mock_signal): + """ + Test that the change event is not fired when course is in state where a certificate is not available. + """ + CourseModeFactory.create(mode_slug='audit', course_id=self.course.id) + sh.emit_course_certificate_config_changed_signal(str(self.course_key), CERTIFICATE_JSON_WITH_SIGNATORIES) + mock_signal.assert_not_called() + + @patch('cms.djangoapps.contentstore.signals.handlers.COURSE_CERTIFICATE_CONFIG_DELETED.send_event', autospec=True) + def test_course_certificate_config_deleted(self, mock_signal): + """ + Test that the delete event is fired when course is in state where a certificate is available. + """ + CourseModeFactory.create(mode_slug='verified', course_id=self.course.id) + sh.emit_course_certificate_config_deleted_signal(str(self.course_key), CERTIFICATE_JSON_WITH_SIGNATORIES) + mock_signal.assert_called_once_with(certificate_config=self.expected_data) + + @patch('cms.djangoapps.contentstore.signals.handlers.COURSE_CERTIFICATE_CONFIG_DELETED.send_event', autospec=True) + def test_course_certificate_config_deleted_does_not_emit(self, mock_signal): + """ + Test that the delete event is not fired when course is in state where a certificate is not available. + """ + CourseModeFactory.create(mode_slug='audit', course_id=self.course.id) + sh.emit_course_certificate_config_deleted_signal(str(self.course_key), CERTIFICATE_JSON_WITH_SIGNATORIES) + mock_signal.assert_not_called() + + +class SignalCourseCertificateConfigurationListenerTestCase(TestCase): + """ + Test case for end listeners of signals: + - COURSE_CERTIFICATE_CONFIG_DELETED, + - COURSE_CERTIFICATE_CONFIG_CHANGED. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(username='cred-user') + self.course_key = CourseLocator(org='TestU', course='sig101', run='Summer2022', branch=None, version_guid=None) + self.expected_data = CertificateConfigData( + course_id=str(self.course_key), + title=CERTIFICATE_JSON_WITH_SIGNATORIES['name'], + is_active=CERTIFICATE_JSON_WITH_SIGNATORIES['is_active'], + certificate_type='verified', + signatories=[ + CertificateSignatoryData( + name=CERTIFICATE_JSON_WITH_SIGNATORIES['signatories'][0]['name'], + title=CERTIFICATE_JSON_WITH_SIGNATORIES['signatories'][0]['title'], + organization=CERTIFICATE_JSON_WITH_SIGNATORIES['signatories'][0]['organization'], + image=CERTIFICATE_JSON_WITH_SIGNATORIES['signatories'][0]['signature_image_path'] + ) + ], + ) + + @patch('cms.djangoapps.contentstore.signals.handlers.delete_course_certificate_configuration') + def test_course_certificate_config_deleted_listener(self, mock_delete_course_certificate_configuration): + """ + Ensure the correct API call when the signal COURSE_CERTIFICATE_CONFIG_DELETED happened. + """ + COURSE_CERTIFICATE_CONFIG_DELETED.send_event(certificate_config=self.expected_data) + + mock_delete_course_certificate_configuration.assert_called_once() + call = mock_delete_course_certificate_configuration.mock_calls[0] + __, (given_course_key, given_config), __ = call + assert given_course_key == str(self.course_key) + assert given_config == attr.asdict(self.expected_data) + + @patch('cms.djangoapps.contentstore.signals.handlers.send_course_certificate_configuration') + def test_course_certificate_config_changed_listener(self, mock_send_course_certificate_configuration): + """ + Ensure the correct API call when the signal COURSE_CERTIFICATE_CONFIG_CHANGED happened. + """ + COURSE_CERTIFICATE_CONFIG_CHANGED.send_event(certificate_config=self.expected_data) + + mock_send_course_certificate_configuration.assert_called_once() + call = mock_send_course_certificate_configuration.mock_calls[0] + __, (given_course_key, given_config, __), __ = call + assert given_course_key == str(self.course_key) + assert given_config == attr.asdict(self.expected_data) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index aaee20fbb159..f22fb5105397 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -44,6 +44,7 @@ LibrarySearchIndexer, SearchIndexingError ) +from cms.djangoapps.contentstore.signals.handlers import emit_course_certificate_config_changed_signal from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.utils import initialize_permissions, reverse_usage_url, translation_language from cms.djangoapps.models.settings.course_metadata import CourseMetadata @@ -671,6 +672,10 @@ def read_chunk(): add_entrance_exam_milestone(course.id, entrance_exam_chapter) LOGGER.info(f'Course import {course.id}: Entrance exam imported') + if certificates := course.certificates.get('certificates'): + for certificate in certificates: + emit_course_certificate_config_changed_signal(str(courselike_key), certificate) + @shared_task @set_code_owner_attribute diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index fabfb2c75fd7..b452caec6535 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -19,12 +19,80 @@ from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.utils import ProceduralCourseTestMixin from xmodule.modulestore.xml_importer import import_course_from_xml +from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from cms.djangoapps.contentstore.utils import reverse_url +from cms.djangoapps.contentstore.views.certificates import CERTIFICATE_SCHEMA_VERSION from common.djangoapps.student.models import Registration TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +CERTIFICATE_JSON_WITH_SIGNATORIES = { + 'name': 'Test certificate', + 'description': 'Test description', + 'version': CERTIFICATE_SCHEMA_VERSION, + 'course_title': 'Course Title Override', + 'is_active': True, + 'signatories': [ + { + 'name': 'Bob Smith', + 'title': 'The DEAN.', + 'organization': 'Test org', + 'signature_image_path': '/c4x/test/CSS101/asset/Signature.png' + } + ] +} +C4X_SIGNATORY_PATH = '/c4x/test/CSS101/asset/Signature{}.png' + + +# pylint: disable=no-member +class HelperMethods: + """ + Mixin that provides useful methods for certificate configuration tests. + """ + def _create_fake_images(self, asset_keys): + """ + Creates fake image files for a list of asset_keys. + """ + for asset_key_string in asset_keys: + asset_key = AssetKey.from_string(asset_key_string) + content = StaticContent( + asset_key, 'Fake asset', 'image/png', 'data', + ) + contentstore().save(content) + + def _add_course_certificates(self, count=1, signatory_count=0, is_active=False, + asset_path_format=C4X_SIGNATORY_PATH): + """ + Create certificate for the course. + """ + signatories = [ + { + 'name': 'Name ' + str(i), + 'title': 'Title ' + str(i), + 'signature_image_path': asset_path_format.format(i), + 'organization': 'Organization ' + str(i), + 'id': i + } for i in range(signatory_count) + + ] + + # create images for signatory signatures except the last signatory + self._create_fake_images(signatory['signature_image_path'] for signatory in signatories[:-1]) + + certificates = [ + { + 'id': i, + 'name': 'Name ' + str(i), + 'description': 'Description ' + str(i), + 'signatories': signatories, + 'version': CERTIFICATE_SCHEMA_VERSION, + 'is_active': is_active + } for i in range(count) + ] + self.course.certificates = {'certificates': certificates} + self.save_course() + def parse_json(response): """Parse response, which is assumed to be json""" diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 6c704197739d..f7f7be8fc107 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -43,6 +43,10 @@ from common.djangoapps.student.roles import GlobalStaff from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from common.djangoapps.util.json_request import JsonResponse +from cms.djangoapps.contentstore.signals.handlers import ( + emit_course_certificate_config_changed_signal, + emit_course_certificate_config_deleted_signal, +) from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -451,6 +455,7 @@ def certificates_list_handler(request, course_key_string): kwargs={'certificate_id': new_certificate.id} ) store.update_item(course, request.user.id) + emit_course_certificate_config_changed_signal(str(course.id), new_certificate.certificate_data) CertificateManager.track_event('created', { 'course_id': str(course.id), 'configuration_id': new_certificate.id @@ -508,6 +513,7 @@ def certificates_detail_handler(request, course_key_string, certificate_id): certificates_list.append(serialized_certificate) store.update_item(course, request.user.id) + emit_course_certificate_config_changed_signal(str(course.id), new_certificate.certificate_data) CertificateManager.track_event(cert_event_type, { 'course_id': str(course.id), 'configuration_id': serialized_certificate["id"] @@ -530,6 +536,7 @@ def certificates_detail_handler(request, course_key_string, certificate_id): course=course, certificate_id=certificate_id ) + emit_course_certificate_config_deleted_signal(str(course.id), match_cert) CertificateManager.track_event('deleted', { 'course_id': str(course.id), 'configuration_id': certificate_id diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index 76dfb3be95d7..0b53d95fd6c3 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -9,17 +9,23 @@ import ddt from django.conf import settings +from django.test.client import RequestFactory from django.test.utils import override_settings from opaque_keys.edx.keys import AssetKey -from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.tests.utils import ( + CourseTestCase, + HelperMethods, + CERTIFICATE_JSON_WITH_SIGNATORIES, + C4X_SIGNATORY_PATH, +) from cms.djangoapps.contentstore.utils import get_lms_link_for_certificate_web_view, reverse_course_url +from cms.djangoapps.contentstore.views.certificates import certificates_list_handler, certificates_detail_handler from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -35,73 +41,9 @@ 'version': CERTIFICATE_SCHEMA_VERSION, } -CERTIFICATE_JSON_WITH_SIGNATORIES = { - 'name': 'Test certificate', - 'description': 'Test description', - 'version': CERTIFICATE_SCHEMA_VERSION, - 'course_title': 'Course Title Override', - 'is_active': True, - 'signatories': [ - { - "name": "Bob Smith", - "title": "The DEAN.", - "signature_image_path": "/c4x/test/CSS101/asset/Signature.png" - } - ] -} - -C4X_SIGNATORY_PATH = '/c4x/test/CSS101/asset/Signature{}.png' SIGNATORY_PATH = 'asset-v1:test+CSS101+SP2017+type@asset+block@Signature{}.png' -# pylint: disable=no-member -class HelperMethods: - """ - Mixin that provides useful methods for certificate configuration tests. - """ - def _create_fake_images(self, asset_keys): - """ - Creates fake image files for a list of asset_keys. - """ - for asset_key_string in asset_keys: - asset_key = AssetKey.from_string(asset_key_string) - content = StaticContent( - asset_key, "Fake asset", "image/png", "data", - ) - contentstore().save(content) - - def _add_course_certificates(self, count=1, signatory_count=0, is_active=False, - asset_path_format=C4X_SIGNATORY_PATH): - """ - Create certificate for the course. - """ - signatories = [ - { - 'name': 'Name ' + str(i), - 'title': 'Title ' + str(i), - 'signature_image_path': asset_path_format.format(i), - 'id': i - } for i in range(signatory_count) - - ] - - # create images for signatory signatures except the last signatory - self._create_fake_images(signatory['signature_image_path'] for signatory in signatories[:-1]) - - certificates = [ - { - 'id': i, - 'name': 'Name ' + str(i), - 'description': 'Description ' + str(i), - 'signatories': signatories, - 'version': CERTIFICATE_SCHEMA_VERSION, - 'is_active': is_active - } for i in range(count) - ] - self.course.certificates = {'certificates': certificates} - self.save_course() - - # pylint: disable=no-member class CertificatesBaseTestCase: """ @@ -209,6 +151,14 @@ def setUp(self): # lint-amnesty, pylint: disable=arguments-differ Set up CertificatesListHandlerTestCase. """ super().setUp('cms.djangoapps.contentstore.views.certificates.tracker') + self.user = UserFactory(is_staff=True) + self.request = RequestFactory().post( + self._url(), + data=json.dumps(CERTIFICATE_JSON_WITH_SIGNATORIES), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + self.request.user = self.user self.reset_urls() def _url(self): @@ -301,6 +251,17 @@ def test_certificate_info_in_response(self): self.assertEqual(data[0]['description'], 'Test description') self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION) + def test_emit_course_certificate_config_signal_when_certificate_creates(self): + """ + Test that course certificate config signal has been emmited. + """ + with mock.patch( + 'cms.djangoapps.contentstore.views.certificates.emit_course_certificate_config_changed_signal', + ) as mocked_emit_course_certificate_config_changed_signal: + CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') + certificates_list_handler(self.request, str(self.course.id)) + self.assertTrue(mocked_emit_course_certificate_config_changed_signal.called) + @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) def test_certificate_info_not_in_response(self): """ @@ -434,6 +395,21 @@ def setUp(self): # pylint: disable=arguments-differ Set up CertificatesDetailHandlerTestCase. """ super().setUp('cms.djangoapps.contentstore.views.certificates.tracker') + self.user = UserFactory(is_staff=True) + self.edit_request = RequestFactory().put( + self._url(cid=0), + data=json.dumps(CERTIFICATE_JSON_WITH_SIGNATORIES), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + self.delete_request = RequestFactory().delete( + self._url(cid=0), + data=json.dumps(CERTIFICATE_JSON_WITH_SIGNATORIES), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + self.edit_request.user = self.user + self.delete_request.user = self.user self.reset_urls() def _url(self, cid=-1): @@ -554,6 +530,17 @@ def test_can_edit_certificate_without_is_active(self): content = json.loads(response.content.decode('utf-8')) self.assertEqual(content, expected) + def test_emit_course_certificate_config_signal_when_certificate_edits(self): + """ + Check that the course certificate configuration signal was sent when editing. + """ + with mock.patch( + 'cms.djangoapps.contentstore.views.certificates.emit_course_certificate_config_changed_signal', + ) as mocked_emit_course_certificate_config_changed_signal: + self._add_course_certificates() + certificates_detail_handler(self.edit_request, str(self.course.id), 0) + self.assertTrue(mocked_emit_course_certificate_config_changed_signal.called) + @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_can_delete_certificate_with_signatories(self, signatory_path): """ @@ -760,6 +747,17 @@ def test_delete_signatory_non_existing_certificate(self): ) self.assertEqual(response.status_code, 404) + def test_emit_course_certificate_config_signal_when_certificate_deletes(self): + """ + Check that the course certificate configuration signal was sent when deleting. + """ + with mock.patch( + 'cms.djangoapps.contentstore.views.certificates.emit_course_certificate_config_deleted_signal', + ) as mocked_emit_course_certificate_config_deleted_signal: + self._add_course_certificates() + certificates_detail_handler(self.delete_request, str(self.course.id), 0) + self.assertTrue(mocked_emit_course_certificate_config_deleted_signal.called) + @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_certificate_activation_success(self, signatory_path): """ diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 49a26e2ac85c..7a004a7ebce5 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -1,7 +1,11 @@ """Tests covering Credentials utilities.""" import uuid +import json +import httpretty from unittest import mock +from opaque_keys.edx.locator import CourseLocator +from django.test import TestCase from django.test import override_settings from edx_toggles.toggles.testutils import override_waffle_switch @@ -9,7 +13,12 @@ from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.credentials.tests import factories from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin -from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_records_url +from openedx.core.djangoapps.credentials.utils import ( + get_credentials, + get_credentials_records_url, + delete_course_certificate_configuration, + send_course_certificate_configuration, +) from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from common.djangoapps.student.tests.factories import UserFactory @@ -159,3 +168,51 @@ def test_get_credentials_records_url_only_mfe_configured(self): result = get_credentials_records_url("abcdefgh-ijkl-mnop-qrst-uvwxyz123456") assert result == "http://blah/abcdefghijklmnopqrstuvwxyz123456/" + + +@skip_unless_lms +class TestCourseCertificateConfiguration(TestCase): + """ + Tests for course certificate configurations functions. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(username='cred-user') + self.course_key = CourseLocator(org='TestU', course='sig101', run='Summer2022', branch=None, version_guid=None) + self.expected_body_data = { + 'foo': 'bar', + 'baz': 'foo' + } + + @override_settings(CREDENTIALS_SERVICE_USERNAME='cred-user') + @httpretty.activate + @mock.patch('openedx.core.djangoapps.credentials.utils.get_credentials_api_base_url') + def test_course_certificate_config_deleted(self, mock_get_api_base_url): + """ + Ensure the correct API call when the invoke delete_course_certificate_configuration happened. + """ + mock_get_api_base_url.return_value = 'http://test-server/' + httpretty.register_uri( + httpretty.DELETE, + 'http://test-server/course_certificates/', + ) + delete_course_certificate_configuration(self.course_key, self.expected_body_data) + last_request_body = httpretty.last_request().body.decode('utf-8') + assert json.loads(last_request_body) == self.expected_body_data + + @override_settings(CREDENTIALS_SERVICE_USERNAME='cred-user') + @httpretty.activate + @mock.patch('openedx.core.djangoapps.credentials.utils.get_credentials_api_base_url') + def test_course_certificate_config_sent(self, mock_get_api_base_url): + """ + Ensure the correct API call when the invoke send_course_certificate_configuration happened. + """ + mock_get_api_base_url.return_value = 'http://test-server/' + httpretty.register_uri( + httpretty.POST, + 'http://test-server/course_certificates/', + ) + send_course_certificate_configuration(self.course_key, self.expected_body_data, signature_assets={}) + last_request_body = httpretty.last_request().body.decode('utf-8') + assert json.loads(last_request_body) == self.expected_body_data diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 752d41bc4b11..5d372a28c95b 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -1,15 +1,21 @@ """Helper functions for working with Credentials.""" +import logging import requests -from edx_rest_api_client.auth import SuppliedJwtAuth - +from urllib.parse import urljoin # pylint: disable=import-error from django.conf import settings +from django.contrib.auth.models import User +from edx_rest_api_client.auth import SuppliedJwtAuth +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.credentials.config import USE_LEARNER_RECORD_MFE from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.lib.edx_api_utils import get_api_data +log = logging.getLogger(__name__) + + def get_credentials_records_url(program_uuid=None): """ Returns a URL for a given records page (or general records list if given no UUID). @@ -114,3 +120,44 @@ def get_credentials(user, program_uuid=None, credential_type=None): querystring=querystring, cache_key=cache_key ) + + +def send_course_certificate_configuration(course_id: str, config_data: dict, signature_assets): + try: + credentials_client = get_credentials_api_client( + User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME), + ) + credentials_api_base_url = get_credentials_api_base_url() + api_url = urljoin(f'{credentials_api_base_url}/', 'course_certificates/') + response = credentials_client.post( + api_url, + files=signature_assets, + json=config_data + ) + response.raise_for_status() + log.info(f'Course certificate config sent for course {course_id} to Credentials.') + except Exception: # lint-amnesty, pylint: disable=W0703 + log.exception(f'Failed to send course certificate config for course {course_id} to Credentials.') + raise + else: + return response + + +def delete_course_certificate_configuration(course_id: str, config_data: dict): + try: + credentials_client = get_credentials_api_client( + User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME), + ) + credentials_api_base_url = get_credentials_api_base_url() + api_url = urljoin(f'{credentials_api_base_url}/', 'course_certificates/') + response = credentials_client.delete( + api_url, + json=config_data + ) + response.raise_for_status() + log.info(f'Course certificate config is deleted for course {course_id} from Credentials.') + except Exception: # lint-amnesty, pylint: disable=W0703 + log.exception(f'Failed to delete certificate config for course {course_id} from Credentials.') + raise + else: + return response diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 381c7b4254a7..e36d7f6325af 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -758,7 +758,8 @@ openedx-calc==3.0.1 # via -r requirements/edx/base.in openedx-django-wiki==1.1.4 # via -r requirements/edx/base.in -openedx-events==3.2.0 +# openedx-events==3.2.0 +-e git+https://github.com/raccoongang/openedx-events.git@v3.2.0#egg=openedx_events==3.2.0 # via # -r requirements/edx/base.in # edx-event-bus-kafka diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index fa4ebfa0ea85..e7839611c658 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -996,7 +996,7 @@ openedx-calc==3.0.1 # via -r requirements/edx/testing.txt openedx-django-wiki==1.1.4 # via -r requirements/edx/testing.txt -openedx-events==3.2.0 +-e git+https://github.com/raccoongang/openedx-events.git@v3.2.0#egg=openedx_events==3.2.0 # via # -r requirements/edx/testing.txt # edx-event-bus-kafka