diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 8ff43b4456..765a75b3a0 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -22,6 +22,7 @@ from urllib import urlencode import uuid import analytics + from config_models.models import ConfigurationModel from django.utils.translation import ugettext_lazy as _ from django.conf import settings @@ -1212,6 +1213,10 @@ class CourseEnrollment(models.Model): # User is allowed to enroll if they've reached this point. enrollment = cls.get_or_create_enrollment(user, course_key) enrollment.update_enrollment(is_active=True, mode=mode) + if settings.FEATURES.get("ENABLE_OPENBADGES"): + from lms.djangoapps.badges.events.course_meta import award_enrollment_badge + award_enrollment_badge(user) + return enrollment @classmethod diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py index a30eaf4f9c..08b0c59da3 100644 --- a/lms/djangoapps/badges/admin.py +++ b/lms/djangoapps/badges/admin.py @@ -2,6 +2,9 @@ Admin registration for Badge Models """ from django.contrib import admin -from badges.models import CourseCompleteImageConfiguration +from badges.models import CourseCompleteImageConfiguration, CourseEventBadgesConfiguration, BadgeClass +from config_models.admin import ConfigurationModelAdmin admin.site.register(CourseCompleteImageConfiguration) +admin.site.register(BadgeClass) +admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin) diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py index 1c9fd22fad..c2786a3b52 100644 --- a/lms/djangoapps/badges/events/course_complete.py +++ b/lms/djangoapps/badges/events/course_complete.py @@ -2,17 +2,23 @@ Helper functions for the course complete event that was originally included with the Badging MVP. """ import hashlib +import logging from django.core.urlresolvers import reverse from django.template.defaultfilters import slugify from django.utils.translation import ugettext_lazy as _ -from badges.utils import site_prefix +from badges.models import CourseCompleteImageConfiguration, BadgeClass, BadgeAssertion +from badges.utils import site_prefix, requires_badges_enabled +from xmodule.modulestore.django import modulestore + +LOGGER = logging.getLogger(__name__) # NOTE: As these functions are carry-overs from the initial badging implementation, they are used in # migrations. Please check the badge migrations when changing any of these functions. + def course_slug(course_key, mode): """ Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge @@ -61,3 +67,49 @@ def criteria(course_key): """ about_path = reverse('about_course', kwargs={'course_id': unicode(course_key)}) return u'{}{}'.format(site_prefix(), about_path) + + +def get_completion_badge(course_id, user): + """ + Given a course key and a user, find the user's enrollment mode + and get the Course Completion badge. + """ + from student.models import CourseEnrollment + badge_classes = CourseEnrollment.objects.filter( + user=user, course_id=course_id + ).order_by('-is_active') + if not badge_classes: + return None + mode = badge_classes[0].mode + course = modulestore().get_course(course_id) + return BadgeClass.get_badge_class( + slug=course_slug(course_id, mode), + issuing_component='', + criteria=criteria(course_id), + description=badge_description(course, mode), + course_id=course_id, + mode=mode, + display_name=course.display_name, + image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode) + ) + + +@requires_badges_enabled +def course_badge_check(user, course_key): + """ + Takes a GeneratedCertificate instance, and checks to see if a badge exists for this course, creating + it if not, should conditions be right. + """ + if not modulestore().get_course(course_key).issue_badges: + LOGGER.info("Course is not configured to issue badges.") + return + badge_class = get_completion_badge(course_key, user) + if not badge_class: + # We're not configured to make a badge for this course mode. + return + if BadgeAssertion.objects.filter(user=user, badge_class=badge_class): + LOGGER.info("Completion badge already exists for this user on this course.") + # Badge already exists. Skip. + return + evidence = evidence_url(user.id, course_key) + badge_class.award(user, evidence_url=evidence) diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py new file mode 100644 index 0000000000..a09696a1d0 --- /dev/null +++ b/lms/djangoapps/badges/events/course_meta.py @@ -0,0 +1,78 @@ +""" +Events which have to do with a user doing something with more than one course, such +as enrolling in a certain number, completing a certain number, or completing a specific set of courses. +""" + +from badges.models import CourseEventBadgesConfiguration, BadgeClass +from badges.utils import requires_badges_enabled + + +def award_badge(config, count, user): + """ + Given one of the configurations for enrollments or completions, award + the appropriate badge if one is configured. + + config is a dictionary with integer keys and course keys as values. + count is the key to retrieve from this dictionary. + user is the user to award the badge to. + """ + slug = config.get(count) + if not slug: + return + badge_class = BadgeClass.get_badge_class( + slug=slug, issuing_component='edx__course', create=False, + ) + if not badge_class: + return + if not badge_class.get_for_user(user): + badge_class.award(user) + + +def award_enrollment_badge(user): + """ + Awards badges based on the number of courses a user is enrolled in. + """ + config = CourseEventBadgesConfiguration.current().enrolled_settings + enrollments = user.courseenrollment_set.filter(is_active=True).count() + award_badge(config, enrollments, user) + + +@requires_badges_enabled +def completion_check(user): + """ + Awards badges based upon the number of courses a user has 'completed'. + Courses are never truly complete, but they can be closed. + + For this reason we use checks on certificates to find out if a user has + completed courses. This badge will not work if certificate generation isn't + enabled and run. + """ + from certificates.models import CertificateStatuses + config = CourseEventBadgesConfiguration.current().completed_settings + certificates = user.generatedcertificate_set.filter(status__in=CertificateStatuses.PASSED_STATUSES).count() + award_badge(config, certificates, user) + + +@requires_badges_enabled +def course_group_check(user, course_key): + """ + Awards a badge if a user has completed every course in a defined set. + """ + from certificates.models import CertificateStatuses + config = CourseEventBadgesConfiguration.current().course_group_settings + awards = [] + for slug, keys in config.items(): + if course_key in keys: + certs = user.generatedcertificate_set.filter( + status__in=CertificateStatuses.PASSED_STATUSES, + course_id__in=keys, + ) + if len(certs) == len(keys): + awards.append(slug) + + for slug in awards: + badge_class = BadgeClass.get_badge_class( + slug=slug, issuing_component='edx__course', create=False, + ) + if badge_class and not badge_class.get_for_user(user): + badge_class.award(user) diff --git a/lms/djangoapps/badges/events/tests/test_course_meta.py b/lms/djangoapps/badges/events/tests/test_course_meta.py new file mode 100644 index 0000000000..c5c92fe828 --- /dev/null +++ b/lms/djangoapps/badges/events/tests/test_course_meta.py @@ -0,0 +1,233 @@ +""" +Tests the course meta badging events +""" + +from django.test.utils import override_settings +from mock import patch + +from django.conf import settings + +from badges.backends.base import BadgeBackend +from badges.tests.factories import RandomBadgeClassFactory, CourseEventBadgesConfigurationFactory, BadgeAssertionFactory +from certificates.models import GeneratedCertificate, CertificateStatuses +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class DummyBackend(BadgeBackend): + """ + Dummy backend that creates assertions without contacting any real-world backend. + """ + def award(self, badge_class, user, evidence_url=None): + return BadgeAssertionFactory(badge_class=badge_class, user=user) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseEnrollmentBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges based on number of courses a user is enrolled in. + """ + def setUp(self): + super(CourseEnrollmentBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + ] + nums = ['3', '5', '8'] + entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])] + enrollment_config = '\r'.join(entries) + self.config = CourseEventBadgesConfigurationFactory(courses_enrolled=enrollment_config) + + def test_no_match(self): + """ + Make sure a badge isn't created before a user's reached any checkpoint. + """ + user = UserFactory() + course = CourseFactory() + # pylint: disable=no-member + CourseEnrollment.enroll(user, course_key=course.location.course_key) + self.assertFalse(user.badgeassertion_set.all()) + + def test_checkpoint_matches(self): + """ + Make sure the proper badges are awarded at the right checkpoints. + """ + user = UserFactory() + courses = [CourseFactory() for _i in range(3)] + for course in courses: + CourseEnrollment.enroll(user, course_key=course.location.course_key) + # pylint: disable=no-member + assertions = user.badgeassertion_set.all() + self.assertEqual(user.badgeassertion_set.all().count(), 1) + self.assertEqual(assertions[0].badge_class, self.badge_classes[0]) + + courses = [CourseFactory() for _i in range(2)] + for course in courses: + # pylint: disable=no-member + CourseEnrollment.enroll(user, course_key=course.location.course_key) + # pylint: disable=no-member + assertions = user.badgeassertion_set.all().order_by('id') + # pylint: disable=no-member + self.assertEqual(user.badgeassertion_set.all().count(), 2) + self.assertEqual(assertions[1].badge_class, self.badge_classes[1]) + + courses = [CourseFactory() for _i in range(3)] + for course in courses: + # pylint: disable=no-member + CourseEnrollment.enroll(user, course_key=course.location.course_key) + # pylint: disable=no-member + assertions = user.badgeassertion_set.all().order_by('id') + # pylint: disable=no-member + self.assertEqual(user.badgeassertion_set.all().count(), 3) + self.assertEqual(assertions[2].badge_class, self.badge_classes[2]) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseCompletionBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges based on the number of courses completed. + """ + def setUp(self, **kwargs): + super(CourseCompletionBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + ] + nums = ['2', '6', '9'] + entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])] + completed_config = '\r'.join(entries) + self.config = CourseEventBadgesConfigurationFactory.create(courses_completed=completed_config) + self.config.clean_fields() + + def test_no_match(self): + """ + Make sure a badge isn't created before a user's reached any checkpoint. + """ + user = UserFactory() + course = CourseFactory() + GeneratedCertificate( + # pylint: disable=no-member + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + self.assertFalse(user.badgeassertion_set.all()) + + def test_checkpoint_matches(self): + """ + Make sure the proper badges are awarded at the right checkpoints. + """ + user = UserFactory() + courses = [CourseFactory() for _i in range(2)] + for course in courses: + GeneratedCertificate( + # pylint: disable=no-member + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + assertions = user.badgeassertion_set.all() + # pylint: disable=no-member + self.assertEqual(user.badgeassertion_set.all().count(), 1) + self.assertEqual(assertions[0].badge_class, self.badge_classes[0]) + + courses = [CourseFactory() for _i in range(6)] + for course in courses: + GeneratedCertificate( + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + assertions = user.badgeassertion_set.all().order_by('id') + self.assertEqual(user.badgeassertion_set.all().count(), 2) + self.assertEqual(assertions[1].badge_class, self.badge_classes[1]) + + courses = [CourseFactory() for _i in range(9)] + for course in courses: + GeneratedCertificate( + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + assertions = user.badgeassertion_set.all().order_by('id') + # pylint: disable=no-member + self.assertEqual(user.badgeassertion_set.all().count(), 3) + self.assertEqual(assertions[2].badge_class, self.badge_classes[2]) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseGroupBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges when a user completes a set of courses. + """ + def setUp(self): + super(CourseGroupBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + ] + self.courses = [] + for _badge_class in self.badge_classes: + # pylint: disable=no-member + self.courses.append([CourseFactory().location.course_key for _i in range(3)]) + lines = [badge_class.slug + ',' + ','.join([unicode(course_key) for course_key in keys]) + for badge_class, keys in zip(self.badge_classes, self.courses)] + config = '\r'.join(lines) + self.config = CourseEventBadgesConfigurationFactory(course_groups=config) + self.config_map = dict(zip(self.badge_classes, self.courses)) + + def test_no_match(self): + """ + Make sure a badge isn't created before a user's completed any course groups. + """ + user = UserFactory() + course = CourseFactory() + GeneratedCertificate( + # pylint: disable=no-member + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + self.assertFalse(user.badgeassertion_set.all()) + + def test_group_matches(self): + """ + Make sure the proper badges are awarded when groups are completed. + """ + user = UserFactory() + items = list(self.config_map.items()) + for badge_class, course_keys in items: + for i, key in enumerate(course_keys): + GeneratedCertificate( + user=user, course_id=key, status=CertificateStatuses.downloadable + ).save() + # We don't award badges until all three are set. + if i + 1 == len(course_keys): + self.assertTrue(badge_class.get_for_user(user)) + else: + self.assertFalse(badge_class.get_for_user(user)) + # pylint: disable=no-member + classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()] + source_classes = [badge.id for badge in self.badge_classes] + self.assertEqual(classes, source_classes) diff --git a/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py new file mode 100644 index 0000000000..aefbf1d3fb --- /dev/null +++ b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('badges', '0002_data__migrate_assertions'), + ] + + operations = [ + migrations.CreateModel( + name='CourseEventBadgesConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), + ('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), + ('course_groups', models.TextField(default=b'', help_text="On each line, put the slug of a badge class you have created with the issuing component 'edx__course' to award, a comma, and a comma separated list of course keys that the user will need to complete to get this badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + ), + migrations.AlterModelOptions( + name='badgeclass', + options={'verbose_name_plural': 'Badge Classes'}, + ), + ] diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 909ccaec1e..a1ad274c6c 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -9,7 +9,11 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ from lazy import lazy +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from config_models.models import ConfigurationModel +from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField from jsonfield import JSONField @@ -53,7 +57,7 @@ class BadgeClass(models.Model): @classmethod def get_badge_class( - cls, slug, issuing_component, display_name, description, criteria, image_file_handle, + cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None, mode='', course_id=None, create=True ): """ @@ -115,6 +119,7 @@ class BadgeClass(models.Model): class Meta(object): app_label = "badges" unique_together = (('slug', 'issuing_component', 'course_id'),) + verbose_name_plural = "Badge Classes" class BadgeAssertion(models.Model): @@ -199,3 +204,112 @@ class CourseCompleteImageConfiguration(models.Model): class Meta(object): app_label = "badges" + + +class CourseEventBadgesConfiguration(ConfigurationModel): + """ + Determines the settings for meta course awards-- such as completing a certain + number of courses or enrolling in a certain number of them. + """ + courses_completed = models.TextField( + blank=True, default='', + help_text=_( + u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a " + u"badge class you have created with the issuing component 'edx__course'. " + u"For example: 3,course-v1:edx/Demo/DemoX" + ) + ) + courses_enrolled = models.TextField( + blank=True, default='', + help_text=_( + u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a " + u"badge class you have created with the issuing component 'edx__course'. " + u"For example: 3,course-v1:edx/Demo/DemoX" + ) + ) + course_groups = models.TextField( + blank=True, default='', + help_text=_( + u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, " + u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the " + u"user will need to complete to get the badge. For example: slug_for_compsci_courses_group_badge,course-v1" + u":CompSci+Course+First,course-v1:CompsSci+Course+Second" + ) + ) + + def __unicode__(self): + return u"".format(u"Enabled" if self.enabled else u"Disabled") + + @staticmethod + def get_specs(text): + """ + Takes a string in the format of: + int,course_key + int,course_key + + And returns a dictionary with the keys as the numbers and the values as the course keys. + """ + specs = text.splitlines() + specs = [line.split(',') for line in specs if line.strip()] + return {int(num): slug.strip().lower() for num, slug in specs} + + @property + def completed_settings(self): + """ + Parses the settings from the courses_completed field. + """ + return self.get_specs(self.courses_completed) + + @property + def enrolled_settings(self): + """ + Parses the settings from the courses_completed field. + """ + return self.get_specs(self.courses_enrolled) + + @property + def course_group_settings(self): + """ + Parses the course group settings. In example, the format is: + + slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second + """ + specs = self.course_groups.strip() + if not specs: + return {} + specs = [line.split(',', 1) for line in specs.splitlines()] + return { + slug.strip().lower(): [CourseKey.from_string(key.strip()) for key in keys.strip().split(',')] + for slug, keys in specs + } + + def clean_fields(self, exclude=tuple()): + """ + Verify the settings are parseable. + """ + errors = {} + error_message = _(u"Please check the syntax of your entry.") + if 'courses_completed' not in exclude: + try: + self.completed_settings + except (ValueError, InvalidKeyError): + errors['courses_completed'] = [unicode(error_message)] + if 'courses_enrolled' not in exclude: + try: + self.enrolled_settings + except (ValueError, InvalidKeyError): + errors['courses_enrolled'] = [unicode(error_message)] + if 'course_groups' not in exclude: + store = modulestore() + try: + for key_list in self.course_group_settings.values(): + for course_key in key_list: + if not store.get_course(course_key): + ValueError(u"The course {course_key} does not exist.".format(course_key=course_key)) + except (ValueError, InvalidKeyError): + errors['course_groups'] = [unicode(error_message)] + if errors: + raise ValidationError(errors) + + class Meta(object): + app_label = "badges" diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py index dfb60544df..4751ed413b 100644 --- a/lms/djangoapps/badges/tests/factories.py +++ b/lms/djangoapps/badges/tests/factories.py @@ -8,7 +8,7 @@ from django.core.files.base import ContentFile from factory import DjangoModelFactory from factory.django import ImageField -from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass +from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass, CourseEventBadgesConfiguration from student.tests.factories import UserFactory @@ -69,3 +69,13 @@ class BadgeAssertionFactory(DjangoModelFactory): data = {} assertion_url = 'http://example.com/example.json' image_url = 'http://example.com/image.png' + + +class CourseEventBadgesConfigurationFactory(DjangoModelFactory): + """ + Factory for CourseEventsBadgesConfiguration + """ + class Meta(object): + model = CourseEventBadgesConfiguration + + enabled = True diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py index a59ec41e2c..6058ce0ee2 100644 --- a/lms/djangoapps/badges/tests/test_models.py +++ b/lms/djangoapps/badges/tests/test_models.py @@ -3,9 +3,10 @@ Tests for the Badges app models. """ from django.core.exceptions import ValidationError from django.core.files.images import ImageFile +from django.db.utils import IntegrityError from django.test import TestCase from django.test.utils import override_settings -from mock import patch +from mock import patch, Mock from nose.plugins.attrib import attr from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -54,6 +55,7 @@ class DummyBackend(object): """ Dummy badge backend, used for testing. """ + award = Mock() class BadgeClassTest(ModuleStoreTestCase): @@ -126,9 +128,7 @@ class BadgeClassTest(ModuleStoreTestCase): Test returns None if the badge class does not exist. """ badge_class = BadgeClass.get_badge_class( - slug='new_slug', issuing_component='new_component', description=None, - criteria=None, display_name=None, - image_file_handle=None, create=False + slug='new_slug', issuing_component='new_component', create=False ) self.assertIsNone(badge_class) # Run this twice to verify there wasn't a background creation of the badge. @@ -139,7 +139,7 @@ class BadgeClassTest(ModuleStoreTestCase): ) self.assertIsNone(badge_class) - def test_get_badge_class_validate(self): + def test_get_badge_class_image_validate(self): """ Verify handing a broken image to get_badge_class raises a validation error upon creation. """ @@ -151,6 +151,17 @@ class BadgeClassTest(ModuleStoreTestCase): image_file_handle=get_image('unbalanced') ) + def test_get_badge_class_data_validate(self): + """ + Verify handing incomplete data for required fields when making a badge class raises an Integrity error. + """ + self.assertRaises( + IntegrityError, + BadgeClass.get_badge_class, + slug='new_slug', issuing_component='new_component', + image_file_handle=get_image('good') + ) + def test_get_for_user(self): """ Make sure we can get an assertion for a user if there is one. diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py index 9e2d98f304..4c069d3a72 100644 --- a/lms/djangoapps/badges/utils.py +++ b/lms/djangoapps/badges/utils.py @@ -10,3 +10,17 @@ def site_prefix(): """ scheme = u"https" if settings.HTTPS == "on" else u"http" return u'{}://{}'.format(scheme, settings.SITE_NAME) + + +def requires_badges_enabled(function): + """ + Decorator that bails a function out early if badges aren't enabled. + """ + def wrapped(*args, **kwargs): + """ + Wrapped function which bails out early if bagdes aren't enabled. + """ + if not settings.FEATURES.get('ENABLE_OPENBADGES', False): + return + return function(*args, **kwargs) + return wrapped diff --git a/lms/djangoapps/certificates/management/commands/regenerate_user.py b/lms/djangoapps/certificates/management/commands/regenerate_user.py index 7461a9654b..ac37abf34a 100644 --- a/lms/djangoapps/certificates/management/commands/regenerate_user.py +++ b/lms/djangoapps/certificates/management/commands/regenerate_user.py @@ -9,7 +9,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey -from certificates.models import get_completion_badge +from badges.events.course_complete import get_completion_badge from xmodule.modulestore.django import modulestore from certificates.api import regenerate_user_certificates diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 9de0d2a59f..42da820c1c 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -64,12 +64,11 @@ from model_utils.models import TimeStampedModel from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED -from badges.events import course_complete -from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass +from badges.events.course_complete import course_badge_check +from badges.events.course_meta import completion_check, course_group_check from config_models.models import ConfigurationModel from instructor_task.models import InstructorTask from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled -from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField, NoneToEmptyManager LOGGER = logging.getLogger(__name__) @@ -1012,47 +1011,26 @@ class CertificateTemplateAsset(TimeStampedModel): app_label = "certificates" -def get_completion_badge(course_id, user): +@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) +# pylint: disable=unused-argument +def create_course_badge(sender, user, course_key, status, **kwargs): """ - Given a course key and a user, find the user's enrollment mode - and get the Course Completion badge. + Standard signal hook to create course badges when a certificate has been generated. """ - from student.models import CourseEnrollment - mode = CourseEnrollment.objects.filter( - user=user, course_id=course_id - ).order_by('-is_active')[0].mode - course = modulestore().get_course(course_id) - return BadgeClass.get_badge_class( - slug=course_complete.course_slug(course_id, mode), - issuing_component='', - criteria=course_complete.criteria(course_id), - description=course_complete.badge_description(course, mode), - course_id=course_id, - mode=mode, - display_name=course.display_name, - image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode) - ) + course_badge_check(user, course_key) @receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) -#pylint: disable=unused-argument -def create_badge(sender, user, course_key, status, **kwargs): +def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument """ - Standard signal hook to create badges when a certificate has been generated. + Standard signal hook to create 'x courses completed' badges when a certificate has been generated. """ - if not settings.FEATURES.get('ENABLE_OPENBADGES', False): - return - if not modulestore().get_course(course_key).issue_badges: - LOGGER.info("Course is not configured to issue badges.") - return - badge_class = get_completion_badge(course_key, user) - if BadgeAssertion.objects.filter(user=user, badge_class=badge_class): - LOGGER.info("Completion badge already exists for this user on this course.") - # Badge already exists. Skip. - return - # Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this - # by making sure it only gets run on the callback during normal workflow. - if not status == CertificateStatuses.downloadable: - return - evidence = course_complete.evidence_url(user.id, course_key) - badge_class.award(user, evidence_url=evidence) + completion_check(user) + + +@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) +def create_course_group_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument + """ + Standard signal hook to create badges when a user has completed a prespecified set of courses. + """ + course_group_check(user, course_key) diff --git a/lms/djangoapps/certificates/tests/test_cert_management.py b/lms/djangoapps/certificates/tests/test_cert_management.py index a0a791f882..413158616d 100644 --- a/lms/djangoapps/certificates/tests/test_cert_management.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -9,13 +9,14 @@ from mock import patch from course_modes.models import CourseMode from opaque_keys.edx.locator import CourseLocator +from badges.events.course_complete import get_completion_badge from badges.models import BadgeAssertion from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from student.tests.factories import UserFactory, CourseEnrollmentFactory from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs -from certificates.models import GeneratedCertificate, CertificateStatuses, get_completion_badge +from certificates.models import GeneratedCertificate, CertificateStatuses class CertificateManagementTest(ModuleStoreTestCase): diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index a6b799eb9b..7c80987f77 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -14,7 +14,11 @@ from django.core.urlresolvers import reverse from django.test.client import Client from django.test.utils import override_settings +<<<<<<< HEAD from course_modes.models import CourseMode +======= +from badges.events.course_complete import get_completion_badge +>>>>>>> a248c5a... Add completion and enrollment badges. from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from openedx.core.lib.tests.assertions.events import assert_event_matches from student.tests.factories import UserFactory, CourseEnrollmentFactory @@ -31,7 +35,6 @@ from certificates.models import ( CertificateTemplate, CertificateHtmlViewConfiguration, CertificateTemplateAsset, - get_completion_badge ) from certificates.tests.factories import ( diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 2c76777bf7..eb8441af35 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -14,6 +14,7 @@ from django.template import RequestContext from django.utils.translation import ugettext as _ from django.utils.encoding import smart_str +from badges.events.course_complete import get_completion_badge from courseware.access import has_access from edxmako.shortcuts import render_to_response from edxmako.template import Template @@ -41,8 +42,7 @@ from certificates.models import ( GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, - CertificateSocialNetworks, - get_completion_badge) + CertificateSocialNetworks) log = logging.getLogger(__name__)