Add completion and enrollment badges.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
78
lms/djangoapps/badges/events/course_meta.py
Normal file
78
lms/djangoapps/badges/events/course_meta.py
Normal file
@@ -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)
|
||||
233
lms/djangoapps/badges/events/tests/test_course_meta.py
Normal file
233
lms/djangoapps/badges/events/tests/test_course_meta.py
Normal file
@@ -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)
|
||||
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -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"<CourseEventBadgesConfiguration ({})>".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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user