From 39bf59e6d358bcfceedf02cb674f0589a09691e9 Mon Sep 17 00:00:00 2001 From: McKenzie Welter Date: Wed, 4 Oct 2017 12:07:44 -0400 Subject: [PATCH] Flag for Hours of Effort in course certificates Retrieve different data fields from Discovery --- lms/djangoapps/certificates/api.py | 2 +- ...oncoursesetting_include_hours_of_effort.py | 19 ++++ lms/djangoapps/certificates/models.py | 61 +++++------- .../certificates/tests/test_webview_views.py | 99 +++++++++++++++++-- lms/djangoapps/certificates/views/webview.py | 40 +++++--- .../djangoapps/catalog/tests/factories.py | 3 +- .../djangoapps/catalog/tests/test_utils.py | 5 +- 7 files changed, 168 insertions(+), 61 deletions(-) create mode 100644 lms/djangoapps/certificates/migrations/0012_certificategenerationcoursesetting_include_hours_of_effort.py diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 40c8481db9..4bd7516f0c 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -503,7 +503,6 @@ def get_certificate_template(course_key, mode, language): mode=mode ) template = get_language_specific_template_or_default(language, mode_templates) - #return template[0].template if template else None return template.template if template else None @@ -532,6 +531,7 @@ def get_all_languages_or_default_template(templates): for template in templates: if template.language == '': return template + return templates[0] if templates else None diff --git a/lms/djangoapps/certificates/migrations/0012_certificategenerationcoursesetting_include_hours_of_effort.py b/lms/djangoapps/certificates/migrations/0012_certificategenerationcoursesetting_include_hours_of_effort.py new file mode 100644 index 0000000000..c9cddda313 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0012_certificategenerationcoursesetting_include_hours_of_effort.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0011_certificatetemplate_alter_unique'), + ] + + operations = [ + migrations.AddField( + model_name='certificategenerationcoursesetting', + name='include_hours_of_effort', + field=models.NullBooleanField(default=None, help_text="Display estimated time to complete the course, which is equal to the maximum hours of effort per week times the length of the course in weeks. This attribute will only be displayed in a certificate when the attributes 'Weeks to complete' and 'Max effort' have been provided for the course run and its certificate template includes Hours of Effort."), + ), + ] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 27c4313132..43270d145b 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -870,11 +870,37 @@ class CertificateGenerationCourseSetting(TimeStampedModel): u"certificate template." ) ) + include_hours_of_effort = models.NullBooleanField( + default=None, + help_text=( + u"Display estimated time to complete the course, which is equal to the maximum hours of effort per week " + u"times the length of the course in weeks. This attribute will only be displayed in a certificate when the " + u"attributes 'Weeks to complete' and 'Max effort' have been provided for the course run and its certificate " + u"template includes Hours of Effort." + ) + ) class Meta(object): get_latest_by = 'created' app_label = "certificates" + @classmethod + def get(cls, course_key): + """ Retrieve certificate generation settings for a course. + + Arguments: + course_key (CourseKey): The identifier for the course. + + Returns: + CertificateGenerationCourseSetting + """ + try: + latest = cls.objects.filter(course_key=course_key).latest() + except cls.DoesNotExist: + return None + else: + return latest + @classmethod def is_self_generation_enabled_for_course(cls, course_key): """Check whether self-generated certificates are enabled for a course. @@ -910,41 +936,6 @@ class CertificateGenerationCourseSetting(TimeStampedModel): defaults=default ) - @classmethod - def is_language_specific_templates_enabled_for_course(cls, course_key): - """Check whether language-specific certificates are enabled for a course. - - Arguments: - course_key (CourseKey): The identifier for the course. - - Returns: - boolean - - """ - try: - latest = cls.objects.filter(course_key=course_key).latest() - except cls.DoesNotExist: - return False - else: - return latest.language_specific_templates_enabled - - @classmethod - def set_language_specific_templates_enabled_for_course(cls, course_key, is_enabled): - """Enable or disable language-specific certificates for a course. - - Arguments: - course_key (CourseKey): The identifier for the course. - is_enabled (boolean): Whether to enable or disable language-specific certificates. - - """ - default = { - 'language_specific_templates_enabled': is_enabled, - } - CertificateGenerationCourseSetting.objects.update_or_create( - course_key=course_key, - defaults=default - ) - class CertificateGenerationConfiguration(ConfigurationModel): """Configure certificate generation. diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index e1efbc4125..899af116ac 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -204,6 +204,37 @@ class CommonCertificatesTestCase(ModuleStoreTestCase): ) template.save() + def _create_custom_template_with_hours_of_effort(self, org_id=None, mode=None, course_key=None, language=None): + """ + Creates a custom certificate template entry in DB that includes hours of effort. + """ + template_html = """ + <%namespace name='static' file='static_content.html'/> + + + lang: ${LANGUAGE_CODE} + course name: ${accomplishment_copy_course_name} + mode: ${course_mode} + % if hours_of_effort: + hours of effort: ${hours_of_effort} + % endif + ${accomplishment_copy_course_description} + ${twitter_url} + + + + """ + template = CertificateTemplate( + name='custom template', + template=template_html, + organization_id=org_id, + course_key=course_key, + mode=mode, + is_active=True, + language=language + ) + template.save() + @attr(shard=1) @ddt.ddt @@ -216,8 +247,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase): super(CertificatesViewsTests, self).setUp() self.mock_course_run_details = { 'content_language': 'en', - 'start': '2013-02-05T05:00:00Z', - 'end': '2013-03-05T05:00:00Z', + 'weeks_to_complete': '4', 'max_effort': '10' } @@ -1052,7 +1082,12 @@ class CertificatesViewsTests(CommonCertificatesTestCase): course_run_details.update({'content_language': 'es'}) mock_get_course_run_details.return_value = course_run_details - CertificateGenerationCourseSetting.set_language_specific_templates_enabled_for_course(self.course.id, True) + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'language_specific_templates_enabled': True + } + ) self._add_course_certificates(count=1, signatory_count=2) @@ -1104,7 +1139,12 @@ class CertificatesViewsTests(CommonCertificatesTestCase): course_run_details = self.mock_course_run_details course_run_details.update({'content_language': 'es'}) mock_get_course_run_details.return_value = course_run_details - CertificateGenerationCourseSetting.set_language_specific_templates_enabled_for_course(self.course.id, True) + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'language_specific_templates_enabled': True + } + ) self._add_course_certificates(count=1, signatory_count=2) @@ -1155,7 +1195,12 @@ class CertificatesViewsTests(CommonCertificatesTestCase): course_run_details = self.mock_course_run_details course_run_details.update({'content_language': 'es'}) mock_get_course_run_details.return_value = course_run_details - CertificateGenerationCourseSetting.set_language_specific_templates_enabled_for_course(self.course.id, True) + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'language_specific_templates_enabled': True + } + ) self._add_course_certificates(count=1, signatory_count=2) test_url = get_certificate_url( @@ -1205,7 +1250,12 @@ class CertificatesViewsTests(CommonCertificatesTestCase): course_run_details = self.mock_course_run_details course_run_details.update({'content_language': 'es'}) mock_get_course_run_details.return_value = course_run_details - CertificateGenerationCourseSetting.set_language_specific_templates_enabled_for_course(self.course.id, True) + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'language_specific_templates_enabled': True + } + ) self._add_course_certificates(count=1, signatory_count=2) @@ -1255,7 +1305,12 @@ class CertificatesViewsTests(CommonCertificatesTestCase): course_run_details = self.mock_course_run_details course_run_details.update({'content_language': 'es-419'}) mock_get_course_run_details.return_value = course_run_details - CertificateGenerationCourseSetting.set_language_specific_templates_enabled_for_course(self.course.id, True) + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'language_specific_templates_enabled': True + } + ) self._add_course_certificates(count=1, signatory_count=2) @@ -1291,6 +1346,36 @@ class CertificatesViewsTests(CommonCertificatesTestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, 'course name: test_right_lang_template') + @override_settings(FEATURES=FEATURES_WITH_CUSTOM_CERTS_ENABLED) + @ddt.data(True, False) + @patch('certificates.views.webview.get_course_run_details') + @patch('certificates.api.get_course_organization_id') + def test_certificate_custom_template_with_hours_of_effort(self, include_effort, mock_get_org_id, mock_get_course_run_details): + """ + Tests custom template properly retrieves and calculates Hours of Effort when the feature is enabled + """ + # mock the response data from Discovery that updates the context for template lookup and rendering + mock_get_course_run_details.return_value = self.mock_course_run_details + mock_get_org_id.return_value = 1 + CertificateGenerationCourseSetting.objects.update_or_create( + course_key=self.course.id, + defaults={ + 'include_hours_of_effort': include_effort + } + ) + self._add_course_certificates(count=1, signatory_count=2) + self._create_custom_template_with_hours_of_effort(org_id=1, language=None) + test_url = get_certificate_url( + user_id=self.user.id, + course_id=unicode(self.course.id) + ) + response = self.client.get(test_url) + self.assertEqual(response.status_code, 200) + if include_effort: + self.assertIn('hours of effort: 40', response.content) + else: + self.assertNotIn('hours of effort', response.content) + @ddt.data(True, False) @patch('certificates.views.webview.get_course_run_details') def test_certificate_custom_template_with_unicode_data(self, custom_certs_enabled, mock_get_course_run_details): diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 64894780eb..2edc14be00 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -249,20 +249,29 @@ def _update_course_context(request, context, course, course_key, platform_name): '{partner_short_name}.').format( partner_short_name=context['organization_short_name'], platform_name=platform_name) - # If language specific templates are enabled for the course, add course_run specific information to the context - if CertificateGenerationCourseSetting.is_language_specific_templates_enabled_for_course(course_key): - fields = ['start', 'end', 'max_effort', 'content_language'] - course_run_data = get_course_run_details(course_key, fields) - if course_run_data.get('start') and course_run_data.get('end') and course_run_data.get('max_effort'): - # Calculate duration of the course run in weeks, multiplied by max_effort for total Hours of Effort - try: - start = parser.parse(course_run_data.get('start')) - end = parser.parse(course_run_data.get('end')) - max_effort = int(course_run_data.get('max_effort')) - context['hours_of_effort'] = ((end - start).days / 7) * max_effort - except ValueError: - log.exception('Error occurred while parsing course run details') - context['content_language'] = course_run_data.get('content_language') + + +def _update_context_with_catalog_data(context, course_key): + """ + Updates context dictionary with relevant course run info from Discovery. + """ + course_certificate_settings = CertificateGenerationCourseSetting.get(course_key) + if course_certificate_settings: + course_run_fields = [] + if course_certificate_settings.language_specific_templates_enabled: + course_run_fields.append('content_language') + if course_certificate_settings.include_hours_of_effort: + course_run_fields.extend(['weeks_to_complete', 'max_effort']) + if course_run_fields: + course_run_data = get_course_run_details(course_key, course_run_fields) + if course_run_data.get('weeks_to_complete') and course_run_data.get('max_effort'): + try: + weeks_to_complete = int(course_run_data['weeks_to_complete']) + max_effort = int(course_run_data['max_effort']) + context['hours_of_effort'] = weeks_to_complete * max_effort + except ValueError: + log.exception('Error occurred while parsing course run details') + context['content_language'] = course_run_data.get('content_language') def _update_social_context(request, context, course, user, user_certificate, platform_name): @@ -592,6 +601,9 @@ def render_html_view(request, user_id, course_id): # Append course info _update_course_context(request, context, course, course_key, platform_name) + # Append course run info from discovery + _update_context_with_catalog_data(context, course_key) + # Append user info _update_context_with_user_info(context, user, user_certificate) diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 945f56b3ec..c7b2706d19 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -119,7 +119,8 @@ class CourseRunFactory(DictFactoryBase): type = 'verified' uuid = factory.Faker('uuid4') content_language = 'en' - max_effort = 5 + max_effort = 4 + weeks_to_complete = 10 class CourseFactory(DictFactoryBase): diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py index 5cd9f42e28..b58476749f 100644 --- a/openedx/core/djangoapps/catalog/tests/test_utils.py +++ b/openedx/core/djangoapps/catalog/tests/test_utils.py @@ -349,11 +349,10 @@ class TestGetCourseRunDetails(CatalogIntegrationMixin, TestCase): course_run = CourseRunFactory() course_run_details = { 'content_language': course_run['content_language'], - 'start': course_run['start'], - 'end': course_run['end'], + 'weeks_to_complete': course_run['weeks_to_complete'], 'max_effort': course_run['max_effort'] } mock_get_edx_api_data.return_value = course_run_details - data = get_course_run_details(course_run['key'], ['content_language', 'start', 'end', 'max_effort']) + data = get_course_run_details(course_run['key'], ['content_language', 'weeks_to_complete', 'max_effort']) self.assertTrue(mock_get_edx_api_data.called) self.assertEqual(data, course_run_details)