Commit bfe01605 by Will Daly

Merge pull request #8700 from edx/will/remove-provider-course-m2m-relation

Credit eligibility/provider refactor
parents 4526b929 e2acf3ab
...@@ -1830,20 +1830,27 @@ class LanguageProficiency(models.Model): ...@@ -1830,20 +1830,27 @@ class LanguageProficiency(models.Model):
class CourseEnrollmentAttribute(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( namespace = models.CharField(
max_length=255, max_length=255,
help_text=_("Namespace of enrollment attribute e.g. credit") help_text=_("Namespace of enrollment attribute")
) )
name = models.CharField( name = models.CharField(
max_length=255, max_length=255,
help_text=_("Name of the enrollment attribute e.g. provider_id") help_text=_("Name of the enrollment attribute")
) )
value = models.CharField( value = models.CharField(
max_length=255, 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,
) )
"""
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 ...@@ -51,7 +51,7 @@ from course_modes.models import CourseMode
from shoppingcart.api import order_history from shoppingcart.api import order_history
from student.models import ( from student.models import (
Registration, UserProfile, PendingNameChange, Registration, UserProfile, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user, PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user,
CourseEnrollmentAllowed, UserStanding, LoginFailures, CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory, UserSignupSource, create_comments_service_user, PasswordHistory, UserSignupSource,
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED) DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
...@@ -124,7 +124,6 @@ from notification_prefs.views import enable_notifications ...@@ -124,7 +124,6 @@ from notification_prefs.views import enable_notifications
# Note that this lives in openedx, so this dependency should be refactored. # 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.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") log = logging.getLogger("edx.student")
...@@ -531,8 +530,6 @@ def dashboard(request): ...@@ -531,8 +530,6 @@ def dashboard(request):
for course, __ in course_enrollment_pairs: for course, __ in course_enrollment_pairs:
enrolled_courses_dict[unicode(course.id)] = course 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) course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
message = "" message = ""
...@@ -638,7 +635,6 @@ def dashboard(request): ...@@ -638,7 +635,6 @@ def dashboard(request):
context = { context = {
'enrollment_message': enrollment_message, 'enrollment_message': enrollment_message,
'credit_messages': credit_messages,
'course_enrollment_pairs': course_enrollment_pairs, 'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts, 'course_optouts': course_optouts,
'message': message, 'message': message,
...@@ -647,6 +643,7 @@ def dashboard(request): ...@@ -647,6 +643,7 @@ def dashboard(request):
'show_courseware_links_for': show_courseware_links_for, 'show_courseware_links_for': show_courseware_links_for,
'all_course_modes': course_mode_info, 'all_course_modes': course_mode_info,
'cert_statuses': cert_statuses, 'cert_statuses': cert_statuses,
'credit_statuses': _credit_statuses(user, course_enrollment_pairs),
'show_email_settings_for': show_email_settings_for, 'show_email_settings_for': show_email_settings_for,
'reverifications': reverifications, 'reverifications': reverifications,
'verification_status': verification_status, 'verification_status': verification_status,
...@@ -703,47 +700,6 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes): ...@@ -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): def _get_recently_enrolled_courses(course_enrollment_pairs):
"""Checks to see if the student has recently enrolled in courses. """Checks to see if the student has recently enrolled in courses.
...@@ -793,6 +749,124 @@ def _update_email_opt_in(request, org): ...@@ -793,6 +749,124 @@ def _update_email_opt_in(request, org):
preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) 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 @require_POST
@commit_on_success_with_read_committed @commit_on_success_with_read_committed
def change_enrollment(request, check_access=True): def change_enrollment(request, check_access=True):
......
...@@ -641,13 +641,11 @@ class TestCourseGrader(TestSubmittingProblems): ...@@ -641,13 +641,11 @@ class TestCourseGrader(TestSubmittingProblems):
) )
# Configure a credit provider for the course # Configure a credit provider for the course
credit_provider = CreditProvider.objects.create( CreditProvider.objects.create(
provider_id="ASU", provider_id="ASU",
enable_integration=True, enable_integration=True,
provider_url="https://credit.example.com/request", provider_url="https://credit.example.com/request",
) )
credit_course.providers.add(credit_provider)
credit_course.save()
requirements = [{ requirements = [{
"namespace": "grade", "namespace": "grade",
......
(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 @@ ...@@ -531,8 +531,17 @@
padding: 0; padding: 0;
} }
.credit-eligibility-msg {
@include float(left);
margin-top: 10px;
}
.purchase_credit { .purchase_credit {
float: right; @include float(right);
}
.purchase-credit-btn {
@extend %btn-pl-yellow-base;
} }
.message { .message {
......
...@@ -76,6 +76,7 @@ from django.core.urlresolvers import reverse ...@@ -76,6 +76,7 @@ from django.core.urlresolvers import reverse
% for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs): % for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs):
<% show_courseware_link = (course.id in show_courseware_links_for) %> <% show_courseware_link = (course.id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(course.id) %> <% cert_status = cert_statuses.get(course.id) %>
<% credit_status = credit_statuses.get(course.id) %>
<% show_email_settings = (course.id in show_email_settings_for) %> <% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %> <% course_mode_info = all_course_modes.get(course.id) %>
<% show_refund_option = (course.id in show_refund_option_for) %> <% show_refund_option = (course.id in show_refund_option_for) %>
...@@ -83,8 +84,7 @@ from django.core.urlresolvers import reverse ...@@ -83,8 +84,7 @@ from django.core.urlresolvers import reverse
<% is_course_blocked = (course.id in block_courses) %> <% is_course_blocked = (course.id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(course.id, {}) %> <% course_verification_status = verification_status_by_course.get(course.id, {}) %>
<% course_requirements = courses_requirements_not_met.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, 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" />
<%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" />
% endfor % endfor
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): % if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
......
<%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 import urllib
...@@ -275,8 +275,8 @@ from student.helpers import ( ...@@ -275,8 +275,8 @@ from student.helpers import (
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/> <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
% endif % endif
% if credit_message: % if credit_status is not None:
<%include file='_dashboard_credit_information.html' args='credit_message=credit_message'/> <%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/>
% endif % 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: % 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:
......
<%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
<%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 @@ ...@@ -2,7 +2,33 @@
Django admin page for credit eligibility Django admin page for credit eligibility
""" """
from ratelimitbackend import admin 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)
"""
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 {}
"""
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
"""
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
"""
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 {}
# -*- 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']
# -*- 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 ...@@ -6,9 +6,15 @@ Credit courses allow students to receive university credit for
successful completion of a course on EdX successful completion of a course on EdX
""" """
import datetime
from collections import defaultdict from collections import defaultdict
import logging 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.db import models, transaction, IntegrityError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
...@@ -23,7 +29,8 @@ log = logging.getLogger(__name__) ...@@ -23,7 +29,8 @@ log = logging.getLogger(__name__)
class CreditProvider(TimeStampedModel): 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 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 includes a `url` where the student will be sent when he/she will try to
...@@ -78,13 +85,54 @@ class CreditProvider(TimeStampedModel): ...@@ -78,13 +85,54 @@ class CreditProvider(TimeStampedModel):
) )
) )
# Default is one year provider_status_url = models.URLField(
DEFAULT_ELIGIBILITY_DURATION = 31556970 default="",
help_text=ugettext_lazy(
eligibility_duration = models.PositiveIntegerField( "URL from the credit provider where the user can check the status "
help_text=ugettext_lazy(u"Number of seconds to show eligibility message"), "of his or her request for credit. This is displayed to students "
default=DEFAULT_ELIGIBILITY_DURATION "*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): class CreditCourse(models.Model):
...@@ -94,23 +142,35 @@ class CreditCourse(models.Model): ...@@ -94,23 +142,35 @@ class CreditCourse(models.Model):
course_key = CourseKeyField(max_length=255, db_index=True, unique=True) course_key = CourseKeyField(max_length=255, db_index=True, unique=True)
enabled = models.BooleanField(default=False) enabled = models.BooleanField(default=False)
providers = models.ManyToManyField(CreditProvider)
CREDIT_COURSES_CACHE_KEY = "credit.courses.set"
@classmethod @classmethod
def is_credit_course(cls, course_key): 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: Args:
course_key(CourseKey): The course identifier course_key (CourseKey): Identifier of the course.
Returns: 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 @classmethod
def get_credit_course(cls, course_key): 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: Args:
course_key(CourseKey): The course identifier course_key(CourseKey): The course identifier
...@@ -123,6 +183,17 @@ class CreditCourse(models.Model): ...@@ -123,6 +183,17 @@ class CreditCourse(models.Model):
""" """
return cls.objects.get(course_key=course_key, enabled=True) 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): class CreditRequirement(TimeStampedModel):
""" """
...@@ -227,7 +298,8 @@ class CreditRequirement(TimeStampedModel): ...@@ -227,7 +298,8 @@ class CreditRequirement(TimeStampedModel):
@classmethod @classmethod
def get_course_requirement(cls, course_key, namespace, name): def get_course_requirement(cls, course_key, namespace, name):
"""Get credit requirement of a given course. """
Get credit requirement of a given course.
Args: Args:
course_key(CourseKey): The identifier for a course course_key(CourseKey): The identifier for a course
...@@ -286,7 +358,8 @@ class CreditRequirementStatus(TimeStampedModel): ...@@ -286,7 +358,8 @@ class CreditRequirementStatus(TimeStampedModel):
@classmethod @classmethod
def get_statuses(cls, requirements, username): 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: Args:
requirement(CreditRequirement): The identifier for a requirement requirement(CreditRequirement): The identifier for a requirement
...@@ -300,7 +373,8 @@ class CreditRequirementStatus(TimeStampedModel): ...@@ -300,7 +373,8 @@ class CreditRequirementStatus(TimeStampedModel):
@classmethod @classmethod
@transaction.commit_on_success @transaction.commit_on_success
def add_or_update_requirement_status(cls, username, requirement, status="satisfied", reason=None): 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: Args:
username(str): Username of the user username(str): Username of the user
...@@ -328,21 +402,23 @@ class CreditEligibility(TimeStampedModel): ...@@ -328,21 +402,23 @@ class CreditEligibility(TimeStampedModel):
username = models.CharField(max_length=255, db_index=True) username = models.CharField(max_length=255, db_index=True)
course = models.ForeignKey(CreditCourse, related_name="eligibilities") 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 class Meta(object): # pylint: disable=missing-docstring
unique_together = ('username', 'course') unique_together = ('username', 'course')
verbose_name_plural = "Credit eligibilities"
@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')
@classmethod @classmethod
def update_eligibility(cls, requirements, username, course_key): def update_eligibility(cls, requirements, username, course_key):
...@@ -379,8 +455,27 @@ class CreditEligibility(TimeStampedModel): ...@@ -379,8 +455,27 @@ class CreditEligibility(TimeStampedModel):
pass pass
@classmethod @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): 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: Args:
course_key(CourseKey): The course identifier course_key(CourseKey): The course identifier
...@@ -389,7 +484,19 @@ class CreditEligibility(TimeStampedModel): ...@@ -389,7 +484,19 @@ class CreditEligibility(TimeStampedModel):
Returns: Returns:
Bool True if the user eligible for credit course else False 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): class CreditRequest(TimeStampedModel):
...@@ -476,7 +583,8 @@ class CreditRequest(TimeStampedModel): ...@@ -476,7 +583,8 @@ class CreditRequest(TimeStampedModel):
@classmethod @classmethod
def get_user_request_status(cls, username, course_key): 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: Args:
username(str): The username of requesting user username(str): The username of requesting user
...@@ -492,3 +600,11 @@ class CreditRequest(TimeStampedModel): ...@@ -492,3 +600,11 @@ class CreditRequest(TimeStampedModel):
).select_related('course', 'provider').latest() ).select_related('course', 'provider').latest()
except cls.DoesNotExist: except cls.DoesNotExist:
return None 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 ...@@ -39,24 +39,24 @@ def listen_for_grade_calculation(sender, username, grade_summary, course_key, de
kwargs : None kwargs : None
""" """
from openedx.core.djangoapps.credit.api import ( # This needs to be imported here to avoid a circular dependency
is_credit_course, get_credit_requirement, set_credit_requirement_status # that can cause syncdb to fail.
) from openedx.core.djangoapps.credit import api
course_id = CourseKey.from_string(unicode(course_key)) 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: if is_credit:
requirement = get_credit_requirement(course_id, 'grade', 'grade') requirements = api.get_credit_requirements(course_id, namespace='grade')
if requirement: if requirements:
criteria = requirement.get('criteria') criteria = requirements[0].get('criteria')
if criteria: if criteria:
min_grade = criteria.get('min_grade') min_grade = criteria.get('min_grade')
if grade_summary['percent'] >= min_grade: if grade_summary['percent'] >= min_grade:
reason_dict = {'final_grade': grade_summary['percent']} 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 username, course_id, 'grade', 'grade', status="satisfied", reason=reason_dict
) )
elif deadline and deadline < timezone.now(): elif deadline and deadline < timezone.now():
set_credit_requirement_status( api.set_credit_requirement_status(
username, course_id, 'grade', 'grade', status="failed", reason={} username, course_id, 'grade', 'grade', status="failed", reason={}
) )
""" """
Tests for the API functions in the credit app. Tests for the API functions in the credit app.
""" """
import unittest
import datetime import datetime
import ddt import ddt
import pytz import pytz
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.db import connection, transaction from django.db import connection, transaction
from django.core.urlresolvers import reverse
from django.conf import settings
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -30,11 +27,7 @@ from openedx.core.djangoapps.credit.models import ( ...@@ -30,11 +27,7 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirementStatus, CreditRequirementStatus,
CreditEligibility CreditEligibility
) )
from student.models import CourseEnrollment
from student.views import _create_credit_availability_message
from student.tests.factories import UserFactory 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" TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
...@@ -53,6 +46,7 @@ class CreditApiTestBase(TestCase): ...@@ -53,6 +46,7 @@ class CreditApiTestBase(TestCase):
PROVIDER_ID = "hogwarts" PROVIDER_ID = "hogwarts"
PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry" PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
PROVIDER_URL = "https://credit.example.com/request" PROVIDER_URL = "https://credit.example.com/request"
PROVIDER_STATUS_URL = "https://credit.example.com/status"
def setUp(self, **kwargs): def setUp(self, **kwargs):
super(CreditApiTestBase, self).setUp() super(CreditApiTestBase, self).setUp()
...@@ -62,14 +56,13 @@ class CreditApiTestBase(TestCase): ...@@ -62,14 +56,13 @@ class CreditApiTestBase(TestCase):
"""Mark the course as a credit """ """Mark the course as a credit """
credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled) credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled)
# Associate a credit provider with the course. CreditProvider.objects.create(
credit_provider = CreditProvider.objects.create(
provider_id=self.PROVIDER_ID, provider_id=self.PROVIDER_ID,
display_name=self.PROVIDER_NAME, display_name=self.PROVIDER_NAME,
provider_url=self.PROVIDER_URL, provider_url=self.PROVIDER_URL,
provider_status_url=self.PROVIDER_STATUS_URL,
enable_integration=True, enable_integration=True,
) )
credit_course.providers.add(credit_provider)
return credit_course return credit_course
...@@ -223,34 +216,41 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -223,34 +216,41 @@ class CreditRequirementApiTests(CreditApiTestBase):
is_eligible = api.is_user_eligible_for_credit('abc', credit_course.course_key) is_eligible = api.is_user_eligible_for_credit('abc', credit_course.course_key)
self.assertFalse(is_eligible) self.assertFalse(is_eligible)
def test_get_credit_requirement(self): def test_eligibility_expired(self):
self.add_credit_course() # Configure a credit eligibility that expired yesterday
requirements = [ credit_course = self.add_credit_course()
{ CreditEligibility.objects.create(
"namespace": "grade", course=credit_course,
"name": "grade", username="staff",
"display_name": "Grade", deadline=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
"criteria": { )
"min_grade": 0.8
},
}
]
requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
self.assertIsNone(requirement)
expected_requirement = { # The user should NOT be eligible for credit
"course_key": self.course_key, is_eligible = api.is_user_eligible_for_credit("staff", credit_course.course_key)
"namespace": "grade", self.assertFalse(is_eligible)
"name": "grade",
"display_name": "Grade", # The eligibility should NOT show up in the user's list of eligibilities
"criteria": { eligibilities = api.get_eligibilities_for_user("staff")
"min_grade": 0.8 self.assertEqual(eligibilities, [])
}
} def test_eligibility_disabled_course(self):
api.set_credit_requirements(self.course_key, requirements) # Configure a credit eligibility for a disabled course
requirement = api.get_credit_requirement(self.course_key, "grade", "grade") credit_course = self.add_credit_course()
self.assertIsNotNone(requirement) credit_course.enabled = False
self.assertEqual(requirement, expected_requirement) 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): def test_set_credit_requirement_status(self):
self.add_credit_course() self.add_credit_course()
...@@ -427,6 +427,25 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): ...@@ -427,6 +427,25 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
# credit requirement that the user has satisfied (minimum grade) # credit requirement that the user has satisfied (minimum grade)
self._configure_credit() 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): def test_credit_request(self):
# Initiate a credit request # Initiate a credit request
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
...@@ -611,7 +630,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): ...@@ -611,7 +630,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
self.assertEqual(requests, []) self.assertEqual(requests, [])
def _configure_credit(self): def _configure_credit(self):
""" """
Configure a credit course and its requirements. Configure a credit course and its requirements.
...@@ -643,123 +661,3 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): ...@@ -643,123 +661,3 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
"""Check the user's credit status. """ """Check the user's credit status. """
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"]) statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(statuses[0]["status"], expected_status) 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): ...@@ -41,19 +41,17 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
self.client.login(username=self.user.username, password=self.user.password) self.client.login(username=self.user.username, password=self.user.password)
# Enable the course for credit # Enable the course for credit
credit_course = CreditCourse.objects.create( CreditCourse.objects.create(
course_key=self.course.id, course_key=self.course.id,
enabled=True, enabled=True,
) )
# Configure a credit provider for the course # Configure a credit provider for the course
credit_provider = CreditProvider.objects.create( CreditProvider.objects.create(
provider_id="ASU", provider_id="ASU",
enable_integration=True, enable_integration=True,
provider_url="https://credit.example.com/request", provider_url="https://credit.example.com/request",
) )
credit_course.providers.add(credit_provider)
credit_course.save()
requirements = [{ requirements = [{
"namespace": "grade", "namespace": "grade",
......
...@@ -73,13 +73,11 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): ...@@ -73,13 +73,11 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
) )
# Configure a credit provider for the course # Configure a credit provider for the course
credit_provider = CreditProvider.objects.create( CreditProvider.objects.create(
provider_id=self.PROVIDER_ID, provider_id=self.PROVIDER_ID,
enable_integration=True, enable_integration=True,
provider_url=self.PROVIDER_URL, provider_url=self.PROVIDER_URL,
) )
credit_course.providers.add(credit_provider)
credit_course.save()
# Add a single credit requirement (final grade) # Add a single credit requirement (final grade)
requirement = CreditRequirement.objects.create( requirement = CreditRequirement.objects.create(
...@@ -256,11 +254,8 @@ class CreditProviderViewTests(UrlResetMixin, TestCase): ...@@ -256,11 +254,8 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
other_provider_id = "other_provider" other_provider_id = "other_provider"
other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d" other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d"
# Create an additional credit provider and associate it with the course. # Create an additional credit provider
credit_course = CreditCourse.objects.get(course_key=self.COURSE_KEY) CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
credit_provider = CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
credit_course.providers.add(credit_provider)
credit_course.save()
# Initiate a credit request with the first provider # Initiate a credit request with the first provider
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment