Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7ad871f
Merge pull request #564 from appsembler/appsembler/tahoe/develop
melvinsoft Apr 8, 2020
fbe4419
Initial commit of openedx.core.djangoapps.credential_criteria app
bryanlandia May 11, 2020
df4d9ed
credential_criteria more logic for criterion, add stub of a signal ha…
bryanlandia May 12, 2020
b333c60
credential_criteria app in progress:
bryanlandia May 13, 2020
0c80d9e
fix a bunch of errors, rework to make CredentialCriteria a concrete m…
bryanlandia May 14, 2020
76fccfb
credential_criteria rework criterion model to usage either UsageKey o…
bryanlandia May 14, 2020
4f4b37e
credential_criteria better reprs for models
bryanlandia May 14, 2020
e86f6c4
credential_criteria: fix waffle switch
bryanlandia May 14, 2020
f664e1c
credential_criteria working signal handler from AggretationUpdater to…
bryanlandia May 15, 2020
4f742fc
credential_criteria Fix reverse relation from Criteria to abstract Cr…
bryanlandia May 15, 2020
4f372cf
support config via settings, env_tokens, siteconfiguration for which …
bryanlandia May 15, 2020
01b91f1
credentials_criteria call Credentials API to award credential
bryanlandia May 15, 2020
3afc2ea
credential_criteria squash migrations into one
bryanlandia May 19, 2020
87e13ec
credential_criteria don't try to register handler for completion aggr…
bryanlandia May 19, 2020
138c330
Minor code style changes
bryanlandia May 19, 2020
eec3c2e
credential_criteria simplify compat with opaque_keys 2.0+
bryanlandia May 19, 2020
cc2d256
code style
bryanlandia May 19, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openedx/core/djangoapps/credential_criteria/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Django app to connect criteria with credentials (in Credentials service).
"""

# TODO: redeploy as an independently deployable app
55 changes: 55 additions & 0 deletions openedx/core/djangoapps/credential_criteria/apps.py
Original file line number Diff line number Diff line change
@@ -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()
38 changes: 38 additions & 0 deletions openedx/core/djangoapps/credential_criteria/constants.py
Original file line number Diff line number Diff line change
@@ -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"),
}
122 changes: 122 additions & 0 deletions openedx/core/djangoapps/credential_criteria/criterion_types.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions openedx/core/djangoapps/credential_criteria/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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')]),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Django database migrations for credential_criteria.
"""
Loading