Commit e5a62aaa by Will Daly

Credit provider integration Python API

* Add Python API functions for creating and updating requests for credit.
* Add Django models and migrations for tracking credit requests and status.
parent 8d1651cc
......@@ -680,6 +680,9 @@ INSTALLED_APPS = (
'south',
'method_override',
# History tables
'simple_history',
# Database-backed configuration
'config_models',
......
......@@ -1710,6 +1710,9 @@ INSTALLED_APPS = (
'djcelery',
'south',
# History tables
'simple_history',
# Database-backed configuration
'config_models',
......
""" Contains the APIs for course credit requirements """
import logging
import uuid
from django.db import transaction
from .exceptions import InvalidCreditRequirements
from .models import CreditCourse, CreditRequirement
from openedx.core.djangoapps.credit.exceptions import InvalidCreditCourse
from student.models import User
from .exceptions import (
InvalidCreditRequirements,
InvalidCreditCourse,
UserIsNotEligible,
RequestAlreadyCompleted,
CreditRequestNotFound,
InvalidCreditStatus,
)
from .models import (
CreditCourse,
CreditRequirement,
CreditRequirementStatus,
CreditRequest,
CreditEligibility,
)
log = logging.getLogger(__name__)
def set_credit_requirements(course_key, requirements):
"""Add requirements to given course.
"""
Add requirements to given course.
Args:
course_key(CourseKey): The identifier for course
......@@ -63,7 +84,8 @@ def set_credit_requirements(course_key, requirements):
def get_credit_requirements(course_key, namespace=None):
"""Get credit eligibility requirements of a given course and namespace.
"""
Get credit eligibility requirements of a given course and namespace.
Args:
course_key(CourseKey): The identifier for course
......@@ -111,8 +133,198 @@ def get_credit_requirements(course_key, namespace=None):
]
@transaction.commit_on_success
def create_credit_request(course_key, provider_id, username):
"""
Initiate a request for credit from a credit provider.
This will return the parameters that the user's browser will need to POST
to the credit provider. It does NOT calculate the signature.
Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests.
A database record will be created to track the request with a 32-character UUID.
The returned dictionary can be used by the user's browser to send a POST request to the credit provider.
If a pending request already exists, this function should return a request description with the same UUID.
(Other parameters, such as the user's full name may be different than the original request).
If a completed request (either accepted or rejected) already exists, this function will
raise an exception. Users are not allowed to make additional requests once a request
has been completed.
Arguments:
course_key (CourseKey): The identifier for the course.
provider_id (str): The identifier of the credit provider.
user (User): The user initiating the request.
Returns: dict
Raises:
UserIsNotEligible: The user has not satisfied eligibility requirements for credit.
RequestAlreadyCompleted: The user has already submitted a request and received a response
from the credit provider.
Example Usage:
>>> create_credit_request(course.id, "hogwarts", "ron")
{
"uuid": "557168d0f7664fe59097106c67c3f847",
"timestamp": "2015-05-04T20:57:57.987119+00:00",
"course_org": "HogwartsX",
"course_num": "Potions101",
"course_run": "1T2015",
"final_grade": 0.95,
"user_username": "ron",
"user_email": "ron@example.com",
"user_full_name": "Ron Weasley",
"user_mailing_address": "",
"user_country": "US",
}
"""
try:
user_eligibility = CreditEligibility.objects.select_related('course', 'provider').get(
username=username,
course__course_key=course_key,
provider__provider_id=provider_id
)
credit_course = user_eligibility.course
credit_provider = user_eligibility.provider
except CreditEligibility.DoesNotExist:
raise UserIsNotEligible
# Initiate a new request if one has not already been created
credit_request, created = CreditRequest.objects.get_or_create(
course=credit_course,
provider=credit_provider,
username=username,
)
# Check whether we've already gotten a response for a request,
# If so, we're not allowed to issue any further requests.
# Skip checking the status if we know that we just created this record.
if not created and credit_request.status != "pending":
raise RequestAlreadyCompleted
if created:
credit_request.uuid = uuid.uuid4().hex
# Retrieve user account and profile info
user = User.objects.select_related('profile').get(username=username)
# Retrieve the final grade from the eligibility table
try:
final_grade = CreditRequirementStatus.objects.filter(
username=username,
requirement__namespace="grade",
requirement__name="grade",
status="satisfied"
).latest().reason["final_grade"]
except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError):
log.exception(
"Could not retrieve final grade from the credit eligibility table "
"for user %s in course %s.",
user.id, course_key
)
raise UserIsNotEligible
parameters = {
"uuid": credit_request.uuid,
"timestamp": credit_request.timestamp.isoformat(),
"course_org": course_key.org,
"course_num": course_key.course,
"course_run": course_key.run,
"final_grade": final_grade,
"user_username": user.username,
"user_email": user.email,
"user_full_name": user.profile.name,
"user_mailing_address": (
user.profile.mailing_address
if user.profile.mailing_address is not None
else ""
),
"user_country": (
user.profile.country.code
if user.profile.country.code is not None
else ""
),
}
credit_request.parameters = parameters
credit_request.save()
return parameters
def update_credit_request_status(request_uuid, status):
"""
Update the status of a credit request.
Approve or reject a request for a student to receive credit in a course
from a particular credit provider.
This function does NOT check that the status update is authorized.
The caller needs to handle authentication and authorization (checking the signature
of the message received from the credit provider)
The function is idempotent; if the request has already been updated to the status,
the function does nothing.
Arguments:
request_uuid (str): The unique identifier for the credit request.
status (str): Either "approved" or "rejected"
Returns: None
Raises:
CreditRequestNotFound: The request does not exist.
InvalidCreditStatus: The status is not either "approved" or "rejected".
"""
if status not in ["approved", "rejected"]:
raise InvalidCreditStatus
try:
request = CreditRequest.objects.get(uuid=request_uuid)
request.status = status
request.save()
except CreditRequest.DoesNotExist:
raise CreditRequestNotFound
def get_credit_requests_for_user(username):
"""
Retrieve the status of a credit request.
Returns either "pending", "accepted", or "rejected"
Arguments:
username (unicode): The username of the user who initiated the requests.
Returns: list
Example Usage:
>>> get_credit_request_status_for_user("bob")
[
{
"uuid": "557168d0f7664fe59097106c67c3f847",
"timestamp": "2015-05-04T20:57:57.987119+00:00",
"course_key": "course-v1:HogwartsX+Potions101+1T2015",
"provider": {
"id": "HogwartsX",
"display_name": "Hogwarts School of Witchcraft and Wizardry",
},
"status": "pending" # or "approved" or "rejected"
}
]
"""
return CreditRequest.credit_requests_for_user(username)
def _get_requirements_to_disable(old_requirements, new_requirements):
"""Get the ids of 'CreditRequirement' entries to be disabled that are
"""
Get the ids of 'CreditRequirement' entries to be disabled that are
deleted from the courseware.
Args:
......@@ -136,7 +348,8 @@ def _get_requirements_to_disable(old_requirements, new_requirements):
def _validate_requirements(requirements):
"""Validate the requirements.
"""
Validate the requirements.
Args:
requirements(list): List of requirements
......
"""
This module contains the exceptions raised in credit course requirements.
"""
"""Exceptions raised by the credit API. """
class InvalidCreditRequirements(Exception):
"""
The exception occurs when the requirement dictionary has invalid format.
The requirement dictionary provided has invalid format.
"""
pass
class InvalidCreditCourse(Exception):
"""
The exception occurs when the the course is not marked as a Credit Course.
The course is not configured for credit.
"""
pass
class UserIsNotEligible(Exception):
"""
The user has not satisfied eligibility requirements for credit.
"""
pass
class RequestAlreadyCompleted(Exception):
"""
The user has already submitted a request and received a response from the credit provider.
"""
pass
class CreditRequestNotFound(Exception):
"""
The request does not exist.
"""
pass
class InvalidCreditStatus(Exception):
"""
The status is not either "approved" or "rejected".
"""
pass
......@@ -66,4 +66,4 @@ class Migration(SchemaMigration):
}
}
complete_apps = ['credit']
\ No newline at end of file
complete_apps = ['credit']
......@@ -9,6 +9,8 @@ successful completion of a course on EdX
import logging
from django.db import models
from simple_history.models import HistoricalRecords
from jsonfield.fields import JSONField
from model_utils.models import TimeStampedModel
......@@ -19,6 +21,29 @@ from django.utils.translation import ugettext_lazy
log = logging.getLogger(__name__)
class CreditProvider(TimeStampedModel):
"""This model represents an institution that can grant credit for a course.
Each provider is identified by unique ID (e.g., 'ASU'). CreditProvider also
includes a `url` where the student will be sent when he/she will try to
get credit for course. Eligibility duration will be use to set duration
for which credit eligible message appears on dashboard.
"""
provider_id = models.CharField(max_length=255, db_index=True, unique=True)
display_name = models.CharField(max_length=255)
provider_url = models.URLField(max_length=255, unique=True, default="")
# Default is one year
DEFAULT_ELIGIBILITY_DURATION = 31556970
eligibility_duration = models.PositiveIntegerField(
help_text=ugettext_lazy(u"Number of seconds to show eligibility message"),
default=DEFAULT_ELIGIBILITY_DURATION
)
active = models.BooleanField(default=True)
class CreditCourse(models.Model):
"""
Model for tracking a credit course.
......@@ -26,6 +51,7 @@ class CreditCourse(models.Model):
course_key = CourseKeyField(max_length=255, db_index=True, unique=True)
enabled = models.BooleanField(default=False)
providers = models.ManyToManyField(CreditProvider)
@classmethod
def is_credit_course(cls, course_key):
......@@ -55,26 +81,9 @@ class CreditCourse(models.Model):
return cls.objects.get(course_key=course_key, enabled=True)
class CreditProvider(TimeStampedModel):
"""This model represents an institution that can grant credit for a course.
Each provider is identified by unique ID (e.g., 'ASU'). CreditProvider also
includes a `url` where the student will be sent when he/she will try to
get credit for course. Eligibility duration will be use to set duration
for which credit eligible message appears on dashboard.
"""
provider_id = models.CharField(max_length=255, db_index=True, unique=True)
display_name = models.CharField(max_length=255)
provider_url = models.URLField(max_length=255, unique=True)
eligibility_duration = models.PositiveIntegerField(
help_text=ugettext_lazy(u"Number of seconds to show eligibility message")
)
active = models.BooleanField(default=True)
class CreditRequirement(TimeStampedModel):
"""This model represents a credit requirement.
"""
This model represents a credit requirement.
Each requirement is uniquely identified by its 'namespace' and
'name' fields.
......@@ -89,7 +98,7 @@ class CreditRequirement(TimeStampedModel):
course = models.ForeignKey(CreditCourse, related_name="credit_requirements")
namespace = models.CharField(max_length=255)
name = models.CharField(max_length=255)
display_name = models.CharField(max_length=255)
display_name = models.CharField(max_length=255, default="")
criteria = JSONField()
active = models.BooleanField(default=True)
......@@ -101,7 +110,8 @@ class CreditRequirement(TimeStampedModel):
@classmethod
def add_or_update_course_requirement(cls, credit_course, requirement):
"""Add requirement to a given course.
"""
Add requirement to a given course.
Args:
credit_course(CreditCourse): The identifier for credit course
......@@ -127,7 +137,8 @@ class CreditRequirement(TimeStampedModel):
@classmethod
def get_course_requirements(cls, course_key, namespace=None):
"""Get credit requirements of a given course.
"""
Get credit requirements of a given course.
Args:
course_key(CourseKey): The identifier for a course
......@@ -143,7 +154,8 @@ class CreditRequirement(TimeStampedModel):
@classmethod
def disable_credit_requirements(cls, requirement_ids):
"""Mark the given requirements inactive.
"""
Mark the given requirements inactive.
Args:
requirement_ids(list): List of ids
......@@ -155,7 +167,8 @@ class CreditRequirement(TimeStampedModel):
class CreditRequirementStatus(TimeStampedModel):
"""This model represents the status of each requirement.
"""
This model represents the status of each requirement.
For a particular credit requirement, a user can either:
1) Have satisfied the requirement (example: approved in-course reverification)
......@@ -176,7 +189,7 @@ class CreditRequirementStatus(TimeStampedModel):
username = models.CharField(max_length=255, db_index=True)
requirement = models.ForeignKey(CreditRequirement, related_name="statuses")
status = models.CharField(choices=REQUIREMENT_STATUS_CHOICES, max_length=32)
status = models.CharField(max_length=32, choices=REQUIREMENT_STATUS_CHOICES)
# Include additional information about why the user satisfied or failed
# the requirement. This is specific to the type of requirement.
......@@ -185,9 +198,13 @@ class CreditRequirementStatus(TimeStampedModel):
# the grade to users later and to send the information to credit providers.
reason = JSONField(default={})
class Meta(object): # pylint: disable=missing-docstring
get_latest_by = "created"
class CreditEligibility(TimeStampedModel):
"""A record of a user's eligibility for credit from a specific credit
"""
A record of a user's eligibility for credit from a specific credit
provider for a specific course.
"""
......@@ -195,8 +212,87 @@ class CreditEligibility(TimeStampedModel):
course = models.ForeignKey(CreditCourse, related_name="eligibilities")
provider = models.ForeignKey(CreditProvider, related_name="eligibilities")
class Meta(object):
class Meta(object): # pylint: disable=missing-docstring
unique_together = ('username', 'course')
class CreditRequest(TimeStampedModel):
"""
A request for credit from a particular credit provider.
When a user initiates a request for credit, a CreditRequest record will be created.
Each CreditRequest is assigned a unique identifier so we can find it when the request
is approved by the provider. The CreditRequest record stores the parameters to be sent
at the time the request is made. If the user re-issues the request
(perhaps because the user did not finish filling in forms on the credit provider's site),
the request record will be updated, but the UUID will remain the same.
"""
uuid = models.CharField(max_length=32, unique=True, db_index=True)
username = models.CharField(max_length=255, db_index=True)
course = models.ForeignKey(CreditCourse, related_name="credit_requests")
provider = models.ForeignKey(CreditProvider, related_name="credit_requests")
timestamp = models.DateTimeField(auto_now_add=True)
parameters = JSONField()
REQUEST_STATUS_PENDING = "pending"
REQUEST_STATUS_APPROVED = "approved"
REQUEST_STATUS_REJECTED = "rejected"
REQUEST_STATUS_CHOICES = (
(REQUEST_STATUS_PENDING, "Pending"),
(REQUEST_STATUS_APPROVED, "Approved"),
(REQUEST_STATUS_REJECTED, "Rejected"),
)
status = models.CharField(
max_length=255,
choices=REQUEST_STATUS_CHOICES,
default=REQUEST_STATUS_PENDING
)
history = HistoricalRecords()
@classmethod
def credit_requests_for_user(cls, username):
"""
Model metadata.
Retrieve all credit requests for a user.
Arguments:
username (unicode): The username of the user.
Returns: list
Example Usage:
>>> CreditRequest.credit_requests_for_user("bob")
[
{
"uuid": "557168d0f7664fe59097106c67c3f847",
"timestamp": "2015-05-04T20:57:57.987119+00:00",
"course_key": "course-v1:HogwartsX+Potions101+1T2015",
"provider": {
"id": "HogwartsX",
"display_name": "Hogwarts School of Witchcraft and Wizardry",
},
"status": "pending" # or "approved" or "rejected"
}
]
"""
unique_together = ('username', 'course')
return [
{
"uuid": request.uuid,
"timestamp": request.modified,
"course_key": request.course.course_key,
"provider": {
"id": request.provider.provider_id,
"display_name": request.provider.display_name
},
"status": request.status
}
for request in cls.objects.select_related('course', 'provider').filter(username=username)
]
class Meta(object): # pylint: disable=missing-docstring
# Enforce the constraint that each user can have exactly one outstanding
# request to a given provider. Multiple requests use the same UUID.
unique_together = ('username', 'course', 'provider')
# -*- coding: utf-8 -*-
"""
Tests for credit course models.
"""
import ddt
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.credit.models import CreditCourse, CreditRequirement
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@ddt.ddt
class ModelTestCases(ModuleStoreTestCase):
class CreditEligibilityModelTests(TestCase):
"""
Tests for credit course models.
Tests for credit models used to track credit eligibility.
"""
def setUp(self, **kwargs):
super(ModelTestCases, self).setUp()
super(CreditEligibilityModelTests, self).setUp()
self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course")
@ddt.data(False, True)
......
......@@ -30,6 +30,7 @@ django-openid-auth==0.4
django-robots==0.9.1
django-sekizai==0.6.1
django-ses==0.4.1
django-simple-history==1.6.1
django-storages==1.1.5
django-threaded-multihost==1.4-1
django-method-override==0.1.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment