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:
@@ -1830,20 +1830,27 @@ class LanguageProficiency(models.Model):
|
||||
|
||||
|
||||
class CourseEnrollmentAttribute(models.Model):
|
||||
"""Represents Student's enrollment record for Credit Course.
|
||||
|
||||
This is populated when the user's order for a credit seat is fulfilled.
|
||||
"""
|
||||
enrollment = models.ForeignKey(CourseEnrollment)
|
||||
Provide additional information about the user's enrollment.
|
||||
"""
|
||||
enrollment = models.ForeignKey(CourseEnrollment, related_name="attributes")
|
||||
namespace = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("Namespace of enrollment attribute e.g. credit")
|
||||
help_text=_("Namespace of enrollment attribute")
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("Name of the enrollment attribute e.g. provider_id")
|
||||
help_text=_("Name of the enrollment attribute")
|
||||
)
|
||||
value = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("Value of the enrollment attribute e.g. ASU")
|
||||
help_text=_("Value of the enrollment attribute")
|
||||
)
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of the attribute. """
|
||||
return u"{namespace}:{name}, {value}".format(
|
||||
namespace=self.namespace,
|
||||
name=self.name,
|
||||
value=self.value,
|
||||
)
|
||||
|
||||
207
common/djangoapps/student/tests/test_credit.py
Normal file
207
common/djangoapps/student/tests/test_credit.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Tests for credit courses on the student dashboard.
|
||||
"""
|
||||
import unittest
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.models import CourseEnrollmentAttribute
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider, CreditEligibility
|
||||
from openedx.core.djangoapps.credit import api as credit_api
|
||||
|
||||
|
||||
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
|
||||
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY,
|
||||
})
|
||||
class CreditCourseDashboardTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for credit courses on the student dashboard.
|
||||
"""
|
||||
|
||||
USERNAME = "ron"
|
||||
PASSWORD = "mobiliarbus"
|
||||
|
||||
PROVIDER_ID = "hogwarts"
|
||||
PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
|
||||
PROVIDER_STATUS_URL = "http://credit.example.com/status"
|
||||
|
||||
def setUp(self):
|
||||
"""Create a course and an enrollment. """
|
||||
super(CreditCourseDashboardTest, self).setUp()
|
||||
|
||||
# Create a user and log in
|
||||
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
|
||||
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
self.assertTrue(result, msg="Could not log in")
|
||||
|
||||
# Create a course and configure it as a credit course
|
||||
self.course = CourseFactory()
|
||||
CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=no-member
|
||||
|
||||
# Configure a credit provider
|
||||
CreditProvider.objects.create(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
display_name=self.PROVIDER_NAME,
|
||||
provider_status_url=self.PROVIDER_STATUS_URL,
|
||||
enable_integration=True,
|
||||
)
|
||||
|
||||
# Configure a single credit requirement (minimum passing grade)
|
||||
credit_api.set_credit_requirements(
|
||||
self.course.id, # pylint: disable=no-member
|
||||
[
|
||||
{
|
||||
"namespace": "grade",
|
||||
"name": "grade",
|
||||
"display_name": "Final Grade",
|
||||
"criteria": {
|
||||
"min_grade": 0.8
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Enroll the user in the course as "verified"
|
||||
self.enrollment = CourseEnrollmentFactory(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
mode="verified"
|
||||
)
|
||||
|
||||
def test_not_eligible_for_credit(self):
|
||||
# The user is not yet eligible for credit, so no additional information should be displayed on the dashboard.
|
||||
response = self._load_dashboard()
|
||||
self.assertNotContains(response, "credit")
|
||||
|
||||
def test_eligible_for_credit(self):
|
||||
# Simulate that the user has completed the only requirement in the course
|
||||
# so the user is eligible for credit.
|
||||
self._make_eligible()
|
||||
|
||||
# The user should have the option to purchase credit
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-eligibility-msg")
|
||||
self.assertContains(response, "purchase-credit-btn")
|
||||
|
||||
# Move the eligibility deadline so it's within 30 days
|
||||
eligibility = CreditEligibility.objects.get(username=self.USERNAME)
|
||||
eligibility.deadline = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=29)
|
||||
eligibility.save()
|
||||
|
||||
# The user should still have the option to purchase credit,
|
||||
# but there should also be a message urging the user to purchase soon.
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-eligibility-msg")
|
||||
self.assertContains(response, "purchase-credit-btn")
|
||||
self.assertContains(response, "purchase credit for this course expires")
|
||||
|
||||
def test_purchased_credit(self):
|
||||
# Simulate that the user has purchased credit, but has not
|
||||
# yet initiated a request to the credit provider
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
|
||||
# Expect that the user's status is "pending"
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-request-pending-msg")
|
||||
|
||||
def test_purchased_credit_and_request_pending(self):
|
||||
# Simulate that the user has purchased credit and initiated a request,
|
||||
# but we haven't yet heard back from the credit provider.
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
self._initiate_request()
|
||||
|
||||
# Expect that the user's status is "pending"
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-request-pending-msg")
|
||||
|
||||
def test_purchased_credit_and_request_approved(self):
|
||||
# Simulate that the user has purchased credit and initiated a request,
|
||||
# and had that request approved by the credit provider
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
request_uuid = self._initiate_request()
|
||||
self._set_request_status(request_uuid, "approved")
|
||||
|
||||
# Expect that the user's status is "approved"
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-request-approved-msg")
|
||||
|
||||
def test_purchased_credit_and_request_rejected(self):
|
||||
# Simulate that the user has purchased credit and initiated a request,
|
||||
# and had that request rejected by the credit provider
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
request_uuid = self._initiate_request()
|
||||
self._set_request_status(request_uuid, "rejected")
|
||||
|
||||
# Expect that the user's status is "approved"
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-request-rejected-msg")
|
||||
|
||||
def test_credit_status_error(self):
|
||||
# Simulate an error condition: the user has a credit enrollment
|
||||
# but no enrollment attribute indicating which provider the user
|
||||
# purchased credit from.
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
CourseEnrollmentAttribute.objects.all().delete()
|
||||
|
||||
# Expect an error message
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-error-msg")
|
||||
|
||||
def _load_dashboard(self):
|
||||
"""Load the student dashboard and return the HttpResponse. """
|
||||
return self.client.get(reverse("dashboard"))
|
||||
|
||||
def _make_eligible(self):
|
||||
"""Make the user eligible for credit in the course. """
|
||||
credit_api.set_credit_requirement_status(
|
||||
self.USERNAME,
|
||||
self.course.id, # pylint: disable=no-member
|
||||
"grade", "grade",
|
||||
status="satisfied",
|
||||
reason={
|
||||
"final_grade": 0.95
|
||||
}
|
||||
)
|
||||
|
||||
def _purchase_credit(self):
|
||||
"""Purchase credit from a provider in the course. """
|
||||
self.enrollment.mode = "credit"
|
||||
self.enrollment.save() # pylint: disable=no-member
|
||||
|
||||
CourseEnrollmentAttribute.objects.create(
|
||||
enrollment=self.enrollment,
|
||||
namespace="credit",
|
||||
name="provider_id",
|
||||
value=self.PROVIDER_ID,
|
||||
)
|
||||
|
||||
def _initiate_request(self):
|
||||
"""Initiate a request for credit from a provider. """
|
||||
request = credit_api.create_credit_request(
|
||||
self.course.id, # pylint: disable=no-member
|
||||
self.PROVIDER_ID,
|
||||
self.USERNAME
|
||||
)
|
||||
return request["parameters"]["request_uuid"]
|
||||
|
||||
def _set_request_status(self, uuid, status):
|
||||
"""Set the status of a request for credit, simulating the notification from the provider. """
|
||||
credit_api.update_credit_request_status(uuid, self.PROVIDER_ID, status)
|
||||
@@ -51,7 +51,7 @@ from course_modes.models import CourseMode
|
||||
from shoppingcart.api import order_history
|
||||
from student.models import (
|
||||
Registration, UserProfile, PendingNameChange,
|
||||
PendingEmailChange, CourseEnrollment, unique_id_for_user,
|
||||
PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user,
|
||||
CourseEnrollmentAllowed, UserStanding, LoginFailures,
|
||||
create_comments_service_user, PasswordHistory, UserSignupSource,
|
||||
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
|
||||
@@ -124,7 +124,6 @@ from notification_prefs.views import enable_notifications
|
||||
|
||||
# Note that this lives in openedx, so this dependency should be refactored.
|
||||
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
|
||||
from openedx.core.djangoapps.credit.api import get_credit_eligibility, get_purchased_credit_courses
|
||||
|
||||
|
||||
log = logging.getLogger("edx.student")
|
||||
@@ -531,8 +530,6 @@ def dashboard(request):
|
||||
for course, __ in course_enrollment_pairs:
|
||||
enrolled_courses_dict[unicode(course.id)] = course
|
||||
|
||||
credit_messages = _create_credit_availability_message(enrolled_courses_dict, user)
|
||||
|
||||
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
|
||||
|
||||
message = ""
|
||||
@@ -638,7 +635,6 @@ def dashboard(request):
|
||||
|
||||
context = {
|
||||
'enrollment_message': enrollment_message,
|
||||
'credit_messages': credit_messages,
|
||||
'course_enrollment_pairs': course_enrollment_pairs,
|
||||
'course_optouts': course_optouts,
|
||||
'message': message,
|
||||
@@ -647,6 +643,7 @@ def dashboard(request):
|
||||
'show_courseware_links_for': show_courseware_links_for,
|
||||
'all_course_modes': course_mode_info,
|
||||
'cert_statuses': cert_statuses,
|
||||
'credit_statuses': _credit_statuses(user, course_enrollment_pairs),
|
||||
'show_email_settings_for': show_email_settings_for,
|
||||
'reverifications': reverifications,
|
||||
'verification_status': verification_status,
|
||||
@@ -703,47 +700,6 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
|
||||
)
|
||||
|
||||
|
||||
def _create_credit_availability_message(enrolled_courses_dict, user): # pylint: disable=invalid-name
|
||||
"""Builds a dict of credit availability for courses.
|
||||
|
||||
Construct a for courses user has completed and has not purchased credit
|
||||
from the credit provider yet.
|
||||
|
||||
Args:
|
||||
course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
|
||||
user (User): User object.
|
||||
|
||||
Returns:
|
||||
A dict of courses user is eligible for credit.
|
||||
|
||||
"""
|
||||
user_eligibilities = get_credit_eligibility(user.username)
|
||||
user_purchased_credit = get_purchased_credit_courses(user.username)
|
||||
|
||||
eligibility_messages = {}
|
||||
for course_id, eligibility in user_eligibilities.iteritems():
|
||||
if course_id not in user_purchased_credit:
|
||||
duration = eligibility["seconds_good_for_display"]
|
||||
curr_time = timezone.now()
|
||||
validity_till = eligibility["created_at"] + timedelta(seconds=duration)
|
||||
if validity_till > curr_time:
|
||||
diff = validity_till - curr_time
|
||||
urgent = diff.days <= 30
|
||||
eligibility_messages[course_id] = {
|
||||
"user_id": user.id,
|
||||
"course_id": course_id,
|
||||
"course_name": enrolled_courses_dict[course_id].display_name,
|
||||
"providers": eligibility["providers"],
|
||||
"status": eligibility["status"],
|
||||
"provider": eligibility.get("provider"),
|
||||
"urgent": urgent,
|
||||
"user_full_name": user.get_full_name(),
|
||||
"expiry": validity_till
|
||||
}
|
||||
|
||||
return eligibility_messages
|
||||
|
||||
|
||||
def _get_recently_enrolled_courses(course_enrollment_pairs):
|
||||
"""Checks to see if the student has recently enrolled in courses.
|
||||
|
||||
@@ -793,6 +749,124 @@ def _update_email_opt_in(request, org):
|
||||
preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
|
||||
|
||||
|
||||
def _credit_statuses(user, course_enrollment_pairs):
|
||||
"""
|
||||
Retrieve the status for credit courses.
|
||||
|
||||
A credit course is a course for which a user can purchased
|
||||
college credit. The current flow is:
|
||||
|
||||
1. User becomes eligible for credit (submits verifications, passes the course, etc.)
|
||||
2. User purchases credit from a particular credit provider.
|
||||
3. User requests credit from the provider, usually creating an account on the provider's site.
|
||||
4. The credit provider notifies us whether the user's request for credit has been accepted or rejected.
|
||||
|
||||
The dashboard is responsible for communicating the user's state in this flow.
|
||||
|
||||
Arguments:
|
||||
user (User): The currently logged-in user.
|
||||
course_enrollment_pairs (list): List of (Course, CourseEnrollment) tuples.
|
||||
|
||||
Returns: dict
|
||||
|
||||
The returned dictionary has keys that are `CourseKey`s and values that
|
||||
are dictionaries with:
|
||||
|
||||
* eligible (bool): True if the user is eligible for credit in this course.
|
||||
* deadline (datetime): The deadline for purchasing and requesting credit for this course.
|
||||
* purchased (bool): Whether the user has purchased credit for this course.
|
||||
* provider_name (string): The display name of the credit provider.
|
||||
* provider_status_url (string): A URL the user can visit to check on their credit request status.
|
||||
* request_status (string): Either "pending", "approved", or "rejected"
|
||||
* error (bool): If true, an unexpected error occurred when retrieving the credit status,
|
||||
so the user should contact the support team.
|
||||
|
||||
Example:
|
||||
>>> _credit_statuses(user, course_enrollment_pairs)
|
||||
{
|
||||
CourseKey.from_string("edX/DemoX/Demo_Course"): {
|
||||
"course_key": "edX/DemoX/Demo_Course",
|
||||
"eligible": True,
|
||||
"deadline": 2015-11-23 00:00:00 UTC,
|
||||
"purchased": True,
|
||||
"provider_name": "Hogwarts",
|
||||
"provider_status_url": "http://example.com/status",
|
||||
"request_status": "pending",
|
||||
"error": False
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
from openedx.core.djangoapps.credit import api as credit_api
|
||||
|
||||
request_status_by_course = {
|
||||
request["course_key"]: request["status"]
|
||||
for request in credit_api.get_credit_requests_for_user(user.username)
|
||||
}
|
||||
|
||||
credit_enrollments = {
|
||||
course.id: enrollment
|
||||
for course, enrollment in course_enrollment_pairs
|
||||
if enrollment.mode == "credit"
|
||||
}
|
||||
|
||||
# When a user purchases credit in a course, the user's enrollment
|
||||
# mode is set to "credit" and an enrollment attribute is set
|
||||
# with the ID of the credit provider. We retrieve *all* such attributes
|
||||
# here to minimize the number of database queries.
|
||||
purchased_credit_providers = {
|
||||
attribute.enrollment.course_id: attribute.value
|
||||
for attribute in CourseEnrollmentAttribute.objects.filter(
|
||||
namespace="credit",
|
||||
name="provider_id",
|
||||
enrollment__in=credit_enrollments.values()
|
||||
).select_related("enrollment")
|
||||
}
|
||||
|
||||
provider_info_by_id = {
|
||||
provider["id"]: provider
|
||||
for provider in credit_api.get_credit_providers()
|
||||
}
|
||||
|
||||
statuses = {}
|
||||
for eligibility in credit_api.get_eligibilities_for_user(user.username):
|
||||
course_key = eligibility["course_key"]
|
||||
status = {
|
||||
"course_key": unicode(course_key),
|
||||
"eligible": True,
|
||||
"deadline": eligibility["deadline"],
|
||||
"purchased": course_key in credit_enrollments,
|
||||
"provider_name": None,
|
||||
"provider_status_url": None,
|
||||
"request_status": request_status_by_course.get(course_key),
|
||||
"error": False,
|
||||
}
|
||||
|
||||
# If the user has purchased credit, then include information about the credit
|
||||
# provider from which the user purchased credit.
|
||||
# We retrieve the provider's ID from the an "enrollment attribute" set on the user's
|
||||
# enrollment when the user's order for credit is fulfilled by the E-Commerce service.
|
||||
if status["purchased"]:
|
||||
provider_id = purchased_credit_providers.get(course_key)
|
||||
if provider_id is None:
|
||||
status["error"] = True
|
||||
log.error(
|
||||
u"Could not find credit provider associated with credit enrollment "
|
||||
u"for user %s in course %s. The user will not be able to see his or her "
|
||||
u"credit request status on the student dashboard. This attribute should "
|
||||
u"have been set when the user purchased credit in the course.",
|
||||
user.id, course_key
|
||||
)
|
||||
else:
|
||||
provider_info = provider_info_by_id.get(provider_id, {})
|
||||
status["provider_name"] = provider_info.get("display_name")
|
||||
status["provider_status_url"] = provider_info.get("status_url")
|
||||
|
||||
statuses[course_key] = status
|
||||
|
||||
return statuses
|
||||
|
||||
|
||||
@require_POST
|
||||
@commit_on_success_with_read_committed
|
||||
def change_enrollment(request, check_access=True):
|
||||
|
||||
@@ -615,13 +615,11 @@ class TestCourseGrader(TestSubmittingProblems):
|
||||
)
|
||||
|
||||
# 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",
|
||||
|
||||
17
lms/static/js/dashboard/credit.js
Normal file
17
lms/static/js/dashboard/credit.js
Normal file
@@ -0,0 +1,17 @@
|
||||
(function($, analytics) {
|
||||
'use strict';
|
||||
|
||||
$(document).ready(function() {
|
||||
// Fire analytics events when the "purchase credit" button is clicked
|
||||
$(".purchase-credit-btn").on("click", function(event) {
|
||||
var courseKey = $(event.target).data("course-key");
|
||||
analytics.track(
|
||||
"edx.bi.credit.clicked_purchase_credit",
|
||||
{
|
||||
category: "credit",
|
||||
label: courseKey
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
})(jQuery, window.analytics);
|
||||
@@ -531,8 +531,17 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.credit-eligibility-msg {
|
||||
@include float(left);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.purchase_credit {
|
||||
float: right;
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
.purchase-credit-btn {
|
||||
@extend %btn-pl-yellow-base;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -76,6 +76,7 @@ from django.core.urlresolvers import reverse
|
||||
% for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs):
|
||||
<% show_courseware_link = (course.id in show_courseware_links_for) %>
|
||||
<% cert_status = cert_statuses.get(course.id) %>
|
||||
<% credit_status = credit_statuses.get(course.id) %>
|
||||
<% show_email_settings = (course.id in show_email_settings_for) %>
|
||||
<% course_mode_info = all_course_modes.get(course.id) %>
|
||||
<% show_refund_option = (course.id in show_refund_option_for) %>
|
||||
@@ -83,8 +84,7 @@ from django.core.urlresolvers import reverse
|
||||
<% is_course_blocked = (course.id in block_courses) %>
|
||||
<% course_verification_status = verification_status_by_course.get(course.id, {}) %>
|
||||
<% course_requirements = courses_requirements_not_met.get(course.id) %>
|
||||
<% credit_message = credit_messages.get(unicode(course.id)) %>
|
||||
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, credit_message=credit_message, user=user" />
|
||||
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user" />
|
||||
% endfor
|
||||
|
||||
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, credit_message" />
|
||||
<%page args="course, enrollment, show_courseware_link, cert_status, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" />
|
||||
|
||||
<%!
|
||||
import urllib
|
||||
@@ -275,9 +275,9 @@ from student.helpers import (
|
||||
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
|
||||
% endif
|
||||
|
||||
% if credit_message:
|
||||
<%include file='_dashboard_credit_information.html' args='credit_message=credit_message'/>
|
||||
% endif
|
||||
% if credit_status is not None:
|
||||
<%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/>
|
||||
% endif
|
||||
|
||||
% if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked:
|
||||
<div class="message message-status wrapper-message-primary is-shown">
|
||||
|
||||
65
lms/templates/dashboard/_dashboard_credit_info.html
Normal file
65
lms/templates/dashboard/_dashboard_credit_info.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<%page args="credit_status" />
|
||||
<%!
|
||||
import datetime
|
||||
import pytz
|
||||
from django.utils.translation import ugettext as _
|
||||
from util.date_utils import get_default_time_display
|
||||
%>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
% if credit_status["eligible"]:
|
||||
<div class="message message-status is-shown credit-message">
|
||||
% if credit_status["error"]:
|
||||
<p class="message-copy credit-error-msg">
|
||||
${_("An error occurred with this transaction. For help, contact {support_email}.").format(
|
||||
support_email=u'<a href="mailto:{address}">{address}</a>'.format(
|
||||
address=settings.DEFAULT_FEEDBACK_EMAIL
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
% elif not credit_status["purchased"]:
|
||||
<p class="credit-eligibility-msg">
|
||||
% if credit_status["deadline"] < datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30):
|
||||
${_("The opportunity to purchase credit for this course expires on {deadline}. You've worked hard - don't miss out!").format(deadline=get_default_time_display(credit_status["deadline"]))}
|
||||
% else:
|
||||
${_("Congratulations - you have met the requirements for credit in this course!")}
|
||||
% endif
|
||||
</p>
|
||||
<div class="purchase_credit">
|
||||
## TODO: set the URL for the link to the provider selection page on the E-Commerce service
|
||||
<a class="btn purchase-credit-btn" href="#" target="_blank" data-course-key="${credit_status["course_key"]}">${_("Purchase Course Credit")}</a>
|
||||
</div>
|
||||
% elif credit_status["request_status"] in [None, "pending"]:
|
||||
<p class="message-copy credit-request-pending-msg">
|
||||
${_("Thanks for your payment! We are currently processing your course credit. You'll see a message here when the transaction is complete. For more information, see {provider_link}.").format(
|
||||
provider_link=u'<a href="{provider_url}">{provider_name}</a>'.format(
|
||||
provider_url=credit_status["provider_status_url"],
|
||||
provider_name=credit_status["provider_name"],
|
||||
)
|
||||
)
|
||||
}
|
||||
</p>
|
||||
% elif credit_status["request_status"] == "approved":
|
||||
<p class="message-copy credit-request-approved-msg">
|
||||
${_("Congratulations - you have received credit for this course! For more information, see {provider_link}.").format(
|
||||
provider_link=u'<a href="{provider_url}">{provider_name}</a>'.format(
|
||||
provider_url=credit_status["provider_status_url"],
|
||||
provider_name=credit_status["provider_name"],
|
||||
)
|
||||
)
|
||||
}
|
||||
</p>
|
||||
% elif credit_status["request_status"] == "rejected":
|
||||
<p class="message-copy credit-request-rejected-msg">
|
||||
${_("{provider_name} has declined your request for course credit. For more information, contact {provider_link}.").format(
|
||||
provider_name=credit_status["provider_name"],
|
||||
provider_link=u'<a href="{provider_url}">{provider_name}</a>'.format(
|
||||
provider_url=credit_status["provider_status_url"],
|
||||
provider_name=credit_status["provider_name"],
|
||||
)
|
||||
)
|
||||
}
|
||||
</p>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
@@ -1,70 +0,0 @@
|
||||
<%page args="credit_message" />
|
||||
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from course_modes.models import CourseMode
|
||||
from util.date_utils import get_default_time_display
|
||||
%>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
<%block name="js_extra" args="credit_message">
|
||||
<%static:js group='credit_wv'/>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'X-CSRFToken': $.cookie('csrftoken')
|
||||
},
|
||||
dataType: 'json'
|
||||
});
|
||||
$(".purchase-credit-btn").click(function() {
|
||||
var data = {
|
||||
user_id: "${credit_message['user_id']}",
|
||||
course_id: "${credit_message['course_id']}"
|
||||
};
|
||||
Logger.log('edx.credit.shared', data);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<div class="message message-status is-shown">
|
||||
<p>
|
||||
% if credit_message["status"] == "requirements_meet":
|
||||
<span>
|
||||
% if credit_message["urgent"]:
|
||||
${_("{username}, your eligibility for credit expires on {expiry}. Don't miss out!").format(
|
||||
username=credit_message["user_full_name"],
|
||||
expiry=get_default_time_display(credit_message["expiry"])
|
||||
)
|
||||
}
|
||||
% else:
|
||||
${_("{congrats} {username}, You have meet requirements for credit.").format(
|
||||
congrats="<b>Congratulations</b>",
|
||||
username=credit_message["user_full_name"]
|
||||
)
|
||||
}
|
||||
% endif
|
||||
</span>
|
||||
<span class="purchase_credit"> <a class="btn purchase-credit-btn" href="" target="_blank">${_("Purchase Credit")}</a> </span>
|
||||
|
||||
% elif credit_message["status"] == "pending":
|
||||
${_("Thank you, your payment is complete, your credit is processing. Please see {provider_link} for more information.").format(
|
||||
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
|
||||
)
|
||||
}
|
||||
% elif credit_message["status"] == "approved":
|
||||
${_("Thank you, your credit is approved. Please see {provider_link} for more information.").format(
|
||||
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
|
||||
)
|
||||
}
|
||||
% elif credit_message["status"] == "rejected":
|
||||
${_("Your credit has been denied. Please contact {provider_link} for more information.").format(
|
||||
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
|
||||
)
|
||||
}
|
||||
% endif
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -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