diff --git a/openedx/core/djangoapps/credential_criteria/__init__.py b/openedx/core/djangoapps/credential_criteria/__init__.py new file mode 100644 index 000000000000..ed5fd96e031a --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/__init__.py @@ -0,0 +1,5 @@ +""" +Django app to connect criteria with credentials (in Credentials service). +""" + +# TODO: redeploy as an independently deployable app diff --git a/openedx/core/djangoapps/credential_criteria/apps.py b/openedx/core/djangoapps/credential_criteria/apps.py new file mode 100644 index 000000000000..0af97279f182 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/apps.py @@ -0,0 +1,55 @@ +""" +Credentials Criteria +""" + +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginSettings, PluginSignals + + +class CredentialCriteriaConfig(AppConfig): + """ + Configuration class for credential_criteria Django app + """ + name = 'openedx.core.djangoapps.credential_criteria' + verbose_name = _("Credential Criteria") + + plugin_app = { + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.AWS: {PluginSettings.RELATIVE_PATH: u'settings.aws'}, + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: u'settings.common'}, + # SettingsType.DEVSTACK: {PluginSettings.RELATIVE_PATH: u'settings.devstack'}, + # SettingsType.TEST: {PluginSettings.RELATIVE_PATH: u'settings.test'}, + } + }, + PluginSignals.CONFIG: { + ProjectType.LMS: { + PluginSignals.RECEIVERS: [ + { + PluginSignals.RECEIVER_FUNC_NAME: u'handle_satisfied_usercredentialcriterion', + PluginSignals.SIGNAL_PATH: u'openedx.core.djangoapps.credential_criteria.signals.SATISFIED_USERCRITERION', + }, + # the post_save should only send a single Aggregator object + # { + # PluginSignals.RECEIVER_FUNC_NAME: u'handle_aggregator_update', + # PluginSignals.SIGNAL_PATH: u'django.db.models.signals.post_save', + # PluginSignals.SENDER_PATH: u'completion_aggregator.models.Aggregator', + # }, + # { + # PluginSignals.RECEIVER_FUNC_NAME: u'handle_blockcompletion_update', + # PluginSignals.SIGNAL_PATH: u'django.db.models.signals.post_save', + # PluginSignals.SENDER_PATH: u'completion.models.BlockCompletion', + # }, + + ], + }, + }, + } + + def ready(self): + # Register celery workers + from . import tasks # pylint: disable=unused-variable + from . import signals + signals.register_conditional_handlers() diff --git a/openedx/core/djangoapps/credential_criteria/constants.py b/openedx/core/djangoapps/credential_criteria/constants.py new file mode 100644 index 000000000000..11453cf15ed1 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/constants.py @@ -0,0 +1,38 @@ +""" +Constants for the credential_criteria Django app. +""" + +from django.utils.translation import ugettext_lazy as _ + + +# LMS +CREDENTIAL_CRITERION_TYPE_COMPLETION = _('Completion') +CREDENTIAL_CRITERION_TYPE_SCORE = _('Score') +CREDENTIAL_CRITERION_TYPE_GRADE = _('Letter Grade') +CREDENTIAL_CRITERION_TYPE_PASSFAIL = _('Pass/Fail') +CREDENTIAL_CRITERION_TYPE_ENROLLMENT = _('Enrollment') +CREDENTIAL_CRITERION_TYPE_CREDENTIAL = _('Credential') + +# Studio +CREDENTIAL_CRITERION_TYPE_PUBLISH = _('Publication') + + +CREDENTIAL_CRITERION_TYPES = { + CREDENTIAL_CRITERION_TYPE_COMPLETION, + CREDENTIAL_CRITERION_TYPE_SCORE, + CREDENTIAL_CRITERION_TYPE_GRADE, + CREDENTIAL_CRITERION_TYPE_PASSFAIL, + CREDENTIAL_CRITERION_TYPE_ENROLLMENT, + CREDENTIAL_CRITERION_TYPE_CREDENTIAL, + CREDENTIAL_CRITERION_TYPE_PUBLISH +} + +CREDENTIAL_CRITERION_TYPE_VERBS = { + CREDENTIAL_CRITERION_TYPE_COMPLETION: _("completed"), + CREDENTIAL_CRITERION_TYPE_SCORE: _("scored at least {percent}%"), + CREDENTIAL_CRITERION_TYPE_GRADE: _("achieved a grade of better than {letter_grade}"), + CREDENTIAL_CRITERION_TYPE_PASSFAIL: _("passed"), + CREDENTIAL_CRITERION_TYPE_ENROLLMENT: _("enrolled"), + CREDENTIAL_CRITERION_TYPE_CREDENTIAL: _("earned"), + CREDENTIAL_CRITERION_TYPE_PUBLISH: _("published"), +} diff --git a/openedx/core/djangoapps/credential_criteria/criterion_types.py b/openedx/core/djangoapps/credential_criteria/criterion_types.py new file mode 100644 index 000000000000..74bc9af4bdb7 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/criterion_types.py @@ -0,0 +1,122 @@ +""" +Logic for calculating satisfaction of a CredentialCriterion, +based on criterion_type; e.g., completion, score, etc.. +""" + +from abc import ABCMeta, abstractmethod + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ImproperlyConfigured + +from completion.models import BlockCompletion +try: + from completion_aggregator.models import Aggregator + completion_aggregator_installed = True +except ImportError: + completion_aggregator_installed = False + +from xblock.completable import XBlockCompletionMode +from xblock.core import XBlock +from xblock.plugin import PluginMissingError + +from . import constants, exceptions + + +def get_model_for_criterion_type(criterion_type): + """ + Return the AbstractCredentialCriterion subclass model which + is used for this criterion_type. + """ + if criterion_type in ( + constants.CREDENTIAL_CRITERION_TYPE_COMPLETION, + constants.CREDENTIAL_CRITERION_TYPE_SCORE, + constants.CREDENTIAL_CRITERION_TYPE_GRADE, + constants.CREDENTIAL_CRITERION_TYPE_PASSFAIL, + constants.CREDENTIAL_CRITERION_TYPE_ENROLLMENT, + constants.CREDENTIAL_CRITERION_TYPE_CREDENTIAL + ): + # there may be some other cases to support later, like block type, etc. + model_name = 'CredentialLocatorCriterion' + else: + raise NotImplementedError + try: + return ContentType.objects.get(app_label="credential_criteria", model=model_name) + except ContentType.DoesNotExist: + raise exception.CredentialCriteriaException( + "No credential criterion database model found for {}".format(criterion_type) + ) + + +class AbstractCriterionType(object): + """ + Abstract class for a criterion type class. + """ + __metaclass__ = ABCMeta + + @classmethod + def is_satisfied_for_user(cls, user, credential_criterion): + raise NotImplementedError + + +class CompletionCriterionType(AbstractCriterionType): + """ + Calculate satisfaction of a criterion based on a completion threshold. + """ + + @classmethod + def _can_use_aggregator(cls, block_type): + """ + Raise an exception if Completion Aggregaton cannot be used for an aggregator type + """ + err_msg = None + if not completion_aggregator_installed: + err_msg = "completion_aggregator must be installed to compute completion criterion for {}" + try: + aggregated_block_types = settings.COMPLETION_AGGREGATOR_BLOCK_TYPES + except AttributeError: + err_msg = "Completion Aggregation must be configured in settings to compute completion criterion for {}" + else: + if block_type not in settings.COMPLETION_AGGREGATOR_BLOCK_TYPES: + err_msg = "Completion Aggregation must be configured for {} to compute completion criterion" + if err_msg: + raise ImproperlyConfigured(err_msg.format(str(block_type))) + + @classmethod + def is_satisfied_for_user(cls, user, credential_criterion): + """ + If BlockCompletion or Aggregator percentage is above the threshold, + return True. + """ + crit = credential_criterion + try: + block_type = crit.locator.block_type + except AttributeError: + raise ValueError("{} is not a completable block".format(crit.locator)) + + try: + mode = XBlockCompletionMode.get_mode(XBlock.load_class(block_type)) + except PluginMissingError: + # Do not count blocks that aren't registered + mode = XBlockCompletionMode.EXCLUDED + + if mode not in (XBlockCompletionMode.COMPLETABLE, XBlockCompletionMode.AGGREGATOR): + raise ValueError("{} is not a completable block".format(crit.locator)) + + if mode == XBlockCompletionMode.AGGREGATOR: + try: + CompletionCriterionType._can_use_aggregator(block_type) + except ImproperlyConfigured as e: + raise exceptions.CredentialCriteriaException(e.msg, user) + completion_model = Aggregator + cmp_field = 'percent' + else: + completion_model = BlockCompletion + cmp_field = 'completion' + + try: + cmp_obj = completion_model.objects.get(user=user, block_key=crit.locator) + completion = getattr(cmp_obj, cmp_field) + return completion >= crit.satisfaction_threshold + except completion_model.DoesNotExist: + return False diff --git a/openedx/core/djangoapps/credential_criteria/exceptions.py b/openedx/core/djangoapps/credential_criteria/exceptions.py new file mode 100644 index 000000000000..3840835934e8 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/exceptions.py @@ -0,0 +1,14 @@ +""" +Exception classes for credential_criteria Django app.' +""" + + +class CredentialCriteriaException(Exception): + """ + Custom exception class to catch various errors with credential criterion/criteria. + """ + def __init__(self, msg='', user=None): + msg = "Could not calculate satisfaction of credential criteria for {}. Errors were {}".format( + user.username, msg + ) + super(CredentialCriteriaException, self).__init__(msg) diff --git a/openedx/core/djangoapps/credential_criteria/migrations/0001_initial.py b/openedx/core/djangoapps/credential_criteria/migrations/0001_initial.py new file mode 100644 index 000000000000..e780bce0acc8 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2020-05-19 01:15 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import opaque_keys.edx.django.models +import openedx.core.djangoapps.credential_criteria.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CredentialCriteria', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_active', models.BooleanField()), + ('credential_id', models.PositiveIntegerField()), + ('credential_type', models.CharField(choices=[(b'badge', b'badge'), (b'coursecertificate', b'coursecertificate'), (b'programcertificate', b'programcertificate')], max_length=255)), + ('_criteria_narrative', models.TextField()), + ('_criteria_url', models.URLField()), + ('_evidence_narrative', models.TextField()), + ('_evidence_url', models.URLField()), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='CredentialLocatorCriterion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('criterion_type', models.CharField(choices=[(set(['Completion', 'Credential', 'Publication', 'Enrollment', 'Pass/Fail', 'Letter Grade', 'Score']), set(['Completion', 'Credential', 'Publication', 'Enrollment', 'Pass/Fail', 'Letter Grade', 'Score']))], max_length=255)), + ('satisfaction_threshold', models.FloatField()), + ('locator', opaque_keys.edx.django.models.UsageKeyField(max_length=255, validators=[openedx.core.djangoapps.credential_criteria.models.validate_locator_field])), + ('criteria', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_query_name='credentiallocatorcriterions', to='credential_criteria.CredentialCriteria')), + ], + options={ + 'verbose_name': 'UsageKey Credential Criterion', + }, + ), + migrations.CreateModel( + name='UserCredentialCriterion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('criterion_id', models.PositiveIntegerField()), + ('satisfied', models.BooleanField()), + ('criterion_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='usercredentialcriterion', + unique_together=set([('user', 'criterion_id')]), + ), + ] diff --git a/openedx/core/djangoapps/credential_criteria/migrations/__init__.py b/openedx/core/djangoapps/credential_criteria/migrations/__init__.py new file mode 100644 index 000000000000..5ca051dcae39 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/migrations/__init__.py @@ -0,0 +1,3 @@ +""" +Django database migrations for credential_criteria. +""" diff --git a/openedx/core/djangoapps/credential_criteria/models.py b/openedx/core/djangoapps/credential_criteria/models.py new file mode 100644 index 000000000000..8d4f0b69c70c --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/models.py @@ -0,0 +1,267 @@ +""" +Models for credentials criteria. +""" + +import itertools +import logging + +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ + +from django_extensions.db.models import TimeStampedModel +from opaque_keys import InvalidKeyError + +try: + from opaque_keys.edx.keys import LearningContextKey + from opaque_keys.edx.django.models import LearningContextKeyField +except ImportError: + # compatibility with opaque_keys >= 2.0, blockstore compat. + from opaque_keys.edx.keys import UsageKey as LearningContextKey + from opaque_keys.edx.django.models import UsageKeyField as LearningContextKeyField + +from . import constants, criterion_types, exceptions + + +logger = logging.getLogger(__name__) + + +def _choices(*values): + """ + Helper for use with model field 'choices'. + """ + return [[value, value] for value in values] + + +def validate_locator_field(key): + """ + Validate the usage_key is correct. + """ + try: + locator = LearningContextKey.from_string(key) + except InvalidKeyError: + raise ValidationError(_("Invalid {}".format(key.KEY_TYPE))) + else: + from . import settings + if locator.block_type not in settings.CREDENTIAL_CONFERRING_BLOCK_TYPES: # this can't be Site aware + raise ValidationError(_("{} cannot be used as criteria for credentials".format(locator.block_type))) + + +class CredentialCriteria(TimeStampedModel): + """ + A collection of criteria sufficient to award a specific Credential. + More than one CredentialCriteria can be used for one Credential. + """ + is_active = models.BooleanField() + credential_id = models.PositiveIntegerField() # the db id of the Credential in Credentials service + credential_type = models.CharField(max_length=255, choices=_choices('badge', 'coursecertificate', 'programcertificate')) + # criteria and evidence fields are typically generated but can be set directly + _criteria_narrative = models.TextField() + _criteria_url = models.URLField() + _evidence_narrative = models.TextField() + _evidence_url = models.URLField() + + @property + def criteria_url(self): + """Implement as a cached property.""" + if self._criteria_url: + return self._criteria_url + else: + # TODO: calculate a URL which might be a view that explains criteria + # using their display names and values + return "https://criteria_url.foo" + + @criteria_url.setter + def criteria_url(self, value): + self._criteria_url = value + + @property + def criteria_narrative(self): + return self._criteria_narrative + + @criteria_narrative.setter + def criteria_narrative(self, value): + self._criteria_narrative = value + + @property + def evidence_url(self): + if self._evidence_url: + return self._evidence_url + else: + # TODO: calculate an evidence url + # the idea is that the evidence for a credential + # may be different depending on the award criteria used to achieve it + return "https://evidence_url.foo" + + @evidence_url.setter + def evidence_url(self, value): + self._evidence_url = value + + @property + def evidence_narrative(self): + return self._criteria_narrative + + @evidence_narrative.setter + def evidence_narrative(self, value): + self._evidence_narrative = value + + @cached_property + def criterions(self): + """Get union value of related set from all subclasses of AbstractCredentialCriterion.""" + # using cached_property this is evaluated only once per instance (in memory) + concretes = [sc.__name__.lower() for sc in AbstractCredentialCriterion.__subclasses__()] + related_managers = [getattr(self, '{}_set'.format(cname)) for cname in concretes] + return tuple(itertools.chain(*[list(manager.all()) for manager in related_managers])) + + def __repr__(self): + return "".format( + credential_type=self.credential_type, + credential_id=self.credential_id, + is_active=self.is_active + ) + + def evaluate_for_user(self, user): + """ + Award the linked Credential if satisfied for user. + """ + if self.is_satisfied_for_user(user): + # TODO: logic for whether it's already been awarded? + # or let Credentials handle that? + # we should minimize inter-service communication but + # Credentials does store UserCredential so it knows + self.award_for_user(user) + + def award_for_user(self, user): + """ + Contact the Credentials Service to award the credential. + """ + # do awarding + # pass anything needed for UserCredentialAttribute + # - store a reference to the Criteria and its timestamp + # as a UserCredentialAttribute + # use a Celery task + from .tasks import award_credential_for_user + award_credential_for_user.delay(**dict( + user=user, + credential_id=self.credential_id, + credential_type=self.credential_type, + criteria_narrative=self.criteria_narrative, + criteria_url=self.criteria_url, + evidence_narrative=self.evidence_narrative, + evidence_url=self.evidence_url) + ) + + # TODO: think about caching/ cache invalidation + # would avoid needing to create a UserCredentialCriteria model, too + # though that's an option + def is_satisfied_for_user(self, user): + """ + Return True only if all related CredentialCriterion are True for the given user + """ + # TODO: what happens when a CredentialCriteria becomes active after? + # probably shouldn't allow creation of a UserCredentialCriterion for inactive Criteria + if not self.is_active: # never sastified if not active + return False + + if not self.criterions: + msg = "Cannot evalute credential criteria: no member criterion" + raise exceptions.CredentialCriteriaException(msg, user) + + return all(crit.is_satisfied_for_user(user) for crit in self.criterions) + + def generate_evidence_url(self): + raise NotImplementedError + + +class UserCredentialCriterion(TimeStampedModel): + """ + Status of user for a criterion. + TODO: think about how this may become no longer satisfied + """ + user = models.ForeignKey(User) + criterion_content_type = models.ForeignKey( + ContentType, limit_choices_to={'model__in': ('credentiallocatorcriterion',)} + ) + criterion_id = models.PositiveIntegerField() + criterion = GenericForeignKey('criterion_content_type', 'criterion_id') + satisfied = models.BooleanField() + + class Meta(object): + unique_together = (('user', 'criterion_id')) + + +class AbstractCredentialCriterion(TimeStampedModel): + """ + A single criterion making up part of the CredentialCriteria. + The concrete model subclass provides additional fields relating to the *context* + of the criterion. Each criterion *type* has a corresponding logic + to determine satisfaction of the criterion. + + For example, a criterion with a UsageKey context can be satisfied by a score, + completion, a letter grade, etc. + """ + criterion_type = models.CharField(max_length=255, choices=_choices(constants.CREDENTIAL_CRITERION_TYPES)) + satisfaction_threshold = models.FloatField() + criteria = models.ForeignKey(CredentialCriteria, related_query_name='%(class)ss') + + user_criterions = GenericRelation( + UserCredentialCriterion, + content_type_field='criterion_content_type', + object_id_field='criterion_id', + related_query_name='criterions' + ) + + class Meta(object): + abstract = True + + @property + def criterion_type_class(self): + return getattr(criterion_types, self.criterion_type.title() + 'CriterionType') + + def is_satisfied_for_user(self, user): + return self.user_criterions.filter(user=user, satisfied=True).exists() + + def satisfy_for_user(self, user): + try: + satisfied = self.criterion_type_class.is_satisfied_for_user(user, self) + except Exception as e: + raise exceptions.CredentialCriteriaException(e.msg, user) + else: + criterion_content_type=ContentType.objects.get_for_model(self) + ucc, created = self.user_criterions.update_or_create( + user=user, + satisfied=satisfied, + criterion_content_type=criterion_content_type, + criterion_id=self.id + ) + if created and satisfied: + # any new, satisfied criterion should cause its CredentialCriteria to + # be evaluated + from . import signals + signals.SATISFIED_USERCRITERION.send( + sender=ucc.__class__, + user=user, + criterion=ucc.criterion + ) + + +class CredentialLocatorCriterion(AbstractCredentialCriterion): + """ + A criterion in the context of a single opaque_keys.edx.keys key type + depending on version of opaque_keys will be OpaqueKey or LearningContextKey + """ + locator = LearningContextKeyField(max_length=255, validators=[validate_locator_field]) + + class Meta(object): + verbose_name = 'Locator Credential Criterion' + + def __repr__(self): + return "".format( + criterion_type=self.criterion_type, + satisfaction_threshold=self.satisfaction_threshold, + locator=self.locator + ) diff --git a/openedx/core/djangoapps/credential_criteria/settings/__init__.py b/openedx/core/djangoapps/credential_criteria/settings/__init__.py new file mode 100644 index 000000000000..679b2549cd76 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/settings/__init__.py @@ -0,0 +1,3 @@ +""" +Plugin app settings modules for credentials_criteria Django app. +""" diff --git a/openedx/core/djangoapps/credential_criteria/settings/aws.py b/openedx/core/djangoapps/credential_criteria/settings/aws.py new file mode 100644 index 000000000000..eba7523bab9d --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/settings/aws.py @@ -0,0 +1,12 @@ + +def plugin_settings(settings): + + settings.CREDENTIAL_CONFERRING_BLOCK_TYPES = set(settings.ENV_TOKENS.get( + 'CREDENTIAL_CONFERRING_BLOCK_TYPES', + settings.CREDENTIAL_CONFERRING_BLOCK_TYPES, + )) + + settings.CREDENTIAL_CRITERIA_ROUTING_KEY = settings.ENV_TOKENS.get( + 'CREDENTIALS_GENERATION_ROUTING_KEY', + settings.CREDENTIAL_CRITERIA_ROUTING_KEY + ) diff --git a/openedx/core/djangoapps/credential_criteria/settings/common.py b/openedx/core/djangoapps/credential_criteria/settings/common.py new file mode 100644 index 000000000000..5ed27d5fcb84 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/settings/common.py @@ -0,0 +1,10 @@ +""" +Common environment plugin settings for credential_criteria Django app. +""" + + +def plugin_settings(settings): + settings.CREDENTIAL_CRITERIA_ROUTING_KEY = settings.CREDENTIALS_GENERATION_ROUTING_KEY + + # only these types can be used for criterion + settings.CREDENTIAL_CONFERRING_BLOCK_TYPES = {'course', 'chapter'} diff --git a/openedx/core/djangoapps/credential_criteria/signals.py b/openedx/core/djangoapps/credential_criteria/signals.py new file mode 100644 index 000000000000..dd5b60b96f4e --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/signals.py @@ -0,0 +1,63 @@ +""" +Signal handlers for credential_criteria Django app. +Implement logic to determine if criteria for a Credential have been satisfied +by the event represented in the Signal. +""" + +# For initial implementation, we only handle course completion via Aggregator +# but we also may need to handle other events which might affect the Criteria + +import logging + +from django.dispatch import Signal, receiver + +from . import constants, tasks, util + + +logger = logging.getLogger(__name__) + + +SATISFIED_USERCRITERION = Signal(providing_args=["user", "criterion"]) + + +def handle_aggregator_update(sender, **kwargs): + """ + Check completion credential criteria when completion Aggregators are updated. + aggregators passed from AggregationUpdater.update() are not Aggregator model objects + but a dictionary of aggregator blocks by block_key. + """ + if not util.feature_is_enabled(): + logger.debug( + "Taking no action on Aggregator completion for {}. " + "Credential Criteria feature not active".format(aggregator.block_key) + ) + return + + # satisfy any pertinent CredentialCriterion + for aggregator in kwargs['aggregators']: + if util.block_can_confer_credentials(aggregator.block_key): + logger.debug("Checking credential criteria after Aggregator completion for {}".format( + aggregator.block_key) + ) + tasks.satisfy_credential_criterion.delay(constants.CREDENTIAL_CRITERION_TYPE_COMPLETION, + **{"user": aggregator.user, "locator": aggregator.block_key}) + + +@receiver(SATISFIED_USERCRITERION) +def handle_satisfied_usercredentialcriterion(sender, **kwargs): + """ + Evaluate any full CredentialCriteria for satisfaction when saving a satisfied UserCredentialCriterion. + """ + criteria = kwargs['criterion'].criteria + criteria.evaluate_for_user(kwargs['user']) + + +def register_conditional_handlers(): + """ + Register signal handlers for conditionally-available signals. + """ + try: + from completion_aggregator.signals import AGGREGATORS_UPDATED + AGGREGATORS_UPDATED.connect(handle_aggregator_update) + except ImportError: + logger.debug("Can't register handle_aggregator_update. Signal not available.") diff --git a/openedx/core/djangoapps/credential_criteria/tasks.py b/openedx/core/djangoapps/credential_criteria/tasks.py new file mode 100644 index 000000000000..fbc8664bd3cb --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/tasks.py @@ -0,0 +1,80 @@ +""" +Celery tasks for credential_criteria Django app. +""" + +import logging + +from celery import task +from django.conf import settings +from django.contrib.auth.models import User + +from openedx.core.djangoapps.credentials.utils import get_credentials_api_client + +from . import criterion_types +from .models import UserCredentialCriterion + + +logger = logging.getLogger(__name__) + + +@task(routing_key=settings.CREDENTIAL_CRITERIA_ROUTING_KEY, ignore_result=True) +def satisfy_credential_criterion(criterion_type, **kwargs): + # satisfy any pertinent CredentialCriterion + user = kwargs['user'] + del kwargs['user'] + criterion_model = criterion_types.get_model_for_criterion_type(criterion_type) + criterions = criterion_model.model_class().objects.filter( + criterion_type=criterion_type, **kwargs) + if not criterions: + return + + # find any existing user criterions satisfied for this type + user_satisfied = UserCredentialCriterion.objects.filter( + user=user, criterion_content_type=criterion_model, + satisfied=True).values_list('criterions', flat=True).distinct() + + for criterion in criterions: + try: + if criterion.id in user_satisfied: + # any already satisfied don't need to be rechecked + continue + except AttributeError: + pass # no user_satisfied + + # see if the criterion is satisfied for this user + criterion.satisfy_for_user(user) + + +@task(bind=True, routing_key=settings.CREDENTIAL_CRITERIA_ROUTING_KEY, ignore_result=True) +def award_credential_for_user(self, **kwargs): + """ + Contact Credentials service to award the credential to the user. + """ + logger.info("Contacting Credentials service to award {} for {}".format( + kwargs['credential_id'], kwargs['user']) + ) + # eventually we should notify the user based on the task result + + countdown = 2 ** self.request.retries + + try: + credentials_client = get_credentials_api_client( + User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME) + ) + + credentials_client.credentials.post({ + 'username': kwargs['user'].username, + 'credential': kwargs['credential_id'], + 'certificate_url': kwargs['evidence_url'], + 'attributes': { + 'credential_type': kwargs['credential_type'], + 'criteria_narrative': kwargs['criteria_narrative'], + 'criteria_url': kwargs['criteria_url'], + 'evidence_narrative': kwargs['evidence_narrative'], + 'evidence_url': kwargs['evidence_url'], + } + }) + + except Exception as exc: + logger.exception('Failed to complete Credentials issue call for {} {}'.format(kwargs['credential_type'], kwargs['credential_id'])) + raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES) diff --git a/openedx/core/djangoapps/credential_criteria/util.py b/openedx/core/djangoapps/credential_criteria/util.py new file mode 100644 index 000000000000..316c00475faf --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/util.py @@ -0,0 +1,26 @@ +""" +utility methods for credentials_criteria Django app. +""" + +from django.conf import settings + +from openedx.core.djangoapps.site_configuration.helpers import get_value + +from . import waffle + + +def feature_is_enabled(): + """ + Return True or False based on whether waffle switch for feature is enabled. + """ + return waffle.WAFFLE_SWITCHES.is_enabled(waffle.ENABLE_CREDENTIAL_CRITERIA_APP) + + +def block_can_confer_credentials(block_key): + """ + Check whether this block is of a type that can confer a credential. + """ + conferrable_block_types = get_value( + "CREDENTIAL_CONFERRING_BLOCK_TYPES", + settings.CREDENTIAL_CONFERRING_BLOCK_TYPES) + return block_key.block_type in conferrable_block_types diff --git a/openedx/core/djangoapps/credential_criteria/waffle.py b/openedx/core/djangoapps/credential_criteria/waffle.py new file mode 100644 index 000000000000..9589a99371f3 --- /dev/null +++ b/openedx/core/djangoapps/credential_criteria/waffle.py @@ -0,0 +1,16 @@ +""" +Waffle switches for credential_criteria Django app +""" +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace + +# Namespace +WAFFLE_NAMESPACE = u'credential_criteria' + +# Switches +WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE) + + +# Full name: credential_criteria.enable_credential_criteria_app +# Indicates whether or not to use the credential criteria functionality +# regardless of it being an installed app. +ENABLE_CREDENTIAL_CRITERIA_APP = u'enable_credential_criteria_app' diff --git a/setup.py b/setup.py index cf933e0f2bf9..b0cb1f246fee 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ "ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig", "appsembler_settings = openedx.core.djangoapps.appsembler.settings.apps:SettingsConfig", "credentials = openedx.core.djangoapps.credentials.apps:CredentialsConfig", + "credential_criteria = openedx.core.djangoapps.credential_criteria.apps:CredentialCriteriaConfig", "discussion = lms.djangoapps.discussion.apps:DiscussionConfig", "grades = lms.djangoapps.grades.apps:GradesConfig", "plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",