Credit eligibility/provider refactor
* Remove m2m relation between credit course and credit providers. * Separate eligibility and provider APIs into different modules. * Add API call for retrieving a user's eligibilities. * Cache credit course list. * Style the dashboard purchase button. * Display a link for the credit provider on the dashboard. * Add analytics events for clicks on the purchase button. * Expose more credit models to Django admin and add search functionality.
This commit is contained in:
@@ -2,7 +2,33 @@
|
||||
Django admin page for credit eligibility
|
||||
"""
|
||||
from ratelimitbackend import admin
|
||||
from .models import CreditCourse, CreditProvider
|
||||
from openedx.core.djangoapps.credit.models import (
|
||||
CreditCourse, CreditProvider, CreditEligibility, CreditRequest
|
||||
)
|
||||
|
||||
admin.site.register(CreditCourse)
|
||||
admin.site.register(CreditProvider)
|
||||
|
||||
class CreditCourseAdmin(admin.ModelAdmin):
|
||||
"""Admin for credit courses. """
|
||||
search_fields = ("course_key",)
|
||||
|
||||
|
||||
class CreditProviderAdmin(admin.ModelAdmin):
|
||||
"""Admin for credit providers. """
|
||||
search_fields = ("provider_id", "display_name")
|
||||
|
||||
|
||||
class CreditEligibilityAdmin(admin.ModelAdmin):
|
||||
"""Admin for credit eligibility. """
|
||||
search_fields = ("username", "course__course_key")
|
||||
|
||||
|
||||
class CreditRequestAdmin(admin.ModelAdmin):
|
||||
"""Admin for credit requests. """
|
||||
search_fields = ("uuid", "username", "course__course_key", "provider__provider_id")
|
||||
readonly_fields = ("uuid",)
|
||||
|
||||
|
||||
admin.site.register(CreditCourse, CreditCourseAdmin)
|
||||
admin.site.register(CreditProvider, CreditProviderAdmin)
|
||||
admin.site.register(CreditEligibility, CreditEligibilityAdmin)
|
||||
admin.site.register(CreditRequest, CreditRequestAdmin)
|
||||
|
||||
@@ -1,794 +0,0 @@
|
||||
"""
|
||||
Contains the APIs for course credit requirements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from util.date_utils import to_timestamp
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from student.models import User
|
||||
from .exceptions import (
|
||||
InvalidCreditRequirements,
|
||||
InvalidCreditCourse,
|
||||
UserIsNotEligible,
|
||||
CreditProviderNotConfigured,
|
||||
RequestAlreadyCompleted,
|
||||
CreditRequestNotFound,
|
||||
InvalidCreditStatus,
|
||||
)
|
||||
from .models import (
|
||||
CreditCourse,
|
||||
CreditProvider,
|
||||
CreditRequirement,
|
||||
CreditRequirementStatus,
|
||||
CreditRequest,
|
||||
CreditEligibility,
|
||||
)
|
||||
from .signature import signature, get_shared_secret_key
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def set_credit_requirements(course_key, requirements):
|
||||
"""
|
||||
Add requirements to given course.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for course
|
||||
requirements(list): List of requirements to be added
|
||||
|
||||
Example:
|
||||
>>> set_credit_requirements(
|
||||
"course-v1-edX-DemoX-1T2015",
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "Assessment 1",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Final Exam",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
},
|
||||
])
|
||||
|
||||
Raises:
|
||||
InvalidCreditRequirements
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
invalid_requirements = _validate_requirements(requirements)
|
||||
if invalid_requirements:
|
||||
invalid_requirements = ", ".join(invalid_requirements)
|
||||
raise InvalidCreditRequirements(invalid_requirements)
|
||||
|
||||
try:
|
||||
credit_course = CreditCourse.get_credit_course(course_key=course_key)
|
||||
except CreditCourse.DoesNotExist:
|
||||
raise InvalidCreditCourse()
|
||||
|
||||
old_requirements = CreditRequirement.get_course_requirements(course_key=course_key)
|
||||
requirements_to_disable = _get_requirements_to_disable(old_requirements, requirements)
|
||||
if requirements_to_disable:
|
||||
CreditRequirement.disable_credit_requirements(requirements_to_disable)
|
||||
|
||||
# update requirement with new order
|
||||
for order, requirement in enumerate(requirements):
|
||||
CreditRequirement.add_or_update_course_requirement(credit_course, requirement, order)
|
||||
|
||||
|
||||
def get_credit_requirements(course_key, namespace=None):
|
||||
"""
|
||||
Get credit eligibility requirements of a given course and namespace.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for course
|
||||
namespace(str): Namespace of requirements
|
||||
|
||||
Example:
|
||||
>>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
|
||||
{
|
||||
requirements =
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "Assessment 1",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Final Exam",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict of requirements in the given namespace
|
||||
|
||||
"""
|
||||
|
||||
requirements = CreditRequirement.get_course_requirements(course_key, namespace)
|
||||
return [
|
||||
{
|
||||
"namespace": requirement.namespace,
|
||||
"name": requirement.name,
|
||||
"display_name": requirement.display_name,
|
||||
"criteria": requirement.criteria
|
||||
}
|
||||
for requirement in requirements
|
||||
]
|
||||
|
||||
|
||||
@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 provider can be configured either with *integration enabled* or not.
|
||||
If automatic integration is disabled, this method will simply return
|
||||
a URL to the credit provider and method set to "GET", so the student can
|
||||
visit the URL and request credit directly. No database record will be created
|
||||
to track these requests.
|
||||
|
||||
If automatic integration *is* enabled, then this will also return the parameters
|
||||
that the user's browser will need to POST to the credit provider.
|
||||
These parameters will be digitally signed using a secret key shared with the credit provider.
|
||||
|
||||
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.
|
||||
CreditProviderNotConfigured: The credit provider has not been configured for this course.
|
||||
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")
|
||||
{
|
||||
"url": "https://credit.example.com/request",
|
||||
"method": "POST",
|
||||
"parameters": {
|
||||
"request_uuid": "557168d0f7664fe59097106c67c3f847",
|
||||
"timestamp": 1434631630,
|
||||
"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",
|
||||
"signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
try:
|
||||
user_eligibility = CreditEligibility.objects.select_related('course').get(
|
||||
username=username,
|
||||
course__course_key=course_key
|
||||
)
|
||||
credit_course = user_eligibility.course
|
||||
credit_provider = credit_course.providers.get(provider_id=provider_id)
|
||||
except (CreditEligibility.DoesNotExist, CreditProvider.DoesNotExist):
|
||||
log.warning(u'User tried to initiate a request for credit, but the user is not eligible for credit')
|
||||
raise UserIsNotEligible
|
||||
|
||||
# Check if we've enabled automatic integration with the credit
|
||||
# provider. If not, we'll show the user a link to a URL
|
||||
# where the user can request credit directly from the provider.
|
||||
# Note that we do NOT track these requests in our database,
|
||||
# since the state would always be "pending" (we never hear back).
|
||||
if not credit_provider.enable_integration:
|
||||
return {
|
||||
"url": credit_provider.provider_url,
|
||||
"method": "GET",
|
||||
"parameters": {}
|
||||
}
|
||||
else:
|
||||
# If automatic credit integration is enabled, then try
|
||||
# to retrieve the shared signature *before* creating the request.
|
||||
# That way, if there's a misconfiguration, we won't have requests
|
||||
# in our system that we know weren't sent to the provider.
|
||||
shared_secret_key = get_shared_secret_key(credit_provider.provider_id)
|
||||
if shared_secret_key is None:
|
||||
msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format(
|
||||
provider_id=credit_provider.provider_id
|
||||
)
|
||||
log.error(msg)
|
||||
raise CreditProviderNotConfigured(msg)
|
||||
|
||||
# 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":
|
||||
log.warning(
|
||||
(
|
||||
u'Cannot initiate credit request because the request with UUID "%s" '
|
||||
u'exists with status "%s"'
|
||||
), credit_request.uuid, credit_request.status
|
||||
)
|
||||
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.get(
|
||||
username=username,
|
||||
requirement__namespace="grade",
|
||||
requirement__name="grade",
|
||||
status="satisfied"
|
||||
).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 = {
|
||||
"request_uuid": credit_request.uuid,
|
||||
"timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)),
|
||||
"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()
|
||||
|
||||
if created:
|
||||
log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid)
|
||||
else:
|
||||
log.info(
|
||||
u'Updated request for credit with UUID "%s" so the user can re-issue the request',
|
||||
credit_request.uuid
|
||||
)
|
||||
|
||||
# Sign the parameters using a secret key we share with the credit provider.
|
||||
parameters["signature"] = signature(parameters, shared_secret_key)
|
||||
|
||||
return {
|
||||
"url": credit_provider.provider_url,
|
||||
"method": "POST",
|
||||
"parameters": parameters
|
||||
}
|
||||
|
||||
|
||||
def update_credit_request_status(request_uuid, provider_id, 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.
|
||||
provider_id (str): Identifier for the credit provider.
|
||||
status (str): Either "approved" or "rejected"
|
||||
|
||||
Returns: None
|
||||
|
||||
Raises:
|
||||
CreditRequestNotFound: No request exists that is associated with the given provider.
|
||||
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, provider__provider_id=provider_id)
|
||||
old_status = request.status
|
||||
request.status = status
|
||||
request.save()
|
||||
|
||||
log.info(
|
||||
u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".',
|
||||
request_uuid, old_status, status, provider_id
|
||||
)
|
||||
except CreditRequest.DoesNotExist:
|
||||
msg = (
|
||||
u'Credit provider with ID "{provider_id}" attempted to '
|
||||
u'update request with UUID "{request_uuid}", but no request '
|
||||
u'with this UUID is associated with the provider.'
|
||||
).format(provider_id=provider_id, request_uuid=request_uuid)
|
||||
log.warning(msg)
|
||||
raise CreditRequestNotFound(msg)
|
||||
|
||||
|
||||
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": 1434631630,
|
||||
"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_credit_requirement_status(course_key, username, namespace=None, name=None):
|
||||
""" Retrieve the user's status for each credit requirement in the course.
|
||||
|
||||
Args:
|
||||
course_key (CourseKey): The identifier for course
|
||||
username (str): The identifier of the user
|
||||
|
||||
Example:
|
||||
>>> get_credit_requirement_status("course-v1-edX-DemoX-1T2015", "john")
|
||||
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "In Course Reverification",
|
||||
"criteria": {},
|
||||
"status": "failed",
|
||||
"status_date": "2015-06-26 07:49:13",
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Proctored Mid Term Exam",
|
||||
"criteria": {},
|
||||
"status": "satisfied",
|
||||
"status_date": "2015-06-26 11:07:42",
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Minimum Passing Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
"status": "failed",
|
||||
"status_date": "2015-06-26 11:07:44",
|
||||
},
|
||||
]
|
||||
|
||||
Returns:
|
||||
list of requirement statuses
|
||||
"""
|
||||
requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
|
||||
requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
|
||||
requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
|
||||
statuses = []
|
||||
for requirement in requirements:
|
||||
requirement_status = requirement_statuses.get(requirement)
|
||||
statuses.append({
|
||||
"namespace": requirement.namespace,
|
||||
"name": requirement.name,
|
||||
"display_name": requirement.display_name,
|
||||
"criteria": requirement.criteria,
|
||||
"status": requirement_status.status if requirement_status else None,
|
||||
"status_date": requirement_status.modified if requirement_status else None,
|
||||
})
|
||||
return statuses
|
||||
|
||||
|
||||
def is_user_eligible_for_credit(username, course_key):
|
||||
"""Returns a boolean indicating if the user is eligible for credit for
|
||||
the given course
|
||||
|
||||
Args:
|
||||
username(str): The identifier for user
|
||||
course_key (CourseKey): The identifier for course
|
||||
|
||||
Returns:
|
||||
True if user is eligible for the course else False
|
||||
"""
|
||||
return CreditEligibility.is_user_eligible_for_credit(course_key, username)
|
||||
|
||||
|
||||
def get_credit_requirement(course_key, namespace, name):
|
||||
"""Returns the requirement of a given course, namespace and name.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for course
|
||||
namespace(str): Namespace of requirement
|
||||
name(str): Name of the requirement
|
||||
|
||||
Returns: dict
|
||||
|
||||
Example:
|
||||
>>> get_credit_requirement_status(
|
||||
"course-v1-edX-DemoX-1T2015", "proctored_exam", "i4x://edX/DemoX/proctoring-block/final_uuid"
|
||||
)
|
||||
{
|
||||
"course_key": "course-v1-edX-DemoX-1T2015"
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "reverification"
|
||||
"criteria": {},
|
||||
}
|
||||
|
||||
"""
|
||||
requirement = CreditRequirement.get_course_requirement(course_key, namespace, name)
|
||||
return {
|
||||
"course_key": requirement.course.course_key,
|
||||
"namespace": requirement.namespace,
|
||||
"name": requirement.name,
|
||||
"display_name": requirement.display_name,
|
||||
"criteria": requirement.criteria
|
||||
} if requirement else None
|
||||
|
||||
|
||||
def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None):
|
||||
"""
|
||||
Update the user's requirement status.
|
||||
|
||||
This will record whether the user satisfied or failed a particular requirement
|
||||
in a course. If the user has satisfied all requirements, the user will be marked
|
||||
as eligible for credit in the course.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
course_key (CourseKey): Identifier for the course associated with the requirement.
|
||||
req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
|
||||
req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)
|
||||
|
||||
Keyword Arguments:
|
||||
status (str): Status of the requirement (either "satisfied" or "failed")
|
||||
reason (dict): Reason of the status
|
||||
|
||||
Example:
|
||||
>>> set_credit_requirement_status(
|
||||
"staff",
|
||||
CourseKey.from_string("course-v1-edX-DemoX-1T2015"),
|
||||
"reverification",
|
||||
"i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
status="satisfied",
|
||||
reason={}
|
||||
)
|
||||
|
||||
"""
|
||||
# Check if we're already eligible for credit.
|
||||
# If so, short-circuit this process.
|
||||
if CreditEligibility.is_user_eligible_for_credit(course_key, username):
|
||||
return
|
||||
|
||||
# Retrieve all credit requirements for the course
|
||||
# We retrieve all of them to avoid making a second query later when
|
||||
# we need to check whether all requirements have been satisfied.
|
||||
reqs = CreditRequirement.get_course_requirements(course_key)
|
||||
|
||||
# Find the requirement we're trying to set
|
||||
req_to_update = next((
|
||||
req for req in reqs
|
||||
if req.namespace == req_namespace
|
||||
and req.name == req_name
|
||||
), None)
|
||||
|
||||
# If we can't find the requirement, then the most likely explanation
|
||||
# is that there was a lag updating the credit requirements after the course
|
||||
# was published. We *could* attempt to create the requirement here,
|
||||
# but that could cause serious performance issues if many users attempt to
|
||||
# lock the row at the same time.
|
||||
# Instead, we skip updating the requirement and log an error.
|
||||
if req_to_update is None:
|
||||
log.error(
|
||||
(
|
||||
u'Could not update credit requirement in course "%s" '
|
||||
u'with namespace "%s" and name "%s" '
|
||||
u'because the requirement does not exist. '
|
||||
u'The user "%s" should have had his/her status updated to "%s".'
|
||||
),
|
||||
unicode(course_key), req_namespace, req_name, username, status
|
||||
)
|
||||
return
|
||||
|
||||
# Update the requirement status
|
||||
CreditRequirementStatus.add_or_update_requirement_status(
|
||||
username, req_to_update, status=status, reason=reason
|
||||
)
|
||||
|
||||
# If we're marking this requirement as "satisfied", there's a chance
|
||||
# that the user has met all eligibility requirements.
|
||||
if status == "satisfied":
|
||||
CreditEligibility.update_eligibility(reqs, username, course_key)
|
||||
|
||||
|
||||
def _get_requirements_to_disable(old_requirements, new_requirements):
|
||||
"""
|
||||
Get the ids of 'CreditRequirement' entries to be disabled that are
|
||||
deleted from the courseware.
|
||||
|
||||
Args:
|
||||
old_requirements(QuerySet): QuerySet of CreditRequirement
|
||||
new_requirements(list): List of requirements being added
|
||||
|
||||
Returns:
|
||||
List of ids of CreditRequirement that are not in new_requirements
|
||||
"""
|
||||
requirements_to_disable = []
|
||||
for old_req in old_requirements:
|
||||
found_flag = False
|
||||
for req in new_requirements:
|
||||
# check if an already added requirement is modified
|
||||
if req["namespace"] == old_req.namespace and req["name"] == old_req.name:
|
||||
found_flag = True
|
||||
break
|
||||
if not found_flag:
|
||||
requirements_to_disable.append(old_req.id)
|
||||
return requirements_to_disable
|
||||
|
||||
|
||||
def _validate_requirements(requirements):
|
||||
"""
|
||||
Validate the requirements.
|
||||
|
||||
Args:
|
||||
requirements(list): List of requirements
|
||||
|
||||
Returns:
|
||||
List of strings of invalid requirements
|
||||
"""
|
||||
invalid_requirements = []
|
||||
for requirement in requirements:
|
||||
invalid_params = []
|
||||
if not requirement.get("namespace"):
|
||||
invalid_params.append("namespace")
|
||||
if not requirement.get("name"):
|
||||
invalid_params.append("name")
|
||||
if not requirement.get("display_name"):
|
||||
invalid_params.append("display_name")
|
||||
if "criteria" not in requirement:
|
||||
invalid_params.append("criteria")
|
||||
|
||||
if invalid_params:
|
||||
invalid_requirements.append(
|
||||
u"{requirement} has missing/invalid parameters: {params}".format(
|
||||
requirement=requirement,
|
||||
params=invalid_params,
|
||||
)
|
||||
)
|
||||
return invalid_requirements
|
||||
|
||||
|
||||
def is_credit_course(course_key):
|
||||
"""API method to check if course is credit or not.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The course identifier string or CourseKey object
|
||||
|
||||
Returns:
|
||||
Bool True if the course is marked credit else False
|
||||
|
||||
"""
|
||||
try:
|
||||
course_key = CourseKey.from_string(unicode(course_key))
|
||||
except InvalidKeyError:
|
||||
return False
|
||||
|
||||
return CreditCourse.is_credit_course(course_key=course_key)
|
||||
|
||||
|
||||
def get_credit_request_status(username, course_key):
|
||||
"""Get the credit request status.
|
||||
|
||||
This function returns the status of credit request of user for given course.
|
||||
It returns the latest request status for the any credit provider.
|
||||
The valid status are 'pending', 'approved' or 'rejected'.
|
||||
|
||||
Args:
|
||||
username(str): The username of user
|
||||
course_key(CourseKey): The course locator key
|
||||
|
||||
Returns:
|
||||
A dictionary of credit request user has made if any
|
||||
|
||||
"""
|
||||
credit_request = CreditRequest.get_user_request_status(username, course_key)
|
||||
if credit_request:
|
||||
credit_status = {
|
||||
"uuid": credit_request.uuid,
|
||||
"timestamp": credit_request.modified,
|
||||
"course_key": credit_request.course.course_key,
|
||||
"provider": {
|
||||
"id": credit_request.provider.provider_id,
|
||||
"display_name": credit_request.provider.display_name
|
||||
},
|
||||
"status": credit_request.status
|
||||
}
|
||||
else:
|
||||
credit_status = {}
|
||||
return credit_status
|
||||
|
||||
|
||||
def _get_duration_and_providers(credit_course):
|
||||
"""Returns the credit providers and eligibility durations.
|
||||
|
||||
The eligibility_duration is the max of the credit duration of
|
||||
all the credit providers of given course.
|
||||
|
||||
Args:
|
||||
credit_course(CreditCourse): The CreditCourse object
|
||||
|
||||
Returns:
|
||||
Tuple of eligibility_duration and credit providers of given course
|
||||
|
||||
"""
|
||||
providers = credit_course.providers.all()
|
||||
seconds_good_for_display = 0
|
||||
providers_list = []
|
||||
for provider in providers:
|
||||
providers_list.append(
|
||||
{
|
||||
"id": provider.provider_id,
|
||||
"display_name": provider.display_name,
|
||||
"eligibility_duration": provider.eligibility_duration,
|
||||
"provider_url": provider.provider_url
|
||||
}
|
||||
)
|
||||
eligibility_duration = int(provider.eligibility_duration) if provider.eligibility_duration else 0
|
||||
seconds_good_for_display = max(eligibility_duration, seconds_good_for_display)
|
||||
|
||||
return seconds_good_for_display, providers_list
|
||||
|
||||
|
||||
def get_credit_eligibility(username):
|
||||
"""
|
||||
Returns the all the eligibility the user has meet.
|
||||
|
||||
Args:
|
||||
username(str): The username of user
|
||||
|
||||
Example:
|
||||
>> get_credit_eligibility('Aamir'):
|
||||
{
|
||||
"edX/DemoX/Demo_Course": {
|
||||
"created_at": "2015-12-21",
|
||||
"providers": [
|
||||
"id": 12,
|
||||
"display_name": "Arizona State University",
|
||||
"eligibility_duration": 60,
|
||||
"provider_url": "http://arizona/provideere/link"
|
||||
],
|
||||
"seconds_good_for_display": 90
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
A dict of eligibilities
|
||||
"""
|
||||
eligibilities = CreditEligibility.get_user_eligibility(username)
|
||||
user_credit_requests = get_credit_requests_for_user(username)
|
||||
request_dict = {}
|
||||
# Change the list to dict for iteration
|
||||
for request in user_credit_requests:
|
||||
request_dict[unicode(request["course_key"])] = request
|
||||
user_eligibilities = {}
|
||||
for eligibility in eligibilities:
|
||||
course_key = eligibility.course.course_key
|
||||
duration, providers_list = _get_duration_and_providers(eligibility.course)
|
||||
user_eligibilities[unicode(course_key)] = {
|
||||
"created_at": eligibility.created,
|
||||
"seconds_good_for_display": duration,
|
||||
"providers": providers_list,
|
||||
}
|
||||
|
||||
# Default status is requirements_meet
|
||||
user_eligibilities[unicode(course_key)]["status"] = "requirements_meet"
|
||||
# If there is some request user has made for this eligibility then update the status
|
||||
if unicode(course_key) in request_dict:
|
||||
user_eligibilities[unicode(course_key)]["status"] = request_dict[unicode(course_key)]["status"]
|
||||
user_eligibilities[unicode(course_key)]["provider"] = request_dict[unicode(course_key)]["provider"]
|
||||
|
||||
return user_eligibilities
|
||||
|
||||
|
||||
def get_purchased_credit_courses(username): # pylint: disable=unused-argument
|
||||
"""
|
||||
Returns the purchased credit courses.
|
||||
|
||||
Args:
|
||||
username(str): Username of the student
|
||||
|
||||
Returns:
|
||||
A dict of courses user has purchased from the credit provider after completion
|
||||
|
||||
"""
|
||||
# TODO: How to track the purchased courses. It requires Will's work for credit provider integration
|
||||
return {}
|
||||
8
openedx/core/djangoapps/credit/api/__init__.py
Normal file
8
openedx/core/djangoapps/credit/api/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Credit Python API.
|
||||
|
||||
This module aggregates the API functions from the eligibility and provider APIs.
|
||||
"""
|
||||
|
||||
from .eligibility import * # pylint: disable=wildcard-import
|
||||
from .provider import * # pylint: disable=wildcard-import
|
||||
383
openedx/core/djangoapps/credit/api/eligibility.py
Normal file
383
openedx/core/djangoapps/credit/api/eligibility.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
APIs for configuring credit eligibility requirements and tracking
|
||||
whether a user has satisfied those requirements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
|
||||
from openedx.core.djangoapps.credit.models import (
|
||||
CreditCourse,
|
||||
CreditRequirement,
|
||||
CreditRequirementStatus,
|
||||
CreditEligibility,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_credit_course(course_key):
|
||||
"""
|
||||
Check whether the course has been configured for credit.
|
||||
|
||||
Args:
|
||||
course_key (CourseKey): Identifier of the course.
|
||||
|
||||
Returns:
|
||||
bool: True iff this is a credit course.
|
||||
|
||||
"""
|
||||
return CreditCourse.is_credit_course(course_key=course_key)
|
||||
|
||||
|
||||
def set_credit_requirements(course_key, requirements):
|
||||
"""
|
||||
Add requirements to given course.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for course
|
||||
requirements(list): List of requirements to be added
|
||||
|
||||
Example:
|
||||
>>> set_credit_requirements(
|
||||
"course-v1-edX-DemoX-1T2015",
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "Assessment 1",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Final Exam",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
},
|
||||
])
|
||||
|
||||
Raises:
|
||||
InvalidCreditRequirements
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
invalid_requirements = _validate_requirements(requirements)
|
||||
if invalid_requirements:
|
||||
invalid_requirements = ", ".join(invalid_requirements)
|
||||
raise InvalidCreditRequirements(invalid_requirements)
|
||||
|
||||
try:
|
||||
credit_course = CreditCourse.get_credit_course(course_key=course_key)
|
||||
except CreditCourse.DoesNotExist:
|
||||
raise InvalidCreditCourse()
|
||||
|
||||
old_requirements = CreditRequirement.get_course_requirements(course_key=course_key)
|
||||
requirements_to_disable = _get_requirements_to_disable(old_requirements, requirements)
|
||||
if requirements_to_disable:
|
||||
CreditRequirement.disable_credit_requirements(requirements_to_disable)
|
||||
|
||||
for order, requirement in enumerate(requirements):
|
||||
CreditRequirement.add_or_update_course_requirement(credit_course, requirement, order)
|
||||
|
||||
|
||||
def get_credit_requirements(course_key, namespace=None):
|
||||
"""
|
||||
Get credit eligibility requirements of a given course and namespace.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for course
|
||||
namespace(str): Namespace of requirements
|
||||
|
||||
Example:
|
||||
>>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
|
||||
{
|
||||
requirements =
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "Assessment 1",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Final Exam",
|
||||
"criteria": {},
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
Dict of requirements in the given namespace
|
||||
|
||||
"""
|
||||
|
||||
requirements = CreditRequirement.get_course_requirements(course_key, namespace)
|
||||
return [
|
||||
{
|
||||
"namespace": requirement.namespace,
|
||||
"name": requirement.name,
|
||||
"display_name": requirement.display_name,
|
||||
"criteria": requirement.criteria
|
||||
}
|
||||
for requirement in requirements
|
||||
]
|
||||
|
||||
|
||||
def is_user_eligible_for_credit(username, course_key):
|
||||
"""
|
||||
Returns a boolean indicating if the user is eligible for credit for
|
||||
the given course
|
||||
|
||||
Args:
|
||||
username(str): The identifier for user
|
||||
course_key (CourseKey): The identifier for course
|
||||
|
||||
Returns:
|
||||
True if user is eligible for the course else False
|
||||
"""
|
||||
return CreditEligibility.is_user_eligible_for_credit(course_key, username)
|
||||
|
||||
|
||||
def get_eligibilities_for_user(username):
|
||||
"""
|
||||
Retrieve all courses for which the user is eligible for credit.
|
||||
|
||||
Arguments:
|
||||
username (unicode): Identifier of the user.
|
||||
|
||||
Example:
|
||||
>>> get_eligibilities_for_user("ron")
|
||||
[
|
||||
{
|
||||
"course_key": "edX/Demo_101/Fall",
|
||||
"deadline": "2015-10-23"
|
||||
},
|
||||
{
|
||||
"course_key": "edX/Demo_201/Spring",
|
||||
"deadline": "2015-11-15"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Returns: list
|
||||
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"course_key": eligibility.course.course_key,
|
||||
"deadline": eligibility.deadline,
|
||||
}
|
||||
for eligibility in CreditEligibility.get_user_eligibilities(username)
|
||||
]
|
||||
|
||||
|
||||
def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None):
|
||||
"""
|
||||
Update the user's requirement status.
|
||||
|
||||
This will record whether the user satisfied or failed a particular requirement
|
||||
in a course. If the user has satisfied all requirements, the user will be marked
|
||||
as eligible for credit in the course.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
course_key (CourseKey): Identifier for the course associated with the requirement.
|
||||
req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
|
||||
req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)
|
||||
|
||||
Keyword Arguments:
|
||||
status (str): Status of the requirement (either "satisfied" or "failed")
|
||||
reason (dict): Reason of the status
|
||||
|
||||
Example:
|
||||
>>> set_credit_requirement_status(
|
||||
"staff",
|
||||
CourseKey.from_string("course-v1-edX-DemoX-1T2015"),
|
||||
"reverification",
|
||||
"i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
status="satisfied",
|
||||
reason={}
|
||||
)
|
||||
|
||||
"""
|
||||
# Check if we're already eligible for credit.
|
||||
# If so, short-circuit this process.
|
||||
if CreditEligibility.is_user_eligible_for_credit(course_key, username):
|
||||
log.info(
|
||||
u'Skipping update of credit requirement with namespace "%s" '
|
||||
u'and name "%s" because the user "%s" is already eligible for credit '
|
||||
u'in the course "%s".',
|
||||
req_namespace, req_name, username, course_key
|
||||
)
|
||||
return
|
||||
|
||||
# Retrieve all credit requirements for the course
|
||||
# We retrieve all of them to avoid making a second query later when
|
||||
# we need to check whether all requirements have been satisfied.
|
||||
reqs = CreditRequirement.get_course_requirements(course_key)
|
||||
|
||||
# Find the requirement we're trying to set
|
||||
req_to_update = next((
|
||||
req for req in reqs
|
||||
if req.namespace == req_namespace
|
||||
and req.name == req_name
|
||||
), None)
|
||||
|
||||
# If we can't find the requirement, then the most likely explanation
|
||||
# is that there was a lag updating the credit requirements after the course
|
||||
# was published. We *could* attempt to create the requirement here,
|
||||
# but that could cause serious performance issues if many users attempt to
|
||||
# lock the row at the same time.
|
||||
# Instead, we skip updating the requirement and log an error.
|
||||
if req_to_update is None:
|
||||
log.error(
|
||||
(
|
||||
u'Could not update credit requirement in course "%s" '
|
||||
u'with namespace "%s" and name "%s" '
|
||||
u'because the requirement does not exist. '
|
||||
u'The user "%s" should have had his/her status updated to "%s".'
|
||||
),
|
||||
unicode(course_key), req_namespace, req_name, username, status
|
||||
)
|
||||
return
|
||||
|
||||
# Update the requirement status
|
||||
CreditRequirementStatus.add_or_update_requirement_status(
|
||||
username, req_to_update, status=status, reason=reason
|
||||
)
|
||||
|
||||
# If we're marking this requirement as "satisfied", there's a chance
|
||||
# that the user has met all eligibility requirements.
|
||||
if status == "satisfied":
|
||||
CreditEligibility.update_eligibility(reqs, username, course_key)
|
||||
|
||||
|
||||
def get_credit_requirement_status(course_key, username, namespace=None, name=None):
|
||||
""" Retrieve the user's status for each credit requirement in the course.
|
||||
|
||||
Args:
|
||||
course_key (CourseKey): The identifier for course
|
||||
username (str): The identifier of the user
|
||||
|
||||
Example:
|
||||
>>> get_credit_requirement_status("course-v1-edX-DemoX-1T2015", "john")
|
||||
|
||||
[
|
||||
{
|
||||
"namespace": "reverification",
|
||||
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
|
||||
"display_name": "In Course Reverification",
|
||||
"criteria": {},
|
||||
"status": "failed",
|
||||
"status_date": "2015-06-26 07:49:13",
|
||||
},
|
||||
{
|
||||
"namespace": "proctored_exam",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Proctored Mid Term Exam",
|
||||
"criteria": {},
|
||||
"status": "satisfied",
|
||||
"status_date": "2015-06-26 11:07:42",
|
||||
},
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "i4x://edX/DemoX/proctoring-block/final_uuid",
|
||||
"display_name": "Minimum Passing Grade",
|
||||
"criteria": {"min_grade": 0.8},
|
||||
"status": "failed",
|
||||
"status_date": "2015-06-26 11:07:44",
|
||||
},
|
||||
]
|
||||
|
||||
Returns:
|
||||
list of requirement statuses
|
||||
"""
|
||||
requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
|
||||
requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
|
||||
requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
|
||||
statuses = []
|
||||
for requirement in requirements:
|
||||
requirement_status = requirement_statuses.get(requirement)
|
||||
statuses.append({
|
||||
"namespace": requirement.namespace,
|
||||
"name": requirement.name,
|
||||
"display_name": requirement.display_name,
|
||||
"criteria": requirement.criteria,
|
||||
"status": requirement_status.status if requirement_status else None,
|
||||
"status_date": requirement_status.modified if requirement_status else None,
|
||||
})
|
||||
return statuses
|
||||
|
||||
|
||||
def _get_requirements_to_disable(old_requirements, new_requirements):
|
||||
"""
|
||||
Get the ids of 'CreditRequirement' entries to be disabled that are
|
||||
deleted from the courseware.
|
||||
|
||||
Args:
|
||||
old_requirements(QuerySet): QuerySet of CreditRequirement
|
||||
new_requirements(list): List of requirements being added
|
||||
|
||||
Returns:
|
||||
List of ids of CreditRequirement that are not in new_requirements
|
||||
"""
|
||||
requirements_to_disable = []
|
||||
for old_req in old_requirements:
|
||||
found_flag = False
|
||||
for req in new_requirements:
|
||||
# check if an already added requirement is modified
|
||||
if req["namespace"] == old_req.namespace and req["name"] == old_req.name:
|
||||
found_flag = True
|
||||
break
|
||||
if not found_flag:
|
||||
requirements_to_disable.append(old_req.id)
|
||||
return requirements_to_disable
|
||||
|
||||
|
||||
def _validate_requirements(requirements):
|
||||
"""
|
||||
Validate the requirements.
|
||||
|
||||
Args:
|
||||
requirements(list): List of requirements
|
||||
|
||||
Returns:
|
||||
List of strings of invalid requirements
|
||||
"""
|
||||
invalid_requirements = []
|
||||
for requirement in requirements:
|
||||
invalid_params = []
|
||||
if not requirement.get("namespace"):
|
||||
invalid_params.append("namespace")
|
||||
if not requirement.get("name"):
|
||||
invalid_params.append("name")
|
||||
if not requirement.get("display_name"):
|
||||
invalid_params.append("display_name")
|
||||
if "criteria" not in requirement:
|
||||
invalid_params.append("criteria")
|
||||
|
||||
if invalid_params:
|
||||
invalid_requirements.append(
|
||||
u"{requirement} has missing/invalid parameters: {params}".format(
|
||||
requirement=requirement,
|
||||
params=invalid_params,
|
||||
)
|
||||
)
|
||||
return invalid_requirements
|
||||
349
openedx/core/djangoapps/credit/api/provider.py
Normal file
349
openedx/core/djangoapps/credit/api/provider.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
API for initiating and tracking requests for credit from a provider.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from util.date_utils import to_timestamp
|
||||
|
||||
from student.models import User
|
||||
from openedx.core.djangoapps.credit.exceptions import (
|
||||
UserIsNotEligible,
|
||||
CreditProviderNotConfigured,
|
||||
RequestAlreadyCompleted,
|
||||
CreditRequestNotFound,
|
||||
InvalidCreditStatus,
|
||||
)
|
||||
from openedx.core.djangoapps.credit.models import (
|
||||
CreditProvider,
|
||||
CreditRequirementStatus,
|
||||
CreditRequest,
|
||||
CreditEligibility,
|
||||
)
|
||||
from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_credit_providers():
|
||||
"""
|
||||
Retrieve all available credit providers.
|
||||
|
||||
Example:
|
||||
>>> get_credit_providers()
|
||||
[
|
||||
{
|
||||
"id": "hogwarts",
|
||||
"display_name": "Hogwarts School of Witchcraft and Wizardry"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Returns: list
|
||||
"""
|
||||
return CreditProvider.get_credit_providers()
|
||||
|
||||
|
||||
@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 provider can be configured either with *integration enabled* or not.
|
||||
If automatic integration is disabled, this method will simply return
|
||||
a URL to the credit provider and method set to "GET", so the student can
|
||||
visit the URL and request credit directly. No database record will be created
|
||||
to track these requests.
|
||||
|
||||
If automatic integration *is* enabled, then this will also return the parameters
|
||||
that the user's browser will need to POST to the credit provider.
|
||||
These parameters will be digitally signed using a secret key shared with the credit provider.
|
||||
|
||||
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.
|
||||
CreditProviderNotConfigured: The credit provider has not been configured for this course.
|
||||
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")
|
||||
{
|
||||
"url": "https://credit.example.com/request",
|
||||
"method": "POST",
|
||||
"parameters": {
|
||||
"request_uuid": "557168d0f7664fe59097106c67c3f847",
|
||||
"timestamp": 1434631630,
|
||||
"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",
|
||||
"signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
try:
|
||||
user_eligibility = CreditEligibility.objects.select_related('course').get(
|
||||
username=username,
|
||||
course__course_key=course_key
|
||||
)
|
||||
credit_course = user_eligibility.course
|
||||
credit_provider = CreditProvider.objects.get(provider_id=provider_id)
|
||||
except CreditEligibility.DoesNotExist:
|
||||
log.warning(
|
||||
u'User "%s" tried to initiate a request for credit in course "%s", '
|
||||
u'but the user is not eligible for credit',
|
||||
username, course_key
|
||||
)
|
||||
raise UserIsNotEligible
|
||||
except CreditProvider.DoesNotExist:
|
||||
log.error(u'Credit provider with ID "%s" has not been configured.', provider_id)
|
||||
raise CreditProviderNotConfigured
|
||||
|
||||
# Check if we've enabled automatic integration with the credit
|
||||
# provider. If not, we'll show the user a link to a URL
|
||||
# where the user can request credit directly from the provider.
|
||||
# Note that we do NOT track these requests in our database,
|
||||
# since the state would always be "pending" (we never hear back).
|
||||
if not credit_provider.enable_integration:
|
||||
return {
|
||||
"url": credit_provider.provider_url,
|
||||
"method": "GET",
|
||||
"parameters": {}
|
||||
}
|
||||
else:
|
||||
# If automatic credit integration is enabled, then try
|
||||
# to retrieve the shared signature *before* creating the request.
|
||||
# That way, if there's a misconfiguration, we won't have requests
|
||||
# in our system that we know weren't sent to the provider.
|
||||
shared_secret_key = get_shared_secret_key(credit_provider.provider_id)
|
||||
if shared_secret_key is None:
|
||||
msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format(
|
||||
provider_id=credit_provider.provider_id
|
||||
)
|
||||
log.error(msg)
|
||||
raise CreditProviderNotConfigured(msg)
|
||||
|
||||
# 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":
|
||||
log.warning(
|
||||
(
|
||||
u'Cannot initiate credit request because the request with UUID "%s" '
|
||||
u'exists with status "%s"'
|
||||
), credit_request.uuid, credit_request.status
|
||||
)
|
||||
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.get(
|
||||
username=username,
|
||||
requirement__namespace="grade",
|
||||
requirement__name="grade",
|
||||
status="satisfied"
|
||||
).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 = {
|
||||
"request_uuid": credit_request.uuid,
|
||||
"timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)),
|
||||
"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()
|
||||
|
||||
if created:
|
||||
log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid)
|
||||
else:
|
||||
log.info(
|
||||
u'Updated request for credit with UUID "%s" so the user can re-issue the request',
|
||||
credit_request.uuid
|
||||
)
|
||||
|
||||
# Sign the parameters using a secret key we share with the credit provider.
|
||||
parameters["signature"] = signature(parameters, shared_secret_key)
|
||||
|
||||
return {
|
||||
"url": credit_provider.provider_url,
|
||||
"method": "POST",
|
||||
"parameters": parameters
|
||||
}
|
||||
|
||||
|
||||
def update_credit_request_status(request_uuid, provider_id, 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.
|
||||
provider_id (str): Identifier for the credit provider.
|
||||
status (str): Either "approved" or "rejected"
|
||||
|
||||
Returns: None
|
||||
|
||||
Raises:
|
||||
CreditRequestNotFound: No request exists that is associated with the given provider.
|
||||
InvalidCreditStatus: The status is not either "approved" or "rejected".
|
||||
|
||||
"""
|
||||
if status not in [CreditRequest.REQUEST_STATUS_APPROVED, CreditRequest.REQUEST_STATUS_REJECTED]:
|
||||
raise InvalidCreditStatus
|
||||
|
||||
try:
|
||||
request = CreditRequest.objects.get(uuid=request_uuid, provider__provider_id=provider_id)
|
||||
old_status = request.status
|
||||
request.status = status
|
||||
request.save()
|
||||
|
||||
log.info(
|
||||
u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".',
|
||||
request_uuid, old_status, status, provider_id
|
||||
)
|
||||
except CreditRequest.DoesNotExist:
|
||||
msg = (
|
||||
u'Credit provider with ID "{provider_id}" attempted to '
|
||||
u'update request with UUID "{request_uuid}", but no request '
|
||||
u'with this UUID is associated with the provider.'
|
||||
).format(provider_id=provider_id, request_uuid=request_uuid)
|
||||
log.warning(msg)
|
||||
raise CreditRequestNotFound(msg)
|
||||
|
||||
|
||||
def get_credit_requests_for_user(username):
|
||||
"""
|
||||
Retrieve the status of a credit request.
|
||||
|
||||
Returns either "pending", "approved", 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": 1434631630,
|
||||
"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_credit_request_status(username, course_key):
|
||||
"""Get the credit request status.
|
||||
|
||||
This function returns the status of credit request of user for given course.
|
||||
It returns the latest request status for the any credit provider.
|
||||
The valid status are 'pending', 'approved' or 'rejected'.
|
||||
|
||||
Args:
|
||||
username(str): The username of user
|
||||
course_key(CourseKey): The course locator key
|
||||
|
||||
Returns:
|
||||
A dictionary of credit request user has made if any
|
||||
|
||||
"""
|
||||
credit_request = CreditRequest.get_user_request_status(username, course_key)
|
||||
return {
|
||||
"uuid": credit_request.uuid,
|
||||
"timestamp": credit_request.modified,
|
||||
"course_key": credit_request.course.course_key,
|
||||
"provider": {
|
||||
"id": credit_request.provider.provider_id,
|
||||
"display_name": credit_request.provider.display_name
|
||||
},
|
||||
"status": credit_request.status
|
||||
} if credit_request else {}
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Deleting field 'CreditProvider.eligibility_duration'
|
||||
db.delete_column('credit_creditprovider', 'eligibility_duration')
|
||||
|
||||
# Removing M2M table for field providers on 'CreditCourse'
|
||||
db.delete_table(db.shorten_name('credit_creditcourse_providers'))
|
||||
|
||||
# Adding field 'CreditEligibility.deadline'
|
||||
db.add_column('credit_crediteligibility', 'deadline',
|
||||
self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2016, 6, 26, 0, 0)),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Adding field 'CreditProvider.eligibility_duration'
|
||||
db.add_column('credit_creditprovider', 'eligibility_duration',
|
||||
self.gf('django.db.models.fields.PositiveIntegerField')(default=31556970),
|
||||
keep_default=False)
|
||||
|
||||
# Adding M2M table for field providers on 'CreditCourse'
|
||||
m2m_table_name = db.shorten_name('credit_creditcourse_providers')
|
||||
db.create_table(m2m_table_name, (
|
||||
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
|
||||
('creditcourse', models.ForeignKey(orm['credit.creditcourse'], null=False)),
|
||||
('creditprovider', models.ForeignKey(orm['credit.creditprovider'], null=False))
|
||||
))
|
||||
db.create_unique(m2m_table_name, ['creditcourse_id', 'creditprovider_id'])
|
||||
|
||||
# Deleting field 'CreditEligibility.deadline'
|
||||
db.delete_column('credit_crediteligibility', 'deadline')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'credit.creditcourse': {
|
||||
'Meta': {'object_name': 'CreditCourse'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'credit.crediteligibility': {
|
||||
'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2016, 6, 26, 0, 0)'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
},
|
||||
'credit.creditprovider': {
|
||||
'Meta': {'object_name': 'CreditProvider'},
|
||||
'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
|
||||
'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'})
|
||||
},
|
||||
'credit.creditrequest': {
|
||||
'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'parameters': ('jsonfield.fields.JSONField', [], {}),
|
||||
'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'})
|
||||
},
|
||||
'credit.creditrequirement': {
|
||||
'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'},
|
||||
'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'criteria': ('jsonfield.fields.JSONField', [], {}),
|
||||
'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'})
|
||||
},
|
||||
'credit.creditrequirementstatus': {
|
||||
'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'},
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
|
||||
'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
},
|
||||
'credit.historicalcreditrequest': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'parameters': ('jsonfield.fields.JSONField', [], {}),
|
||||
'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
|
||||
},
|
||||
'credit.historicalcreditrequirementstatus': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'},
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
|
||||
'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['credit']
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding field 'CreditProvider.provider_status_url'
|
||||
db.add_column('credit_creditprovider', 'provider_status_url',
|
||||
self.gf('django.db.models.fields.URLField')(default='', max_length=200),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'CreditProvider.provider_status_url'
|
||||
db.delete_column('credit_creditprovider', 'provider_status_url')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'credit.creditcourse': {
|
||||
'Meta': {'object_name': 'CreditCourse'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'credit.crediteligibility': {
|
||||
'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2016, 6, 26, 0, 0)'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
},
|
||||
'credit.creditprovider': {
|
||||
'Meta': {'object_name': 'CreditProvider'},
|
||||
'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
|
||||
'provider_status_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}),
|
||||
'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'})
|
||||
},
|
||||
'credit.creditrequest': {
|
||||
'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'parameters': ('jsonfield.fields.JSONField', [], {}),
|
||||
'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'})
|
||||
},
|
||||
'credit.creditrequirement': {
|
||||
'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'},
|
||||
'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'criteria': ('jsonfield.fields.JSONField', [], {}),
|
||||
'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'})
|
||||
},
|
||||
'credit.creditrequirementstatus': {
|
||||
'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'},
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
|
||||
'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
},
|
||||
'credit.historicalcreditrequest': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'},
|
||||
'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'parameters': ('jsonfield.fields.JSONField', [], {}),
|
||||
'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
|
||||
},
|
||||
'credit.historicalcreditrequirementstatus': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'},
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
|
||||
'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['credit']
|
||||
@@ -6,9 +6,15 @@ Credit courses allow students to receive university credit for
|
||||
successful completion of a course on EdX
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
|
||||
import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.dispatch import receiver
|
||||
from django.db import models, transaction, IntegrityError
|
||||
from django.core.validators import RegexValidator
|
||||
from simple_history.models import HistoricalRecords
|
||||
@@ -23,7 +29,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreditProvider(TimeStampedModel):
|
||||
"""This model represents an institution that can grant credit for a course.
|
||||
"""
|
||||
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
|
||||
@@ -78,14 +85,55 @@ class CreditProvider(TimeStampedModel):
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
provider_status_url = models.URLField(
|
||||
default="",
|
||||
help_text=ugettext_lazy(
|
||||
"URL from the credit provider where the user can check the status "
|
||||
"of his or her request for credit. This is displayed to students "
|
||||
"*after* they have requested credit."
|
||||
)
|
||||
)
|
||||
|
||||
CREDIT_PROVIDERS_CACHE_KEY = "credit.providers.list"
|
||||
|
||||
@classmethod
|
||||
def get_credit_providers(cls):
|
||||
"""
|
||||
Retrieve a list of all credit providers, represented
|
||||
as dictionaries.
|
||||
"""
|
||||
# Attempt to retrieve the credit provider list from the cache
|
||||
# The cache key is invalidated when the provider list is updated
|
||||
# (a post-save signal handler on the CreditProvider model)
|
||||
# This doesn't happen very often, so we would expect a *very* high
|
||||
# cache hit rate.
|
||||
providers = cache.get(cls.CREDIT_PROVIDERS_CACHE_KEY)
|
||||
|
||||
# Cache miss: construct the provider list and save it in the cache
|
||||
if providers is None:
|
||||
providers = [
|
||||
{
|
||||
"id": provider.provider_id,
|
||||
"display_name": provider.display_name,
|
||||
"status_url": provider.provider_status_url,
|
||||
}
|
||||
for provider in CreditProvider.objects.filter(active=True)
|
||||
]
|
||||
cache.set(cls.CREDIT_PROVIDERS_CACHE_KEY, providers)
|
||||
|
||||
return providers
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of the credit provider. """
|
||||
return self.provider_id
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=CreditProvider)
|
||||
@receiver(models.signals.post_delete, sender=CreditProvider)
|
||||
def invalidate_provider_cache(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""Invalidate the cache of credit providers. """
|
||||
cache.delete(CreditProvider.CREDIT_PROVIDERS_CACHE_KEY)
|
||||
|
||||
|
||||
class CreditCourse(models.Model):
|
||||
"""
|
||||
@@ -94,23 +142,35 @@ class CreditCourse(models.Model):
|
||||
|
||||
course_key = CourseKeyField(max_length=255, db_index=True, unique=True)
|
||||
enabled = models.BooleanField(default=False)
|
||||
providers = models.ManyToManyField(CreditProvider)
|
||||
|
||||
CREDIT_COURSES_CACHE_KEY = "credit.courses.set"
|
||||
|
||||
@classmethod
|
||||
def is_credit_course(cls, course_key):
|
||||
"""Check that given course is credit or not.
|
||||
"""
|
||||
Check whether the course has been configured for credit.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The course identifier
|
||||
course_key (CourseKey): Identifier of the course.
|
||||
|
||||
Returns:
|
||||
Bool True if the course is marked credit else False
|
||||
bool: True iff this is a credit course.
|
||||
|
||||
"""
|
||||
return cls.objects.filter(course_key=course_key, enabled=True).exists()
|
||||
credit_courses = cache.get(cls.CREDIT_COURSES_CACHE_KEY)
|
||||
if credit_courses is None:
|
||||
credit_courses = set(
|
||||
unicode(course.course_key)
|
||||
for course in cls.objects.filter(enabled=True)
|
||||
)
|
||||
cache.set(cls.CREDIT_COURSES_CACHE_KEY, credit_courses)
|
||||
|
||||
return unicode(course_key) in credit_courses
|
||||
|
||||
@classmethod
|
||||
def get_credit_course(cls, course_key):
|
||||
"""Get the credit course if exists for the given 'course_key'.
|
||||
"""
|
||||
Get the credit course if exists for the given 'course_key'.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The course identifier
|
||||
@@ -123,6 +183,17 @@ class CreditCourse(models.Model):
|
||||
"""
|
||||
return cls.objects.get(course_key=course_key, enabled=True)
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of the credit course. """
|
||||
return unicode(self.course_key)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=CreditCourse)
|
||||
@receiver(models.signals.post_delete, sender=CreditCourse)
|
||||
def invalidate_credit_courses_cache(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""Invalidate the cache of credit courses. """
|
||||
cache.delete(CreditCourse.CREDIT_COURSES_CACHE_KEY)
|
||||
|
||||
|
||||
class CreditRequirement(TimeStampedModel):
|
||||
"""
|
||||
@@ -227,7 +298,8 @@ class CreditRequirement(TimeStampedModel):
|
||||
|
||||
@classmethod
|
||||
def get_course_requirement(cls, course_key, namespace, name):
|
||||
"""Get credit requirement of a given course.
|
||||
"""
|
||||
Get credit requirement of a given course.
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The identifier for a course
|
||||
@@ -286,7 +358,8 @@ class CreditRequirementStatus(TimeStampedModel):
|
||||
|
||||
@classmethod
|
||||
def get_statuses(cls, requirements, username):
|
||||
""" Get credit requirement statuses of given requirement and username
|
||||
"""
|
||||
Get credit requirement statuses of given requirement and username
|
||||
|
||||
Args:
|
||||
requirement(CreditRequirement): The identifier for a requirement
|
||||
@@ -300,7 +373,8 @@ class CreditRequirementStatus(TimeStampedModel):
|
||||
@classmethod
|
||||
@transaction.commit_on_success
|
||||
def add_or_update_requirement_status(cls, username, requirement, status="satisfied", reason=None):
|
||||
"""Add credit requirement status for given username.
|
||||
"""
|
||||
Add credit requirement status for given username.
|
||||
|
||||
Args:
|
||||
username(str): Username of the user
|
||||
@@ -328,21 +402,23 @@ class CreditEligibility(TimeStampedModel):
|
||||
username = models.CharField(max_length=255, db_index=True)
|
||||
course = models.ForeignKey(CreditCourse, related_name="eligibilities")
|
||||
|
||||
# Deadline for when credit eligibility will expire.
|
||||
# Once eligibility expires, users will no longer be able to purchase
|
||||
# or request credit.
|
||||
# We save the deadline as a database field just in case
|
||||
# we need to override the deadline for particular students.
|
||||
deadline = models.DateTimeField(
|
||||
default=lambda: (
|
||||
datetime.datetime.now(pytz.UTC) + datetime.timedelta(
|
||||
days=getattr(settings, "CREDIT_ELIGIBILITY_EXPIRATION_DAYS", 365)
|
||||
)
|
||||
),
|
||||
help_text=ugettext_lazy("Deadline for purchasing and requesting credit.")
|
||||
)
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
unique_together = ('username', 'course')
|
||||
|
||||
@classmethod
|
||||
def get_user_eligibility(cls, username):
|
||||
"""Returns the eligibilities of given user.
|
||||
|
||||
Args:
|
||||
username(str): Username of the user
|
||||
|
||||
Returns:
|
||||
CreditEligibility queryset for the user
|
||||
|
||||
"""
|
||||
return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers')
|
||||
verbose_name_plural = "Credit eligibilities"
|
||||
|
||||
@classmethod
|
||||
def update_eligibility(cls, requirements, username, course_key):
|
||||
@@ -378,9 +454,28 @@ class CreditEligibility(TimeStampedModel):
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_user_eligibilities(cls, username):
|
||||
"""
|
||||
Returns the eligibilities of given user.
|
||||
|
||||
Args:
|
||||
username(str): Username of the user
|
||||
|
||||
Returns:
|
||||
CreditEligibility queryset for the user
|
||||
|
||||
"""
|
||||
return cls.objects.filter(
|
||||
username=username,
|
||||
course__enabled=True,
|
||||
deadline__gt=datetime.datetime.now(pytz.UTC)
|
||||
).select_related('course')
|
||||
|
||||
@classmethod
|
||||
def is_user_eligible_for_credit(cls, course_key, username):
|
||||
"""Check if the given user is eligible for the provided credit course
|
||||
"""
|
||||
Check if the given user is eligible for the provided credit course
|
||||
|
||||
Args:
|
||||
course_key(CourseKey): The course identifier
|
||||
@@ -389,7 +484,19 @@ class CreditEligibility(TimeStampedModel):
|
||||
Returns:
|
||||
Bool True if the user eligible for credit course else False
|
||||
"""
|
||||
return cls.objects.filter(course__course_key=course_key, username=username).exists()
|
||||
return cls.objects.filter(
|
||||
course__course_key=course_key,
|
||||
course__enabled=True,
|
||||
username=username,
|
||||
deadline__gt=datetime.datetime.now(pytz.UTC),
|
||||
).exists()
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of the credit eligibility. """
|
||||
return u"{user}, {course}".format(
|
||||
user=self.username,
|
||||
course=self.course.course_key,
|
||||
)
|
||||
|
||||
|
||||
class CreditRequest(TimeStampedModel):
|
||||
@@ -476,7 +583,8 @@ class CreditRequest(TimeStampedModel):
|
||||
|
||||
@classmethod
|
||||
def get_user_request_status(cls, username, course_key):
|
||||
"""Returns the latest credit request of user against the given course.
|
||||
"""
|
||||
Returns the latest credit request of user against the given course.
|
||||
|
||||
Args:
|
||||
username(str): The username of requesting user
|
||||
@@ -492,3 +600,11 @@ class CreditRequest(TimeStampedModel):
|
||||
).select_related('course', 'provider').latest()
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of a credit request."""
|
||||
return u"{course}, {provider}, {status}".format(
|
||||
course=self.course.course_key,
|
||||
provider=self.provider.provider_id, # pylint: disable=no-member
|
||||
status=self.status,
|
||||
)
|
||||
|
||||
@@ -39,24 +39,24 @@ def listen_for_grade_calculation(sender, username, grade_summary, course_key, de
|
||||
kwargs : None
|
||||
|
||||
"""
|
||||
from openedx.core.djangoapps.credit.api import (
|
||||
is_credit_course, get_credit_requirement, set_credit_requirement_status
|
||||
)
|
||||
# This needs to be imported here to avoid a circular dependency
|
||||
# that can cause syncdb to fail.
|
||||
from openedx.core.djangoapps.credit import api
|
||||
|
||||
course_id = CourseKey.from_string(unicode(course_key))
|
||||
is_credit = is_credit_course(course_id)
|
||||
is_credit = api.is_credit_course(course_id)
|
||||
if is_credit:
|
||||
requirement = get_credit_requirement(course_id, 'grade', 'grade')
|
||||
if requirement:
|
||||
criteria = requirement.get('criteria')
|
||||
requirements = api.get_credit_requirements(course_id, namespace='grade')
|
||||
if requirements:
|
||||
criteria = requirements[0].get('criteria')
|
||||
if criteria:
|
||||
min_grade = criteria.get('min_grade')
|
||||
if grade_summary['percent'] >= min_grade:
|
||||
reason_dict = {'final_grade': grade_summary['percent']}
|
||||
set_credit_requirement_status(
|
||||
api.set_credit_requirement_status(
|
||||
username, course_id, 'grade', 'grade', status="satisfied", reason=reason_dict
|
||||
)
|
||||
elif deadline and deadline < timezone.now():
|
||||
set_credit_requirement_status(
|
||||
api.set_credit_requirement_status(
|
||||
username, course_id, 'grade', 'grade', status="failed", reason={}
|
||||
)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""
|
||||
Tests for the API functions in the credit app.
|
||||
"""
|
||||
import unittest
|
||||
import datetime
|
||||
import ddt
|
||||
import pytz
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.db import connection, transaction
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
@@ -30,11 +27,7 @@ from openedx.core.djangoapps.credit.models import (
|
||||
CreditRequirementStatus,
|
||||
CreditEligibility
|
||||
)
|
||||
from student.models import CourseEnrollment
|
||||
from student.views import _create_credit_availability_message
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
|
||||
@@ -53,6 +46,7 @@ class CreditApiTestBase(TestCase):
|
||||
PROVIDER_ID = "hogwarts"
|
||||
PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
|
||||
PROVIDER_URL = "https://credit.example.com/request"
|
||||
PROVIDER_STATUS_URL = "https://credit.example.com/status"
|
||||
|
||||
def setUp(self, **kwargs):
|
||||
super(CreditApiTestBase, self).setUp()
|
||||
@@ -62,14 +56,13 @@ class CreditApiTestBase(TestCase):
|
||||
"""Mark the course as a credit """
|
||||
credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled)
|
||||
|
||||
# Associate a credit provider with the course.
|
||||
credit_provider = CreditProvider.objects.create(
|
||||
CreditProvider.objects.create(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
display_name=self.PROVIDER_NAME,
|
||||
provider_url=self.PROVIDER_URL,
|
||||
provider_status_url=self.PROVIDER_STATUS_URL,
|
||||
enable_integration=True,
|
||||
)
|
||||
credit_course.providers.add(credit_provider)
|
||||
|
||||
return credit_course
|
||||
|
||||
@@ -223,34 +216,41 @@ class CreditRequirementApiTests(CreditApiTestBase):
|
||||
is_eligible = api.is_user_eligible_for_credit('abc', credit_course.course_key)
|
||||
self.assertFalse(is_eligible)
|
||||
|
||||
def test_get_credit_requirement(self):
|
||||
self.add_credit_course()
|
||||
requirements = [
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
},
|
||||
}
|
||||
]
|
||||
requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
|
||||
self.assertIsNone(requirement)
|
||||
def test_eligibility_expired(self):
|
||||
# Configure a credit eligibility that expired yesterday
|
||||
credit_course = self.add_credit_course()
|
||||
CreditEligibility.objects.create(
|
||||
course=credit_course,
|
||||
username="staff",
|
||||
deadline=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
|
||||
)
|
||||
|
||||
expected_requirement = {
|
||||
"course_key": self.course_key,
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
}
|
||||
}
|
||||
api.set_credit_requirements(self.course_key, requirements)
|
||||
requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
|
||||
self.assertIsNotNone(requirement)
|
||||
self.assertEqual(requirement, expected_requirement)
|
||||
# The user should NOT be eligible for credit
|
||||
is_eligible = api.is_user_eligible_for_credit("staff", credit_course.course_key)
|
||||
self.assertFalse(is_eligible)
|
||||
|
||||
# The eligibility should NOT show up in the user's list of eligibilities
|
||||
eligibilities = api.get_eligibilities_for_user("staff")
|
||||
self.assertEqual(eligibilities, [])
|
||||
|
||||
def test_eligibility_disabled_course(self):
|
||||
# Configure a credit eligibility for a disabled course
|
||||
credit_course = self.add_credit_course()
|
||||
credit_course.enabled = False
|
||||
credit_course.save()
|
||||
|
||||
CreditEligibility.objects.create(
|
||||
course=credit_course,
|
||||
username="staff",
|
||||
)
|
||||
|
||||
# The user should NOT be eligible for credit
|
||||
is_eligible = api.is_user_eligible_for_credit("staff", credit_course.course_key)
|
||||
self.assertFalse(is_eligible)
|
||||
|
||||
# The eligibility should NOT show up in the user's list of eligibilities
|
||||
eligibilities = api.get_eligibilities_for_user("staff")
|
||||
self.assertEqual(eligibilities, [])
|
||||
|
||||
def test_set_credit_requirement_status(self):
|
||||
self.add_credit_course()
|
||||
@@ -427,6 +427,25 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
|
||||
# credit requirement that the user has satisfied (minimum grade)
|
||||
self._configure_credit()
|
||||
|
||||
def test_get_credit_providers(self):
|
||||
# The provider should show up in the list
|
||||
result = api.get_credit_providers()
|
||||
self.assertEqual(result, [
|
||||
{
|
||||
"id": self.PROVIDER_ID,
|
||||
"display_name": self.PROVIDER_NAME,
|
||||
"status_url": self.PROVIDER_STATUS_URL,
|
||||
}
|
||||
])
|
||||
|
||||
# Disable the provider; it should be hidden from the list
|
||||
provider = CreditProvider.objects.get()
|
||||
provider.active = False
|
||||
provider.save()
|
||||
|
||||
result = api.get_credit_providers()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_credit_request(self):
|
||||
# Initiate a credit request
|
||||
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
|
||||
@@ -611,7 +630,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
|
||||
self.assertEqual(requests, [])
|
||||
|
||||
def _configure_credit(self):
|
||||
|
||||
"""
|
||||
Configure a credit course and its requirements.
|
||||
|
||||
@@ -643,123 +661,3 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
|
||||
"""Check the user's credit status. """
|
||||
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
|
||||
self.assertEqual(statuses[0]["status"], expected_status)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class CreditMessagesTests(ModuleStoreTestCase, CreditApiTestBase):
|
||||
"""
|
||||
Test dashboard messages of credit course.
|
||||
"""
|
||||
|
||||
FINAL_GRADE = 0.8
|
||||
|
||||
def setUp(self):
|
||||
super(CreditMessagesTests, self).setUp()
|
||||
self.student = UserFactory()
|
||||
self.student.set_password('test') # pylint: disable=no-member
|
||||
self.student.save() # pylint: disable=no-member
|
||||
|
||||
self.client.login(username=self.student.username, password='test')
|
||||
# New Course
|
||||
self.course = CourseFactory.create()
|
||||
self.enrollment = CourseEnrollment.enroll(self.student, self.course.id)
|
||||
|
||||
def _set_creditcourse(self):
|
||||
"""
|
||||
Mark the course to credit
|
||||
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.first_provider = CreditProvider.objects.create(
|
||||
provider_id="ASU",
|
||||
display_name="Arizona State University",
|
||||
provider_url="google.com",
|
||||
enable_integration=True
|
||||
) # pylint: disable=attribute-defined-outside-init
|
||||
self.second_provider = CreditProvider.objects.create(
|
||||
provider_id="MIT",
|
||||
display_name="Massachusetts Institute of Technology",
|
||||
provider_url="MIT.com",
|
||||
enable_integration=True
|
||||
) # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
self.credit_course = CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=attribute-defined-outside-init
|
||||
self.credit_course.providers.add(self.first_provider)
|
||||
self.credit_course.providers.add(self.second_provider)
|
||||
|
||||
def _set_user_eligible(self, credit_course, username):
|
||||
"""
|
||||
Mark the user eligible for credit for the given credit course.
|
||||
"""
|
||||
self.eligibility = CreditEligibility.objects.create(username=username, course=credit_course) # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
def test_user_request_status(self):
|
||||
request_status = api.get_credit_request_status(self.student.username, self.course.id)
|
||||
self.assertEqual(len(request_status), 0)
|
||||
|
||||
def test_credit_messages(self):
|
||||
self._set_creditcourse()
|
||||
|
||||
requirement = CreditRequirement.objects.create(
|
||||
course=self.credit_course,
|
||||
namespace="grade",
|
||||
name="grade",
|
||||
active=True
|
||||
)
|
||||
status = CreditRequirementStatus.objects.create(
|
||||
username=self.student.username,
|
||||
requirement=requirement,
|
||||
)
|
||||
status.status = "satisfied"
|
||||
status.reason = {"final_grade": self.FINAL_GRADE}
|
||||
status.save()
|
||||
|
||||
self._set_user_eligible(self.credit_course, self.student.username)
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<b>Congratulations</b> {}, You have meet requirements for credit.".format(
|
||||
self.student.get_full_name() # pylint: disable=no-member
|
||||
)
|
||||
)
|
||||
|
||||
api.create_credit_request(self.course.id, self.first_provider.provider_id, self.student.username)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
self.assertContains(
|
||||
response,
|
||||
'Thank you, your payment is complete, your credit is processing. '
|
||||
'Please see {provider_link} for more information.'.format(
|
||||
provider_link='<a href="#" target="_blank">{provider_name}</a>'.format(
|
||||
provider_name=self.first_provider.display_name
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_query_counts(self):
|
||||
# This check the number of queries executed while rendering the
|
||||
# credit message to display on the dashboard.
|
||||
# - 1 query: Check the user's eligibility.
|
||||
# - 1 query: Get the user credit requests.
|
||||
|
||||
self._set_creditcourse()
|
||||
|
||||
requirement = CreditRequirement.objects.create(
|
||||
course=self.credit_course,
|
||||
namespace="grade",
|
||||
name="grade",
|
||||
active=True
|
||||
)
|
||||
status = CreditRequirementStatus.objects.create(
|
||||
username=self.student.username,
|
||||
requirement=requirement,
|
||||
)
|
||||
status.status = "satisfied"
|
||||
status.reason = {"final_grade": self.FINAL_GRADE}
|
||||
status.save()
|
||||
|
||||
with self.assertNumQueries(2):
|
||||
enrollment_dict = {unicode(self.course.id): self.course}
|
||||
_create_credit_availability_message(
|
||||
enrollment_dict, self.student
|
||||
)
|
||||
|
||||
@@ -41,19 +41,17 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
|
||||
self.client.login(username=self.user.username, password=self.user.password)
|
||||
|
||||
# Enable the course for credit
|
||||
credit_course = CreditCourse.objects.create(
|
||||
CreditCourse.objects.create(
|
||||
course_key=self.course.id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Configure a credit provider for the course
|
||||
credit_provider = CreditProvider.objects.create(
|
||||
CreditProvider.objects.create(
|
||||
provider_id="ASU",
|
||||
enable_integration=True,
|
||||
provider_url="https://credit.example.com/request",
|
||||
)
|
||||
credit_course.providers.add(credit_provider)
|
||||
credit_course.save()
|
||||
|
||||
requirements = [{
|
||||
"namespace": "grade",
|
||||
|
||||
@@ -73,13 +73,11 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
|
||||
)
|
||||
|
||||
# Configure a credit provider for the course
|
||||
credit_provider = CreditProvider.objects.create(
|
||||
CreditProvider.objects.create(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
enable_integration=True,
|
||||
provider_url=self.PROVIDER_URL,
|
||||
)
|
||||
credit_course.providers.add(credit_provider)
|
||||
credit_course.save()
|
||||
|
||||
# Add a single credit requirement (final grade)
|
||||
requirement = CreditRequirement.objects.create(
|
||||
@@ -256,11 +254,8 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
|
||||
other_provider_id = "other_provider"
|
||||
other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d"
|
||||
|
||||
# Create an additional credit provider and associate it with the course.
|
||||
credit_course = CreditCourse.objects.get(course_key=self.COURSE_KEY)
|
||||
credit_provider = CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
|
||||
credit_course.providers.add(credit_provider)
|
||||
credit_course.save()
|
||||
# Create an additional credit provider
|
||||
CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
|
||||
|
||||
# Initiate a credit request with the first provider
|
||||
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
|
||||
|
||||
Reference in New Issue
Block a user