""" Database models for the badges app """ from importlib import import_module from config_models.models import ConfigurationModel from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ValidationError from django.db import models from django.utils.encoding import python_2_unicode_compatible # lint-amnesty, pylint: disable=no-name-in-module from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module from model_utils.models import TimeStampedModel from opaque_keys import InvalidKeyError from opaque_keys.edx.django.models import CourseKeyField from opaque_keys.edx.keys import CourseKey from lms.djangoapps.badges.utils import deserialize_count_specs from openedx.core.djangolib.markup import HTML, Text from xmodule.modulestore.django import modulestore def validate_badge_image(image): """ Validates that a particular image is small enough to be a badge and square. """ if image.width != image.height: raise ValidationError(_("The badge image must be square.")) if not image.size < (250 * 1024): raise ValidationError(_("The badge image file size must be less than 250KB.")) def validate_lowercase(string): """ Validates that a string is lowercase. """ if not string.islower(): raise ValidationError(_("This value must be all lowercase.")) class CourseBadgesDisabledError(Exception): """ Exception raised when Course Badges aren't enabled, but an attempt to fetch one is made anyway. """ @python_2_unicode_compatible class BadgeClass(models.Model): """ Specifies a badge class to be registered with a backend. .. no_pii: """ slug = models.SlugField(max_length=255, validators=[validate_lowercase]) issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase]) display_name = models.CharField(max_length=255) course_id = CourseKeyField(max_length=255, blank=True, default=None) description = models.TextField() criteria = models.TextField() # Mode a badge was awarded for. Included for legacy/migration purposes. mode = models.CharField(max_length=100, default='', blank=True) image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image]) def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned return HTML("").format( slug=HTML(self.slug), issuing_component=HTML(self.issuing_component) ) @classmethod def get_badge_class( cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None, mode='', course_id=None, create=True ): # TODO method should be renamed to getorcreate instead """ Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist. If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None. The expectation is that an XBlock or platform developer should not need to concern themselves with whether or not a badge class has already been created, but should just feed all requirements to this function and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class would need to be looked up without also being created were it missing. """ slug = slug.lower() issuing_component = issuing_component.lower() if course_id and not modulestore().get_course(course_id).issue_badges: raise CourseBadgesDisabledError("This course does not have badges enabled.") if not course_id: course_id = CourseKeyField.Empty try: return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id) except cls.DoesNotExist: if not create: return None badge_class = cls( slug=slug, issuing_component=issuing_component, display_name=display_name, course_id=course_id, mode=mode, description=description, criteria=criteria, ) badge_class.image.save(image_file_handle.name, image_file_handle) badge_class.full_clean() badge_class.save() return badge_class @lazy def backend(self): """ Loads the badging backend. """ module, klass = settings.BADGING_BACKEND.rsplit('.', 1) module = import_module(module) return getattr(module, klass)() def get_for_user(self, user): """ Get the assertion for this badge class for this user, if it has been awarded. """ return self.badgeassertion_set.filter(user=user) def award(self, user, evidence_url=None): """ Contacts the backend to have a badge assertion created for this badge class for this user. """ return self.backend.award(self, user, evidence_url=evidence_url) # lint-amnesty, pylint: disable=no-member def save(self, **kwargs): # lint-amnesty, pylint: disable=arguments-differ """ Slugs must always be lowercase. """ self.slug = self.slug and self.slug.lower() self.issuing_component = self.issuing_component and self.issuing_component.lower() super().save(**kwargs) class Meta: app_label = "badges" unique_together = (('slug', 'issuing_component', 'course_id'),) verbose_name_plural = "Badge Classes" @python_2_unicode_compatible class BadgeAssertion(TimeStampedModel): """ Tracks badges on our side of the badge baking transaction .. no_pii: """ user = models.ForeignKey(User, on_delete=models.CASCADE) badge_class = models.ForeignKey(BadgeClass, on_delete=models.CASCADE) data = JSONField() backend = models.CharField(max_length=50) image_url = models.URLField() assertion_url = models.URLField() def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned return HTML("<{username} Badge Assertion for {slug} for {issuing_component}").format( username=HTML(self.user.username), slug=HTML(self.badge_class.slug), issuing_component=HTML(self.badge_class.issuing_component), ) @classmethod def assertions_for_user(cls, user, course_id=None): """ Get all assertions for a user, optionally constrained to a course. """ if course_id: return cls.objects.filter(user=user, badge_class__course_id=course_id) return cls.objects.filter(user=user) class Meta: app_label = "badges" # Abstract model doesn't index this, so we have to. BadgeAssertion._meta.get_field('created').db_index = True @python_2_unicode_compatible class CourseCompleteImageConfiguration(models.Model): """ Contains the icon configuration for badges for a specific course mode. .. no_pii: """ mode = models.CharField( max_length=125, help_text=_('The course mode for this badge image. For example, "verified" or "honor".'), unique=True, ) icon = models.ImageField( # Actual max is 256KB, but need overhead for badge baking. This should be more than enough. help_text=_( "Badge images must be square PNG files. The file size should be under 250KB." ), upload_to='course_complete_badges', validators=[validate_badge_image] ) default = models.BooleanField( help_text=_( "Set this value to True if you want this image to be the default image for any course modes " "that do not have a specified badge image. You can have only one default image." ), default=False, ) def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned return HTML("").format( mode=HTML(self.mode), default=HTML(" (default)") if self.default else HTML('') ) def clean(self): """ Make sure there's not more than one default. """ if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id): raise ValidationError(_("There can be only one default image.")) @classmethod def image_for_mode(cls, mode): """ Get the image for a particular mode. """ try: return cls.objects.get(mode=mode).icon except cls.DoesNotExist: # Fall back to default, if there is one. return cls.objects.get(default=True).icon class Meta: app_label = "badges" @python_2_unicode_compatible 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. .. no_pii: """ courses_completed = models.TextField( blank=True, default='', 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 that has the issuing component 'openedx__course'. " "For example: 3,enrolled_3_courses" ) ) courses_enrolled = models.TextField( blank=True, default='', 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 that has the issuing component 'openedx__course'. " "For example: 3,enrolled_3_courses" ) ) course_groups = models.TextField( blank=True, default='', help_text=_( "Each line is a comma-separated list. The first item in each line is the slug of a badge class you " "have created that has an issuing component of 'openedx__course'. The remaining items in each line are " "the course keys the learner needs to complete to be awarded the badge. For example: " "slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" ) ) def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned return HTML("").format( Text("Enabled") if self.enabled else Text("Disabled") ) @property def completed_settings(self): """ Parses the settings from the courses_completed field. """ return deserialize_count_specs(self.courses_completed) @property def enrolled_settings(self): """ Parses the settings from the courses_completed field. """ return deserialize_count_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 = _("Please check the syntax of your entry.") if 'courses_completed' not in exclude: try: self.completed_settings except (ValueError, InvalidKeyError): errors['courses_completed'] = [str(error_message)] if 'courses_enrolled' not in exclude: try: self.enrolled_settings except (ValueError, InvalidKeyError): errors['courses_enrolled'] = [str(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(f"The course {course_key} does not exist.") except (ValueError, InvalidKeyError): errors['course_groups'] = [str(error_message)] if errors: raise ValidationError(errors) class Meta: app_label = "badges"