diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 5335b73e7e..2c1cc1bb2c 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -1830,20 +1830,27 @@ class LanguageProficiency(models.Model):
class CourseEnrollmentAttribute(models.Model):
- """Represents Student's enrollment record for Credit Course.
-
- This is populated when the user's order for a credit seat is fulfilled.
"""
- enrollment = models.ForeignKey(CourseEnrollment)
+ Provide additional information about the user's enrollment.
+ """
+ enrollment = models.ForeignKey(CourseEnrollment, related_name="attributes")
namespace = models.CharField(
max_length=255,
- help_text=_("Namespace of enrollment attribute e.g. credit")
+ help_text=_("Namespace of enrollment attribute")
)
name = models.CharField(
max_length=255,
- help_text=_("Name of the enrollment attribute e.g. provider_id")
+ help_text=_("Name of the enrollment attribute")
)
value = models.CharField(
max_length=255,
- help_text=_("Value of the enrollment attribute e.g. ASU")
+ help_text=_("Value of the enrollment attribute")
)
+
+ def __unicode__(self):
+ """Unicode representation of the attribute. """
+ return u"{namespace}:{name}, {value}".format(
+ namespace=self.namespace,
+ name=self.name,
+ value=self.value,
+ )
diff --git a/common/djangoapps/student/tests/test_credit.py b/common/djangoapps/student/tests/test_credit.py
new file mode 100644
index 0000000000..0b2a226e88
--- /dev/null
+++ b/common/djangoapps/student/tests/test_credit.py
@@ -0,0 +1,207 @@
+"""
+Tests for credit courses on the student dashboard.
+"""
+import unittest
+import datetime
+
+import pytz
+
+from django.conf import settings
+from django.core.urlresolvers import reverse
+from django.test.utils import override_settings
+
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+from student.models import CourseEnrollmentAttribute
+from student.tests.factories import UserFactory, CourseEnrollmentFactory
+
+from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider, CreditEligibility
+from openedx.core.djangoapps.credit import api as credit_api
+
+
+TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
+
+
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
+@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
+ "hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY,
+})
+class CreditCourseDashboardTest(ModuleStoreTestCase):
+ """
+ Tests for credit courses on the student dashboard.
+ """
+
+ USERNAME = "ron"
+ PASSWORD = "mobiliarbus"
+
+ PROVIDER_ID = "hogwarts"
+ PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
+ PROVIDER_STATUS_URL = "http://credit.example.com/status"
+
+ def setUp(self):
+ """Create a course and an enrollment. """
+ super(CreditCourseDashboardTest, self).setUp()
+
+ # Create a user and log in
+ self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
+ result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
+ self.assertTrue(result, msg="Could not log in")
+
+ # Create a course and configure it as a credit course
+ self.course = CourseFactory()
+ CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=no-member
+
+ # Configure a credit provider
+ CreditProvider.objects.create(
+ provider_id=self.PROVIDER_ID,
+ display_name=self.PROVIDER_NAME,
+ provider_status_url=self.PROVIDER_STATUS_URL,
+ enable_integration=True,
+ )
+
+ # Configure a single credit requirement (minimum passing grade)
+ credit_api.set_credit_requirements(
+ self.course.id, # pylint: disable=no-member
+ [
+ {
+ "namespace": "grade",
+ "name": "grade",
+ "display_name": "Final Grade",
+ "criteria": {
+ "min_grade": 0.8
+ }
+ }
+ ]
+ )
+
+ # Enroll the user in the course as "verified"
+ self.enrollment = CourseEnrollmentFactory(
+ user=self.user,
+ course_id=self.course.id, # pylint: disable=no-member
+ mode="verified"
+ )
+
+ def test_not_eligible_for_credit(self):
+ # The user is not yet eligible for credit, so no additional information should be displayed on the dashboard.
+ response = self._load_dashboard()
+ self.assertNotContains(response, "credit")
+
+ def test_eligible_for_credit(self):
+ # Simulate that the user has completed the only requirement in the course
+ # so the user is eligible for credit.
+ self._make_eligible()
+
+ # The user should have the option to purchase credit
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-eligibility-msg")
+ self.assertContains(response, "purchase-credit-btn")
+
+ # Move the eligibility deadline so it's within 30 days
+ eligibility = CreditEligibility.objects.get(username=self.USERNAME)
+ eligibility.deadline = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=29)
+ eligibility.save()
+
+ # The user should still have the option to purchase credit,
+ # but there should also be a message urging the user to purchase soon.
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-eligibility-msg")
+ self.assertContains(response, "purchase-credit-btn")
+ self.assertContains(response, "purchase credit for this course expires")
+
+ def test_purchased_credit(self):
+ # Simulate that the user has purchased credit, but has not
+ # yet initiated a request to the credit provider
+ self._make_eligible()
+ self._purchase_credit()
+
+ # Expect that the user's status is "pending"
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-request-pending-msg")
+
+ def test_purchased_credit_and_request_pending(self):
+ # Simulate that the user has purchased credit and initiated a request,
+ # but we haven't yet heard back from the credit provider.
+ self._make_eligible()
+ self._purchase_credit()
+ self._initiate_request()
+
+ # Expect that the user's status is "pending"
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-request-pending-msg")
+
+ def test_purchased_credit_and_request_approved(self):
+ # Simulate that the user has purchased credit and initiated a request,
+ # and had that request approved by the credit provider
+ self._make_eligible()
+ self._purchase_credit()
+ request_uuid = self._initiate_request()
+ self._set_request_status(request_uuid, "approved")
+
+ # Expect that the user's status is "approved"
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-request-approved-msg")
+
+ def test_purchased_credit_and_request_rejected(self):
+ # Simulate that the user has purchased credit and initiated a request,
+ # and had that request rejected by the credit provider
+ self._make_eligible()
+ self._purchase_credit()
+ request_uuid = self._initiate_request()
+ self._set_request_status(request_uuid, "rejected")
+
+ # Expect that the user's status is "approved"
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-request-rejected-msg")
+
+ def test_credit_status_error(self):
+ # Simulate an error condition: the user has a credit enrollment
+ # but no enrollment attribute indicating which provider the user
+ # purchased credit from.
+ self._make_eligible()
+ self._purchase_credit()
+ CourseEnrollmentAttribute.objects.all().delete()
+
+ # Expect an error message
+ response = self._load_dashboard()
+ self.assertContains(response, "credit-error-msg")
+
+ def _load_dashboard(self):
+ """Load the student dashboard and return the HttpResponse. """
+ return self.client.get(reverse("dashboard"))
+
+ def _make_eligible(self):
+ """Make the user eligible for credit in the course. """
+ credit_api.set_credit_requirement_status(
+ self.USERNAME,
+ self.course.id, # pylint: disable=no-member
+ "grade", "grade",
+ status="satisfied",
+ reason={
+ "final_grade": 0.95
+ }
+ )
+
+ def _purchase_credit(self):
+ """Purchase credit from a provider in the course. """
+ self.enrollment.mode = "credit"
+ self.enrollment.save() # pylint: disable=no-member
+
+ CourseEnrollmentAttribute.objects.create(
+ enrollment=self.enrollment,
+ namespace="credit",
+ name="provider_id",
+ value=self.PROVIDER_ID,
+ )
+
+ def _initiate_request(self):
+ """Initiate a request for credit from a provider. """
+ request = credit_api.create_credit_request(
+ self.course.id, # pylint: disable=no-member
+ self.PROVIDER_ID,
+ self.USERNAME
+ )
+ return request["parameters"]["request_uuid"]
+
+ def _set_request_status(self, uuid, status):
+ """Set the status of a request for credit, simulating the notification from the provider. """
+ credit_api.update_credit_request_status(uuid, self.PROVIDER_ID, status)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 1420aa5432..1e546c3806 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -51,7 +51,7 @@ from course_modes.models import CourseMode
from shoppingcart.api import order_history
from student.models import (
Registration, UserProfile, PendingNameChange,
- PendingEmailChange, CourseEnrollment, unique_id_for_user,
+ PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user,
CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory, UserSignupSource,
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
@@ -124,7 +124,6 @@ from notification_prefs.views import enable_notifications
# Note that this lives in openedx, so this dependency should be refactored.
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
-from openedx.core.djangoapps.credit.api import get_credit_eligibility, get_purchased_credit_courses
log = logging.getLogger("edx.student")
@@ -531,8 +530,6 @@ def dashboard(request):
for course, __ in course_enrollment_pairs:
enrolled_courses_dict[unicode(course.id)] = course
- credit_messages = _create_credit_availability_message(enrolled_courses_dict, user)
-
course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
message = ""
@@ -638,7 +635,6 @@ def dashboard(request):
context = {
'enrollment_message': enrollment_message,
- 'credit_messages': credit_messages,
'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts,
'message': message,
@@ -647,6 +643,7 @@ def dashboard(request):
'show_courseware_links_for': show_courseware_links_for,
'all_course_modes': course_mode_info,
'cert_statuses': cert_statuses,
+ 'credit_statuses': _credit_statuses(user, course_enrollment_pairs),
'show_email_settings_for': show_email_settings_for,
'reverifications': reverifications,
'verification_status': verification_status,
@@ -703,47 +700,6 @@ def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
)
-def _create_credit_availability_message(enrolled_courses_dict, user): # pylint: disable=invalid-name
- """Builds a dict of credit availability for courses.
-
- Construct a for courses user has completed and has not purchased credit
- from the credit provider yet.
-
- Args:
- course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
- user (User): User object.
-
- Returns:
- A dict of courses user is eligible for credit.
-
- """
- user_eligibilities = get_credit_eligibility(user.username)
- user_purchased_credit = get_purchased_credit_courses(user.username)
-
- eligibility_messages = {}
- for course_id, eligibility in user_eligibilities.iteritems():
- if course_id not in user_purchased_credit:
- duration = eligibility["seconds_good_for_display"]
- curr_time = timezone.now()
- validity_till = eligibility["created_at"] + timedelta(seconds=duration)
- if validity_till > curr_time:
- diff = validity_till - curr_time
- urgent = diff.days <= 30
- eligibility_messages[course_id] = {
- "user_id": user.id,
- "course_id": course_id,
- "course_name": enrolled_courses_dict[course_id].display_name,
- "providers": eligibility["providers"],
- "status": eligibility["status"],
- "provider": eligibility.get("provider"),
- "urgent": urgent,
- "user_full_name": user.get_full_name(),
- "expiry": validity_till
- }
-
- return eligibility_messages
-
-
def _get_recently_enrolled_courses(course_enrollment_pairs):
"""Checks to see if the student has recently enrolled in courses.
@@ -793,6 +749,124 @@ def _update_email_opt_in(request, org):
preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
+def _credit_statuses(user, course_enrollment_pairs):
+ """
+ Retrieve the status for credit courses.
+
+ A credit course is a course for which a user can purchased
+ college credit. The current flow is:
+
+ 1. User becomes eligible for credit (submits verifications, passes the course, etc.)
+ 2. User purchases credit from a particular credit provider.
+ 3. User requests credit from the provider, usually creating an account on the provider's site.
+ 4. The credit provider notifies us whether the user's request for credit has been accepted or rejected.
+
+ The dashboard is responsible for communicating the user's state in this flow.
+
+ Arguments:
+ user (User): The currently logged-in user.
+ course_enrollment_pairs (list): List of (Course, CourseEnrollment) tuples.
+
+ Returns: dict
+
+ The returned dictionary has keys that are `CourseKey`s and values that
+ are dictionaries with:
+
+ * eligible (bool): True if the user is eligible for credit in this course.
+ * deadline (datetime): The deadline for purchasing and requesting credit for this course.
+ * purchased (bool): Whether the user has purchased credit for this course.
+ * provider_name (string): The display name of the credit provider.
+ * provider_status_url (string): A URL the user can visit to check on their credit request status.
+ * request_status (string): Either "pending", "approved", or "rejected"
+ * error (bool): If true, an unexpected error occurred when retrieving the credit status,
+ so the user should contact the support team.
+
+ Example:
+ >>> _credit_statuses(user, course_enrollment_pairs)
+ {
+ CourseKey.from_string("edX/DemoX/Demo_Course"): {
+ "course_key": "edX/DemoX/Demo_Course",
+ "eligible": True,
+ "deadline": 2015-11-23 00:00:00 UTC,
+ "purchased": True,
+ "provider_name": "Hogwarts",
+ "provider_status_url": "http://example.com/status",
+ "request_status": "pending",
+ "error": False
+ }
+ }
+
+ """
+ from openedx.core.djangoapps.credit import api as credit_api
+
+ request_status_by_course = {
+ request["course_key"]: request["status"]
+ for request in credit_api.get_credit_requests_for_user(user.username)
+ }
+
+ credit_enrollments = {
+ course.id: enrollment
+ for course, enrollment in course_enrollment_pairs
+ if enrollment.mode == "credit"
+ }
+
+ # When a user purchases credit in a course, the user's enrollment
+ # mode is set to "credit" and an enrollment attribute is set
+ # with the ID of the credit provider. We retrieve *all* such attributes
+ # here to minimize the number of database queries.
+ purchased_credit_providers = {
+ attribute.enrollment.course_id: attribute.value
+ for attribute in CourseEnrollmentAttribute.objects.filter(
+ namespace="credit",
+ name="provider_id",
+ enrollment__in=credit_enrollments.values()
+ ).select_related("enrollment")
+ }
+
+ provider_info_by_id = {
+ provider["id"]: provider
+ for provider in credit_api.get_credit_providers()
+ }
+
+ statuses = {}
+ for eligibility in credit_api.get_eligibilities_for_user(user.username):
+ course_key = eligibility["course_key"]
+ status = {
+ "course_key": unicode(course_key),
+ "eligible": True,
+ "deadline": eligibility["deadline"],
+ "purchased": course_key in credit_enrollments,
+ "provider_name": None,
+ "provider_status_url": None,
+ "request_status": request_status_by_course.get(course_key),
+ "error": False,
+ }
+
+ # If the user has purchased credit, then include information about the credit
+ # provider from which the user purchased credit.
+ # We retrieve the provider's ID from the an "enrollment attribute" set on the user's
+ # enrollment when the user's order for credit is fulfilled by the E-Commerce service.
+ if status["purchased"]:
+ provider_id = purchased_credit_providers.get(course_key)
+ if provider_id is None:
+ status["error"] = True
+ log.error(
+ u"Could not find credit provider associated with credit enrollment "
+ u"for user %s in course %s. The user will not be able to see his or her "
+ u"credit request status on the student dashboard. This attribute should "
+ u"have been set when the user purchased credit in the course.",
+ user.id, course_key
+ )
+ else:
+ provider_info = provider_info_by_id.get(provider_id, {})
+ status["provider_name"] = provider_info.get("display_name")
+ status["provider_status_url"] = provider_info.get("status_url")
+
+ statuses[course_key] = status
+
+ return statuses
+
+
@require_POST
@commit_on_success_with_read_committed
def change_enrollment(request, check_access=True):
diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py
index 1eaad5f301..95968b63f2 100644
--- a/lms/djangoapps/courseware/tests/test_submitting_problems.py
+++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py
@@ -615,13 +615,11 @@ class TestCourseGrader(TestSubmittingProblems):
)
# Configure a credit provider for the course
- credit_provider = CreditProvider.objects.create(
+ CreditProvider.objects.create(
provider_id="ASU",
enable_integration=True,
provider_url="https://credit.example.com/request",
)
- credit_course.providers.add(credit_provider)
- credit_course.save()
requirements = [{
"namespace": "grade",
diff --git a/lms/static/js/dashboard/credit.js b/lms/static/js/dashboard/credit.js
new file mode 100644
index 0000000000..b40af18030
--- /dev/null
+++ b/lms/static/js/dashboard/credit.js
@@ -0,0 +1,17 @@
+(function($, analytics) {
+ 'use strict';
+
+ $(document).ready(function() {
+ // Fire analytics events when the "purchase credit" button is clicked
+ $(".purchase-credit-btn").on("click", function(event) {
+ var courseKey = $(event.target).data("course-key");
+ analytics.track(
+ "edx.bi.credit.clicked_purchase_credit",
+ {
+ category: "credit",
+ label: courseKey
+ }
+ );
+ });
+ });
+})(jQuery, window.analytics);
diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss
index 5d4e3ca26c..f51f697da7 100644
--- a/lms/static/sass/multicourse/_dashboard.scss
+++ b/lms/static/sass/multicourse/_dashboard.scss
@@ -531,8 +531,17 @@
padding: 0;
}
+ .credit-eligibility-msg {
+ @include float(left);
+ margin-top: 10px;
+ }
+
.purchase_credit {
- float: right;
+ @include float(right);
+ }
+
+ .purchase-credit-btn {
+ @extend %btn-pl-yellow-base;
}
.message {
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index 821a1291f5..e4d2ec04d8 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -76,6 +76,7 @@ from django.core.urlresolvers import reverse
% for dashboard_index, (course, enrollment) in enumerate(course_enrollment_pairs):
<% show_courseware_link = (course.id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(course.id) %>
+ <% credit_status = credit_statuses.get(course.id) %>
<% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %>
<% show_refund_option = (course.id in show_refund_option_for) %>
@@ -83,8 +84,7 @@ from django.core.urlresolvers import reverse
<% is_course_blocked = (course.id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(course.id, {}) %>
<% course_requirements = courses_requirements_not_met.get(course.id) %>
- <% credit_message = credit_messages.get(unicode(course.id)) %>
- <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, credit_message=credit_message, user=user" />
+ <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user" />
% endfor
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index aa431bbf0b..79b3533a2a 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -1,4 +1,4 @@
-<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, credit_message" />
+<%page args="course, enrollment, show_courseware_link, cert_status, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" />
<%!
import urllib
@@ -275,9 +275,9 @@ from student.helpers import (
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
% endif
- % if credit_message:
- <%include file='_dashboard_credit_information.html' args='credit_message=credit_message'/>
- % endif
+ % if credit_status is not None:
+ <%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/>
+ % endif
% if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked:
diff --git a/lms/templates/dashboard/_dashboard_credit_info.html b/lms/templates/dashboard/_dashboard_credit_info.html
new file mode 100644
index 0000000000..edfb11ff35
--- /dev/null
+++ b/lms/templates/dashboard/_dashboard_credit_info.html
@@ -0,0 +1,65 @@
+<%page args="credit_status" />
+<%!
+ import datetime
+ import pytz
+ from django.utils.translation import ugettext as _
+ from util.date_utils import get_default_time_display
+%>
+<%namespace name='static' file='../static_content.html'/>
+
+% if credit_status["eligible"]:
+
+ % if credit_status["error"]:
+
+ ${_("An error occurred with this transaction. For help, contact {support_email}.").format(
+ support_email=u'{address}'.format(
+ address=settings.DEFAULT_FEEDBACK_EMAIL
+ )
+ )}
+
+ % elif not credit_status["purchased"]:
+
+ % 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
+
+
+ % elif credit_status["request_status"] in [None, "pending"]:
+
+ ${_("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'{provider_name}'.format(
+ provider_url=credit_status["provider_status_url"],
+ provider_name=credit_status["provider_name"],
+ )
+ )
+ }
+
+ % elif credit_status["request_status"] == "approved":
+
+ ${_("Congratulations - you have received credit for this course! For more information, see {provider_link}.").format(
+ provider_link=u'{provider_name}'.format(
+ provider_url=credit_status["provider_status_url"],
+ provider_name=credit_status["provider_name"],
+ )
+ )
+ }
+
+ % elif credit_status["request_status"] == "rejected":
+
+ ${_("{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'{provider_name}'.format(
+ provider_url=credit_status["provider_status_url"],
+ provider_name=credit_status["provider_name"],
+ )
+ )
+ }
+
+ % endif
+
+% endif
diff --git a/lms/templates/dashboard/_dashboard_credit_information.html b/lms/templates/dashboard/_dashboard_credit_information.html
deleted file mode 100644
index efdb39337b..0000000000
--- a/lms/templates/dashboard/_dashboard_credit_information.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<%page args="credit_message" />
-
-<%!
-from django.utils.translation import ugettext as _
-from course_modes.models import CourseMode
-from util.date_utils import get_default_time_display
-%>
-<%namespace name='static' file='../static_content.html'/>
-
-<%block name="js_extra" args="credit_message">
- <%static:js group='credit_wv'/>
-
-%block>
-
-
-
- % if credit_message["status"] == "requirements_meet":
-
- % 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="Congratulations",
- username=credit_message["user_full_name"]
- )
- }
- % endif
-
- ${_("Purchase Credit")}
-
- % 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='{}'.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='{}'.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='{}'.format(credit_message["provider"]["display_name"])
- )
- }
- % endif
-
-
-
-
diff --git a/openedx/core/djangoapps/credit/admin.py b/openedx/core/djangoapps/credit/admin.py
index 91c39cbfff..5700f0ef73 100644
--- a/openedx/core/djangoapps/credit/admin.py
+++ b/openedx/core/djangoapps/credit/admin.py
@@ -2,7 +2,33 @@
Django admin page for credit eligibility
"""
from ratelimitbackend import admin
-from .models import CreditCourse, CreditProvider
+from openedx.core.djangoapps.credit.models import (
+ CreditCourse, CreditProvider, CreditEligibility, CreditRequest
+)
-admin.site.register(CreditCourse)
-admin.site.register(CreditProvider)
+
+class CreditCourseAdmin(admin.ModelAdmin):
+ """Admin for credit courses. """
+ search_fields = ("course_key",)
+
+
+class CreditProviderAdmin(admin.ModelAdmin):
+ """Admin for credit providers. """
+ search_fields = ("provider_id", "display_name")
+
+
+class CreditEligibilityAdmin(admin.ModelAdmin):
+ """Admin for credit eligibility. """
+ search_fields = ("username", "course__course_key")
+
+
+class CreditRequestAdmin(admin.ModelAdmin):
+ """Admin for credit requests. """
+ search_fields = ("uuid", "username", "course__course_key", "provider__provider_id")
+ readonly_fields = ("uuid",)
+
+
+admin.site.register(CreditCourse, CreditCourseAdmin)
+admin.site.register(CreditProvider, CreditProviderAdmin)
+admin.site.register(CreditEligibility, CreditEligibilityAdmin)
+admin.site.register(CreditRequest, CreditRequestAdmin)
diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py
deleted file mode 100644
index a8c437f071..0000000000
--- a/openedx/core/djangoapps/credit/api.py
+++ /dev/null
@@ -1,794 +0,0 @@
-"""
-Contains the APIs for course credit requirements.
-"""
-
-import logging
-import uuid
-import datetime
-
-import pytz
-
-from django.db import transaction
-
-from util.date_utils import to_timestamp
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey
-
-from student.models import User
-from .exceptions import (
- InvalidCreditRequirements,
- InvalidCreditCourse,
- UserIsNotEligible,
- CreditProviderNotConfigured,
- RequestAlreadyCompleted,
- CreditRequestNotFound,
- InvalidCreditStatus,
-)
-from .models import (
- CreditCourse,
- CreditProvider,
- CreditRequirement,
- CreditRequirementStatus,
- CreditRequest,
- CreditEligibility,
-)
-from .signature import signature, get_shared_secret_key
-
-
-log = logging.getLogger(__name__)
-
-
-def set_credit_requirements(course_key, requirements):
- """
- Add requirements to given course.
-
- Args:
- course_key(CourseKey): The identifier for course
- requirements(list): List of requirements to be added
-
- Example:
- >>> set_credit_requirements(
- "course-v1-edX-DemoX-1T2015",
- [
- {
- "namespace": "reverification",
- "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
- "display_name": "Assessment 1",
- "criteria": {},
- },
- {
- "namespace": "proctored_exam",
- "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
- "display_name": "Final Exam",
- "criteria": {},
- },
- {
- "namespace": "grade",
- "name": "grade",
- "display_name": "Grade",
- "criteria": {"min_grade": 0.8},
- },
- ])
-
- Raises:
- InvalidCreditRequirements
-
- Returns:
- None
- """
-
- invalid_requirements = _validate_requirements(requirements)
- if invalid_requirements:
- invalid_requirements = ", ".join(invalid_requirements)
- raise InvalidCreditRequirements(invalid_requirements)
-
- try:
- credit_course = CreditCourse.get_credit_course(course_key=course_key)
- except CreditCourse.DoesNotExist:
- raise InvalidCreditCourse()
-
- old_requirements = CreditRequirement.get_course_requirements(course_key=course_key)
- requirements_to_disable = _get_requirements_to_disable(old_requirements, requirements)
- if requirements_to_disable:
- CreditRequirement.disable_credit_requirements(requirements_to_disable)
-
- # update requirement with new order
- for order, requirement in enumerate(requirements):
- CreditRequirement.add_or_update_course_requirement(credit_course, requirement, order)
-
-
-def get_credit_requirements(course_key, namespace=None):
- """
- Get credit eligibility requirements of a given course and namespace.
-
- Args:
- course_key(CourseKey): The identifier for course
- namespace(str): Namespace of requirements
-
- Example:
- >>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
- {
- requirements =
- [
- {
- "namespace": "reverification",
- "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
- "display_name": "Assessment 1",
- "criteria": {},
- },
- {
- "namespace": "proctored_exam",
- "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
- "display_name": "Final Exam",
- "criteria": {},
- },
- {
- "namespace": "grade",
- "name": "grade",
- "display_name": "Grade",
- "criteria": {"min_grade": 0.8},
- },
- ]
- }
-
- Returns:
- Dict of requirements in the given namespace
-
- """
-
- requirements = CreditRequirement.get_course_requirements(course_key, namespace)
- return [
- {
- "namespace": requirement.namespace,
- "name": requirement.name,
- "display_name": requirement.display_name,
- "criteria": requirement.criteria
- }
- for requirement in requirements
- ]
-
-
-@transaction.commit_on_success
-def create_credit_request(course_key, provider_id, username):
- """
- Initiate a request for credit from a credit provider.
-
- This will return the parameters that the user's browser will need to POST
- to the credit provider. It does NOT calculate the signature.
-
- Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests.
-
- A provider can be configured either with *integration enabled* or not.
- If automatic integration is disabled, this method will simply return
- a URL to the credit provider and method set to "GET", so the student can
- visit the URL and request credit directly. No database record will be created
- to track these requests.
-
- If automatic integration *is* enabled, then this will also return the parameters
- that the user's browser will need to POST to the credit provider.
- These parameters will be digitally signed using a secret key shared with the credit provider.
-
- A database record will be created to track the request with a 32-character UUID.
- The returned dictionary can be used by the user's browser to send a POST request to the credit provider.
-
- If a pending request already exists, this function should return a request description with the same UUID.
- (Other parameters, such as the user's full name may be different than the original request).
-
- If a completed request (either accepted or rejected) already exists, this function will
- raise an exception. Users are not allowed to make additional requests once a request
- has been completed.
-
- Arguments:
- course_key (CourseKey): The identifier for the course.
- provider_id (str): The identifier of the credit provider.
- user (User): The user initiating the request.
-
- Returns: dict
-
- Raises:
- UserIsNotEligible: The user has not satisfied eligibility requirements for credit.
- CreditProviderNotConfigured: The credit provider has not been configured for this course.
- RequestAlreadyCompleted: The user has already submitted a request and received a response
- from the credit provider.
-
- Example Usage:
- >>> create_credit_request(course.id, "hogwarts", "ron")
- {
- "url": "https://credit.example.com/request",
- "method": "POST",
- "parameters": {
- "request_uuid": "557168d0f7664fe59097106c67c3f847",
- "timestamp": 1434631630,
- "course_org": "HogwartsX",
- "course_num": "Potions101",
- "course_run": "1T2015",
- "final_grade": 0.95,
- "user_username": "ron",
- "user_email": "ron@example.com",
- "user_full_name": "Ron Weasley",
- "user_mailing_address": "",
- "user_country": "US",
- "signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
- }
- }
-
- """
- try:
- user_eligibility = CreditEligibility.objects.select_related('course').get(
- username=username,
- course__course_key=course_key
- )
- credit_course = user_eligibility.course
- credit_provider = credit_course.providers.get(provider_id=provider_id)
- except (CreditEligibility.DoesNotExist, CreditProvider.DoesNotExist):
- log.warning(u'User tried to initiate a request for credit, but the user is not eligible for credit')
- raise UserIsNotEligible
-
- # Check if we've enabled automatic integration with the credit
- # provider. If not, we'll show the user a link to a URL
- # where the user can request credit directly from the provider.
- # Note that we do NOT track these requests in our database,
- # since the state would always be "pending" (we never hear back).
- if not credit_provider.enable_integration:
- return {
- "url": credit_provider.provider_url,
- "method": "GET",
- "parameters": {}
- }
- else:
- # If automatic credit integration is enabled, then try
- # to retrieve the shared signature *before* creating the request.
- # That way, if there's a misconfiguration, we won't have requests
- # in our system that we know weren't sent to the provider.
- shared_secret_key = get_shared_secret_key(credit_provider.provider_id)
- if shared_secret_key is None:
- msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format(
- provider_id=credit_provider.provider_id
- )
- log.error(msg)
- raise CreditProviderNotConfigured(msg)
-
- # Initiate a new request if one has not already been created
- credit_request, created = CreditRequest.objects.get_or_create(
- course=credit_course,
- provider=credit_provider,
- username=username,
- )
-
- # Check whether we've already gotten a response for a request,
- # If so, we're not allowed to issue any further requests.
- # Skip checking the status if we know that we just created this record.
- if not created and credit_request.status != "pending":
- log.warning(
- (
- u'Cannot initiate credit request because the request with UUID "%s" '
- u'exists with status "%s"'
- ), credit_request.uuid, credit_request.status
- )
- raise RequestAlreadyCompleted
-
- if created:
- credit_request.uuid = uuid.uuid4().hex
-
- # Retrieve user account and profile info
- user = User.objects.select_related('profile').get(username=username)
-
- # Retrieve the final grade from the eligibility table
- try:
- final_grade = CreditRequirementStatus.objects.get(
- username=username,
- requirement__namespace="grade",
- requirement__name="grade",
- status="satisfied"
- ).reason["final_grade"]
- except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError):
- log.exception(
- "Could not retrieve final grade from the credit eligibility table "
- "for user %s in course %s.",
- user.id, course_key
- )
- raise UserIsNotEligible
-
- parameters = {
- "request_uuid": credit_request.uuid,
- "timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)),
- "course_org": course_key.org,
- "course_num": course_key.course,
- "course_run": course_key.run,
- "final_grade": final_grade,
- "user_username": user.username,
- "user_email": user.email,
- "user_full_name": user.profile.name,
- "user_mailing_address": (
- user.profile.mailing_address
- if user.profile.mailing_address is not None
- else ""
- ),
- "user_country": (
- user.profile.country.code
- if user.profile.country.code is not None
- else ""
- ),
- }
-
- credit_request.parameters = parameters
- credit_request.save()
-
- if created:
- log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid)
- else:
- log.info(
- u'Updated request for credit with UUID "%s" so the user can re-issue the request',
- credit_request.uuid
- )
-
- # Sign the parameters using a secret key we share with the credit provider.
- parameters["signature"] = signature(parameters, shared_secret_key)
-
- return {
- "url": credit_provider.provider_url,
- "method": "POST",
- "parameters": parameters
- }
-
-
-def update_credit_request_status(request_uuid, provider_id, status):
- """
- Update the status of a credit request.
-
- Approve or reject a request for a student to receive credit in a course
- from a particular credit provider.
-
- This function does NOT check that the status update is authorized.
- The caller needs to handle authentication and authorization (checking the signature
- of the message received from the credit provider)
-
- The function is idempotent; if the request has already been updated to the status,
- the function does nothing.
-
- Arguments:
- request_uuid (str): The unique identifier for the credit request.
- provider_id (str): Identifier for the credit provider.
- status (str): Either "approved" or "rejected"
-
- Returns: None
-
- Raises:
- CreditRequestNotFound: No request exists that is associated with the given provider.
- InvalidCreditStatus: The status is not either "approved" or "rejected".
-
- """
- if status not in ["approved", "rejected"]:
- raise InvalidCreditStatus
-
- try:
- request = CreditRequest.objects.get(uuid=request_uuid, provider__provider_id=provider_id)
- old_status = request.status
- request.status = status
- request.save()
-
- log.info(
- u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".',
- request_uuid, old_status, status, provider_id
- )
- except CreditRequest.DoesNotExist:
- msg = (
- u'Credit provider with ID "{provider_id}" attempted to '
- u'update request with UUID "{request_uuid}", but no request '
- u'with this UUID is associated with the provider.'
- ).format(provider_id=provider_id, request_uuid=request_uuid)
- log.warning(msg)
- raise CreditRequestNotFound(msg)
-
-
-def get_credit_requests_for_user(username):
- """
- Retrieve the status of a credit request.
-
- Returns either "pending", "accepted", or "rejected"
-
- Arguments:
- username (unicode): The username of the user who initiated the requests.
-
- Returns: list
-
- Example Usage:
- >>> get_credit_request_status_for_user("bob")
- [
- {
- "uuid": "557168d0f7664fe59097106c67c3f847",
- "timestamp": 1434631630,
- "course_key": "course-v1:HogwartsX+Potions101+1T2015",
- "provider": {
- "id": "HogwartsX",
- "display_name": "Hogwarts School of Witchcraft and Wizardry",
- },
- "status": "pending" # or "approved" or "rejected"
- }
- ]
-
- """
- return CreditRequest.credit_requests_for_user(username)
-
-
-def get_credit_requirement_status(course_key, username, namespace=None, name=None):
- """ Retrieve the user's status for each credit requirement in the course.
-
- Args:
- course_key (CourseKey): The identifier for course
- username (str): The identifier of the user
-
- Example:
- >>> get_credit_requirement_status("course-v1-edX-DemoX-1T2015", "john")
-
- [
- {
- "namespace": "reverification",
- "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
- "display_name": "In Course Reverification",
- "criteria": {},
- "status": "failed",
- "status_date": "2015-06-26 07:49:13",
- },
- {
- "namespace": "proctored_exam",
- "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
- "display_name": "Proctored Mid Term Exam",
- "criteria": {},
- "status": "satisfied",
- "status_date": "2015-06-26 11:07:42",
- },
- {
- "namespace": "grade",
- "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
- "display_name": "Minimum Passing Grade",
- "criteria": {"min_grade": 0.8},
- "status": "failed",
- "status_date": "2015-06-26 11:07:44",
- },
- ]
-
- Returns:
- list of requirement statuses
- """
- requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
- requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
- requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
- statuses = []
- for requirement in requirements:
- requirement_status = requirement_statuses.get(requirement)
- statuses.append({
- "namespace": requirement.namespace,
- "name": requirement.name,
- "display_name": requirement.display_name,
- "criteria": requirement.criteria,
- "status": requirement_status.status if requirement_status else None,
- "status_date": requirement_status.modified if requirement_status else None,
- })
- return statuses
-
-
-def is_user_eligible_for_credit(username, course_key):
- """Returns a boolean indicating if the user is eligible for credit for
- the given course
-
- Args:
- username(str): The identifier for user
- course_key (CourseKey): The identifier for course
-
- Returns:
- True if user is eligible for the course else False
- """
- return CreditEligibility.is_user_eligible_for_credit(course_key, username)
-
-
-def get_credit_requirement(course_key, namespace, name):
- """Returns the requirement of a given course, namespace and name.
-
- Args:
- course_key(CourseKey): The identifier for course
- namespace(str): Namespace of requirement
- name(str): Name of the requirement
-
- Returns: dict
-
- Example:
- >>> get_credit_requirement_status(
- "course-v1-edX-DemoX-1T2015", "proctored_exam", "i4x://edX/DemoX/proctoring-block/final_uuid"
- )
- {
- "course_key": "course-v1-edX-DemoX-1T2015"
- "namespace": "reverification",
- "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
- "display_name": "reverification"
- "criteria": {},
- }
-
- """
- requirement = CreditRequirement.get_course_requirement(course_key, namespace, name)
- return {
- "course_key": requirement.course.course_key,
- "namespace": requirement.namespace,
- "name": requirement.name,
- "display_name": requirement.display_name,
- "criteria": requirement.criteria
- } if requirement else None
-
-
-def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None):
- """
- Update the user's requirement status.
-
- This will record whether the user satisfied or failed a particular requirement
- in a course. If the user has satisfied all requirements, the user will be marked
- as eligible for credit in the course.
-
- Args:
- username (str): Username of the user
- course_key (CourseKey): Identifier for the course associated with the requirement.
- req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
- req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)
-
- Keyword Arguments:
- status (str): Status of the requirement (either "satisfied" or "failed")
- reason (dict): Reason of the status
-
- Example:
- >>> set_credit_requirement_status(
- "staff",
- CourseKey.from_string("course-v1-edX-DemoX-1T2015"),
- "reverification",
- "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
- status="satisfied",
- reason={}
- )
-
- """
- # Check if we're already eligible for credit.
- # If so, short-circuit this process.
- if CreditEligibility.is_user_eligible_for_credit(course_key, username):
- return
-
- # Retrieve all credit requirements for the course
- # We retrieve all of them to avoid making a second query later when
- # we need to check whether all requirements have been satisfied.
- reqs = CreditRequirement.get_course_requirements(course_key)
-
- # Find the requirement we're trying to set
- req_to_update = next((
- req for req in reqs
- if req.namespace == req_namespace
- and req.name == req_name
- ), None)
-
- # If we can't find the requirement, then the most likely explanation
- # is that there was a lag updating the credit requirements after the course
- # was published. We *could* attempt to create the requirement here,
- # but that could cause serious performance issues if many users attempt to
- # lock the row at the same time.
- # Instead, we skip updating the requirement and log an error.
- if req_to_update is None:
- log.error(
- (
- u'Could not update credit requirement in course "%s" '
- u'with namespace "%s" and name "%s" '
- u'because the requirement does not exist. '
- u'The user "%s" should have had his/her status updated to "%s".'
- ),
- unicode(course_key), req_namespace, req_name, username, status
- )
- return
-
- # Update the requirement status
- CreditRequirementStatus.add_or_update_requirement_status(
- username, req_to_update, status=status, reason=reason
- )
-
- # If we're marking this requirement as "satisfied", there's a chance
- # that the user has met all eligibility requirements.
- if status == "satisfied":
- CreditEligibility.update_eligibility(reqs, username, course_key)
-
-
-def _get_requirements_to_disable(old_requirements, new_requirements):
- """
- Get the ids of 'CreditRequirement' entries to be disabled that are
- deleted from the courseware.
-
- Args:
- old_requirements(QuerySet): QuerySet of CreditRequirement
- new_requirements(list): List of requirements being added
-
- Returns:
- List of ids of CreditRequirement that are not in new_requirements
- """
- requirements_to_disable = []
- for old_req in old_requirements:
- found_flag = False
- for req in new_requirements:
- # check if an already added requirement is modified
- if req["namespace"] == old_req.namespace and req["name"] == old_req.name:
- found_flag = True
- break
- if not found_flag:
- requirements_to_disable.append(old_req.id)
- return requirements_to_disable
-
-
-def _validate_requirements(requirements):
- """
- Validate the requirements.
-
- Args:
- requirements(list): List of requirements
-
- Returns:
- List of strings of invalid requirements
- """
- invalid_requirements = []
- for requirement in requirements:
- invalid_params = []
- if not requirement.get("namespace"):
- invalid_params.append("namespace")
- if not requirement.get("name"):
- invalid_params.append("name")
- if not requirement.get("display_name"):
- invalid_params.append("display_name")
- if "criteria" not in requirement:
- invalid_params.append("criteria")
-
- if invalid_params:
- invalid_requirements.append(
- u"{requirement} has missing/invalid parameters: {params}".format(
- requirement=requirement,
- params=invalid_params,
- )
- )
- return invalid_requirements
-
-
-def is_credit_course(course_key):
- """API method to check if course is credit or not.
-
- Args:
- course_key(CourseKey): The course identifier string or CourseKey object
-
- Returns:
- Bool True if the course is marked credit else False
-
- """
- try:
- course_key = CourseKey.from_string(unicode(course_key))
- except InvalidKeyError:
- return False
-
- return CreditCourse.is_credit_course(course_key=course_key)
-
-
-def get_credit_request_status(username, course_key):
- """Get the credit request status.
-
- This function returns the status of credit request of user for given course.
- It returns the latest request status for the any credit provider.
- The valid status are 'pending', 'approved' or 'rejected'.
-
- Args:
- username(str): The username of user
- course_key(CourseKey): The course locator key
-
- Returns:
- A dictionary of credit request user has made if any
-
- """
- credit_request = CreditRequest.get_user_request_status(username, course_key)
- if credit_request:
- credit_status = {
- "uuid": credit_request.uuid,
- "timestamp": credit_request.modified,
- "course_key": credit_request.course.course_key,
- "provider": {
- "id": credit_request.provider.provider_id,
- "display_name": credit_request.provider.display_name
- },
- "status": credit_request.status
- }
- else:
- credit_status = {}
- return credit_status
-
-
-def _get_duration_and_providers(credit_course):
- """Returns the credit providers and eligibility durations.
-
- The eligibility_duration is the max of the credit duration of
- all the credit providers of given course.
-
- Args:
- credit_course(CreditCourse): The CreditCourse object
-
- Returns:
- Tuple of eligibility_duration and credit providers of given course
-
- """
- providers = credit_course.providers.all()
- seconds_good_for_display = 0
- providers_list = []
- for provider in providers:
- providers_list.append(
- {
- "id": provider.provider_id,
- "display_name": provider.display_name,
- "eligibility_duration": provider.eligibility_duration,
- "provider_url": provider.provider_url
- }
- )
- eligibility_duration = int(provider.eligibility_duration) if provider.eligibility_duration else 0
- seconds_good_for_display = max(eligibility_duration, seconds_good_for_display)
-
- return seconds_good_for_display, providers_list
-
-
-def get_credit_eligibility(username):
- """
- Returns the all the eligibility the user has meet.
-
- Args:
- username(str): The username of user
-
- Example:
- >> get_credit_eligibility('Aamir'):
- {
- "edX/DemoX/Demo_Course": {
- "created_at": "2015-12-21",
- "providers": [
- "id": 12,
- "display_name": "Arizona State University",
- "eligibility_duration": 60,
- "provider_url": "http://arizona/provideere/link"
- ],
- "seconds_good_for_display": 90
- }
- }
-
- Returns:
- A dict of eligibilities
- """
- eligibilities = CreditEligibility.get_user_eligibility(username)
- user_credit_requests = get_credit_requests_for_user(username)
- request_dict = {}
- # Change the list to dict for iteration
- for request in user_credit_requests:
- request_dict[unicode(request["course_key"])] = request
- user_eligibilities = {}
- for eligibility in eligibilities:
- course_key = eligibility.course.course_key
- duration, providers_list = _get_duration_and_providers(eligibility.course)
- user_eligibilities[unicode(course_key)] = {
- "created_at": eligibility.created,
- "seconds_good_for_display": duration,
- "providers": providers_list,
- }
-
- # Default status is requirements_meet
- user_eligibilities[unicode(course_key)]["status"] = "requirements_meet"
- # If there is some request user has made for this eligibility then update the status
- if unicode(course_key) in request_dict:
- user_eligibilities[unicode(course_key)]["status"] = request_dict[unicode(course_key)]["status"]
- user_eligibilities[unicode(course_key)]["provider"] = request_dict[unicode(course_key)]["provider"]
-
- return user_eligibilities
-
-
-def get_purchased_credit_courses(username): # pylint: disable=unused-argument
- """
- Returns the purchased credit courses.
-
- Args:
- username(str): Username of the student
-
- Returns:
- A dict of courses user has purchased from the credit provider after completion
-
- """
- # TODO: How to track the purchased courses. It requires Will's work for credit provider integration
- return {}
diff --git a/openedx/core/djangoapps/credit/api/__init__.py b/openedx/core/djangoapps/credit/api/__init__.py
new file mode 100644
index 0000000000..4f392d5183
--- /dev/null
+++ b/openedx/core/djangoapps/credit/api/__init__.py
@@ -0,0 +1,8 @@
+"""
+Credit Python API.
+
+This module aggregates the API functions from the eligibility and provider APIs.
+"""
+
+from .eligibility import * # pylint: disable=wildcard-import
+from .provider import * # pylint: disable=wildcard-import
diff --git a/openedx/core/djangoapps/credit/api/eligibility.py b/openedx/core/djangoapps/credit/api/eligibility.py
new file mode 100644
index 0000000000..9a64d69c44
--- /dev/null
+++ b/openedx/core/djangoapps/credit/api/eligibility.py
@@ -0,0 +1,383 @@
+"""
+APIs for configuring credit eligibility requirements and tracking
+whether a user has satisfied those requirements.
+"""
+
+import logging
+
+from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
+from openedx.core.djangoapps.credit.models import (
+ CreditCourse,
+ CreditRequirement,
+ CreditRequirementStatus,
+ CreditEligibility,
+)
+
+
+log = logging.getLogger(__name__)
+
+
+def is_credit_course(course_key):
+ """
+ Check whether the course has been configured for credit.
+
+ Args:
+ course_key (CourseKey): Identifier of the course.
+
+ Returns:
+ bool: True iff this is a credit course.
+
+ """
+ return CreditCourse.is_credit_course(course_key=course_key)
+
+
+def set_credit_requirements(course_key, requirements):
+ """
+ Add requirements to given course.
+
+ Args:
+ course_key(CourseKey): The identifier for course
+ requirements(list): List of requirements to be added
+
+ Example:
+ >>> set_credit_requirements(
+ "course-v1-edX-DemoX-1T2015",
+ [
+ {
+ "namespace": "reverification",
+ "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
+ "display_name": "Assessment 1",
+ "criteria": {},
+ },
+ {
+ "namespace": "proctored_exam",
+ "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
+ "display_name": "Final Exam",
+ "criteria": {},
+ },
+ {
+ "namespace": "grade",
+ "name": "grade",
+ "display_name": "Grade",
+ "criteria": {"min_grade": 0.8},
+ },
+ ])
+
+ Raises:
+ InvalidCreditRequirements
+
+ Returns:
+ None
+ """
+
+ invalid_requirements = _validate_requirements(requirements)
+ if invalid_requirements:
+ invalid_requirements = ", ".join(invalid_requirements)
+ raise InvalidCreditRequirements(invalid_requirements)
+
+ try:
+ credit_course = CreditCourse.get_credit_course(course_key=course_key)
+ except CreditCourse.DoesNotExist:
+ raise InvalidCreditCourse()
+
+ old_requirements = CreditRequirement.get_course_requirements(course_key=course_key)
+ requirements_to_disable = _get_requirements_to_disable(old_requirements, requirements)
+ if requirements_to_disable:
+ CreditRequirement.disable_credit_requirements(requirements_to_disable)
+
+ for order, requirement in enumerate(requirements):
+ CreditRequirement.add_or_update_course_requirement(credit_course, requirement, order)
+
+
+def get_credit_requirements(course_key, namespace=None):
+ """
+ Get credit eligibility requirements of a given course and namespace.
+
+ Args:
+ course_key(CourseKey): The identifier for course
+ namespace(str): Namespace of requirements
+
+ Example:
+ >>> get_credit_requirements("course-v1-edX-DemoX-1T2015")
+ {
+ requirements =
+ [
+ {
+ "namespace": "reverification",
+ "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
+ "display_name": "Assessment 1",
+ "criteria": {},
+ },
+ {
+ "namespace": "proctored_exam",
+ "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
+ "display_name": "Final Exam",
+ "criteria": {},
+ },
+ {
+ "namespace": "grade",
+ "name": "grade",
+ "display_name": "Grade",
+ "criteria": {"min_grade": 0.8},
+ },
+ ]
+ }
+
+ Returns:
+ Dict of requirements in the given namespace
+
+ """
+
+ requirements = CreditRequirement.get_course_requirements(course_key, namespace)
+ return [
+ {
+ "namespace": requirement.namespace,
+ "name": requirement.name,
+ "display_name": requirement.display_name,
+ "criteria": requirement.criteria
+ }
+ for requirement in requirements
+ ]
+
+
+def is_user_eligible_for_credit(username, course_key):
+ """
+ Returns a boolean indicating if the user is eligible for credit for
+ the given course
+
+ Args:
+ username(str): The identifier for user
+ course_key (CourseKey): The identifier for course
+
+ Returns:
+ True if user is eligible for the course else False
+ """
+ return CreditEligibility.is_user_eligible_for_credit(course_key, username)
+
+
+def get_eligibilities_for_user(username):
+ """
+ Retrieve all courses for which the user is eligible for credit.
+
+ Arguments:
+ username (unicode): Identifier of the user.
+
+ Example:
+ >>> get_eligibilities_for_user("ron")
+ [
+ {
+ "course_key": "edX/Demo_101/Fall",
+ "deadline": "2015-10-23"
+ },
+ {
+ "course_key": "edX/Demo_201/Spring",
+ "deadline": "2015-11-15"
+ },
+ ...
+ ]
+
+ Returns: list
+
+ """
+ return [
+ {
+ "course_key": eligibility.course.course_key,
+ "deadline": eligibility.deadline,
+ }
+ for eligibility in CreditEligibility.get_user_eligibilities(username)
+ ]
+
+
+def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None):
+ """
+ Update the user's requirement status.
+
+ This will record whether the user satisfied or failed a particular requirement
+ in a course. If the user has satisfied all requirements, the user will be marked
+ as eligible for credit in the course.
+
+ Args:
+ username (str): Username of the user
+ course_key (CourseKey): Identifier for the course associated with the requirement.
+ req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
+ req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)
+
+ Keyword Arguments:
+ status (str): Status of the requirement (either "satisfied" or "failed")
+ reason (dict): Reason of the status
+
+ Example:
+ >>> set_credit_requirement_status(
+ "staff",
+ CourseKey.from_string("course-v1-edX-DemoX-1T2015"),
+ "reverification",
+ "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
+ status="satisfied",
+ reason={}
+ )
+
+ """
+ # Check if we're already eligible for credit.
+ # If so, short-circuit this process.
+ if CreditEligibility.is_user_eligible_for_credit(course_key, username):
+ log.info(
+ u'Skipping update of credit requirement with namespace "%s" '
+ u'and name "%s" because the user "%s" is already eligible for credit '
+ u'in the course "%s".',
+ req_namespace, req_name, username, course_key
+ )
+ return
+
+ # Retrieve all credit requirements for the course
+ # We retrieve all of them to avoid making a second query later when
+ # we need to check whether all requirements have been satisfied.
+ reqs = CreditRequirement.get_course_requirements(course_key)
+
+ # Find the requirement we're trying to set
+ req_to_update = next((
+ req for req in reqs
+ if req.namespace == req_namespace
+ and req.name == req_name
+ ), None)
+
+ # If we can't find the requirement, then the most likely explanation
+ # is that there was a lag updating the credit requirements after the course
+ # was published. We *could* attempt to create the requirement here,
+ # but that could cause serious performance issues if many users attempt to
+ # lock the row at the same time.
+ # Instead, we skip updating the requirement and log an error.
+ if req_to_update is None:
+ log.error(
+ (
+ u'Could not update credit requirement in course "%s" '
+ u'with namespace "%s" and name "%s" '
+ u'because the requirement does not exist. '
+ u'The user "%s" should have had his/her status updated to "%s".'
+ ),
+ unicode(course_key), req_namespace, req_name, username, status
+ )
+ return
+
+ # Update the requirement status
+ CreditRequirementStatus.add_or_update_requirement_status(
+ username, req_to_update, status=status, reason=reason
+ )
+
+ # If we're marking this requirement as "satisfied", there's a chance
+ # that the user has met all eligibility requirements.
+ if status == "satisfied":
+ CreditEligibility.update_eligibility(reqs, username, course_key)
+
+
+def get_credit_requirement_status(course_key, username, namespace=None, name=None):
+ """ Retrieve the user's status for each credit requirement in the course.
+
+ Args:
+ course_key (CourseKey): The identifier for course
+ username (str): The identifier of the user
+
+ Example:
+ >>> get_credit_requirement_status("course-v1-edX-DemoX-1T2015", "john")
+
+ [
+ {
+ "namespace": "reverification",
+ "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
+ "display_name": "In Course Reverification",
+ "criteria": {},
+ "status": "failed",
+ "status_date": "2015-06-26 07:49:13",
+ },
+ {
+ "namespace": "proctored_exam",
+ "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
+ "display_name": "Proctored Mid Term Exam",
+ "criteria": {},
+ "status": "satisfied",
+ "status_date": "2015-06-26 11:07:42",
+ },
+ {
+ "namespace": "grade",
+ "name": "i4x://edX/DemoX/proctoring-block/final_uuid",
+ "display_name": "Minimum Passing Grade",
+ "criteria": {"min_grade": 0.8},
+ "status": "failed",
+ "status_date": "2015-06-26 11:07:44",
+ },
+ ]
+
+ Returns:
+ list of requirement statuses
+ """
+ requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
+ requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
+ requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
+ statuses = []
+ for requirement in requirements:
+ requirement_status = requirement_statuses.get(requirement)
+ statuses.append({
+ "namespace": requirement.namespace,
+ "name": requirement.name,
+ "display_name": requirement.display_name,
+ "criteria": requirement.criteria,
+ "status": requirement_status.status if requirement_status else None,
+ "status_date": requirement_status.modified if requirement_status else None,
+ })
+ return statuses
+
+
+def _get_requirements_to_disable(old_requirements, new_requirements):
+ """
+ Get the ids of 'CreditRequirement' entries to be disabled that are
+ deleted from the courseware.
+
+ Args:
+ old_requirements(QuerySet): QuerySet of CreditRequirement
+ new_requirements(list): List of requirements being added
+
+ Returns:
+ List of ids of CreditRequirement that are not in new_requirements
+ """
+ requirements_to_disable = []
+ for old_req in old_requirements:
+ found_flag = False
+ for req in new_requirements:
+ # check if an already added requirement is modified
+ if req["namespace"] == old_req.namespace and req["name"] == old_req.name:
+ found_flag = True
+ break
+ if not found_flag:
+ requirements_to_disable.append(old_req.id)
+ return requirements_to_disable
+
+
+def _validate_requirements(requirements):
+ """
+ Validate the requirements.
+
+ Args:
+ requirements(list): List of requirements
+
+ Returns:
+ List of strings of invalid requirements
+ """
+ invalid_requirements = []
+ for requirement in requirements:
+ invalid_params = []
+ if not requirement.get("namespace"):
+ invalid_params.append("namespace")
+ if not requirement.get("name"):
+ invalid_params.append("name")
+ if not requirement.get("display_name"):
+ invalid_params.append("display_name")
+ if "criteria" not in requirement:
+ invalid_params.append("criteria")
+
+ if invalid_params:
+ invalid_requirements.append(
+ u"{requirement} has missing/invalid parameters: {params}".format(
+ requirement=requirement,
+ params=invalid_params,
+ )
+ )
+ return invalid_requirements
diff --git a/openedx/core/djangoapps/credit/api/provider.py b/openedx/core/djangoapps/credit/api/provider.py
new file mode 100644
index 0000000000..50f4447e9a
--- /dev/null
+++ b/openedx/core/djangoapps/credit/api/provider.py
@@ -0,0 +1,349 @@
+"""
+API for initiating and tracking requests for credit from a provider.
+"""
+
+import logging
+import uuid
+import datetime
+
+import pytz
+
+from django.db import transaction
+
+from util.date_utils import to_timestamp
+
+from student.models import User
+from openedx.core.djangoapps.credit.exceptions import (
+ UserIsNotEligible,
+ CreditProviderNotConfigured,
+ RequestAlreadyCompleted,
+ CreditRequestNotFound,
+ InvalidCreditStatus,
+)
+from openedx.core.djangoapps.credit.models import (
+ CreditProvider,
+ CreditRequirementStatus,
+ CreditRequest,
+ CreditEligibility,
+)
+from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key
+
+
+log = logging.getLogger(__name__)
+
+
+def get_credit_providers():
+ """
+ Retrieve all available credit providers.
+
+ Example:
+ >>> get_credit_providers()
+ [
+ {
+ "id": "hogwarts",
+ "display_name": "Hogwarts School of Witchcraft and Wizardry"
+ },
+ ...
+ ]
+
+ Returns: list
+ """
+ return CreditProvider.get_credit_providers()
+
+
+@transaction.commit_on_success
+def create_credit_request(course_key, provider_id, username):
+ """
+ Initiate a request for credit from a credit provider.
+
+ This will return the parameters that the user's browser will need to POST
+ to the credit provider. It does NOT calculate the signature.
+
+ Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests.
+
+ A provider can be configured either with *integration enabled* or not.
+ If automatic integration is disabled, this method will simply return
+ a URL to the credit provider and method set to "GET", so the student can
+ visit the URL and request credit directly. No database record will be created
+ to track these requests.
+
+ If automatic integration *is* enabled, then this will also return the parameters
+ that the user's browser will need to POST to the credit provider.
+ These parameters will be digitally signed using a secret key shared with the credit provider.
+
+ A database record will be created to track the request with a 32-character UUID.
+ The returned dictionary can be used by the user's browser to send a POST request to the credit provider.
+
+ If a pending request already exists, this function should return a request description with the same UUID.
+ (Other parameters, such as the user's full name may be different than the original request).
+
+ If a completed request (either accepted or rejected) already exists, this function will
+ raise an exception. Users are not allowed to make additional requests once a request
+ has been completed.
+
+ Arguments:
+ course_key (CourseKey): The identifier for the course.
+ provider_id (str): The identifier of the credit provider.
+ user (User): The user initiating the request.
+
+ Returns: dict
+
+ Raises:
+ UserIsNotEligible: The user has not satisfied eligibility requirements for credit.
+ CreditProviderNotConfigured: The credit provider has not been configured for this course.
+ RequestAlreadyCompleted: The user has already submitted a request and received a response
+ from the credit provider.
+
+ Example Usage:
+ >>> create_credit_request(course.id, "hogwarts", "ron")
+ {
+ "url": "https://credit.example.com/request",
+ "method": "POST",
+ "parameters": {
+ "request_uuid": "557168d0f7664fe59097106c67c3f847",
+ "timestamp": 1434631630,
+ "course_org": "HogwartsX",
+ "course_num": "Potions101",
+ "course_run": "1T2015",
+ "final_grade": 0.95,
+ "user_username": "ron",
+ "user_email": "ron@example.com",
+ "user_full_name": "Ron Weasley",
+ "user_mailing_address": "",
+ "user_country": "US",
+ "signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI="
+ }
+ }
+
+ """
+ try:
+ user_eligibility = CreditEligibility.objects.select_related('course').get(
+ username=username,
+ course__course_key=course_key
+ )
+ credit_course = user_eligibility.course
+ credit_provider = CreditProvider.objects.get(provider_id=provider_id)
+ except CreditEligibility.DoesNotExist:
+ log.warning(
+ u'User "%s" tried to initiate a request for credit in course "%s", '
+ u'but the user is not eligible for credit',
+ username, course_key
+ )
+ raise UserIsNotEligible
+ except CreditProvider.DoesNotExist:
+ log.error(u'Credit provider with ID "%s" has not been configured.', provider_id)
+ raise CreditProviderNotConfigured
+
+ # Check if we've enabled automatic integration with the credit
+ # provider. If not, we'll show the user a link to a URL
+ # where the user can request credit directly from the provider.
+ # Note that we do NOT track these requests in our database,
+ # since the state would always be "pending" (we never hear back).
+ if not credit_provider.enable_integration:
+ return {
+ "url": credit_provider.provider_url,
+ "method": "GET",
+ "parameters": {}
+ }
+ else:
+ # If automatic credit integration is enabled, then try
+ # to retrieve the shared signature *before* creating the request.
+ # That way, if there's a misconfiguration, we won't have requests
+ # in our system that we know weren't sent to the provider.
+ shared_secret_key = get_shared_secret_key(credit_provider.provider_id)
+ if shared_secret_key is None:
+ msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format(
+ provider_id=credit_provider.provider_id
+ )
+ log.error(msg)
+ raise CreditProviderNotConfigured(msg)
+
+ # Initiate a new request if one has not already been created
+ credit_request, created = CreditRequest.objects.get_or_create(
+ course=credit_course,
+ provider=credit_provider,
+ username=username,
+ )
+
+ # Check whether we've already gotten a response for a request,
+ # If so, we're not allowed to issue any further requests.
+ # Skip checking the status if we know that we just created this record.
+ if not created and credit_request.status != "pending":
+ log.warning(
+ (
+ u'Cannot initiate credit request because the request with UUID "%s" '
+ u'exists with status "%s"'
+ ), credit_request.uuid, credit_request.status
+ )
+ raise RequestAlreadyCompleted
+
+ if created:
+ credit_request.uuid = uuid.uuid4().hex
+
+ # Retrieve user account and profile info
+ user = User.objects.select_related('profile').get(username=username)
+
+ # Retrieve the final grade from the eligibility table
+ try:
+ final_grade = CreditRequirementStatus.objects.get(
+ username=username,
+ requirement__namespace="grade",
+ requirement__name="grade",
+ status="satisfied"
+ ).reason["final_grade"]
+ except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError):
+ log.exception(
+ "Could not retrieve final grade from the credit eligibility table "
+ "for user %s in course %s.",
+ user.id, course_key
+ )
+ raise UserIsNotEligible
+
+ parameters = {
+ "request_uuid": credit_request.uuid,
+ "timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)),
+ "course_org": course_key.org,
+ "course_num": course_key.course,
+ "course_run": course_key.run,
+ "final_grade": final_grade,
+ "user_username": user.username,
+ "user_email": user.email,
+ "user_full_name": user.profile.name,
+ "user_mailing_address": (
+ user.profile.mailing_address
+ if user.profile.mailing_address is not None
+ else ""
+ ),
+ "user_country": (
+ user.profile.country.code
+ if user.profile.country.code is not None
+ else ""
+ ),
+ }
+
+ credit_request.parameters = parameters
+ credit_request.save()
+
+ if created:
+ log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid)
+ else:
+ log.info(
+ u'Updated request for credit with UUID "%s" so the user can re-issue the request',
+ credit_request.uuid
+ )
+
+ # Sign the parameters using a secret key we share with the credit provider.
+ parameters["signature"] = signature(parameters, shared_secret_key)
+
+ return {
+ "url": credit_provider.provider_url,
+ "method": "POST",
+ "parameters": parameters
+ }
+
+
+def update_credit_request_status(request_uuid, provider_id, status):
+ """
+ Update the status of a credit request.
+
+ Approve or reject a request for a student to receive credit in a course
+ from a particular credit provider.
+
+ This function does NOT check that the status update is authorized.
+ The caller needs to handle authentication and authorization (checking the signature
+ of the message received from the credit provider)
+
+ The function is idempotent; if the request has already been updated to the status,
+ the function does nothing.
+
+ Arguments:
+ request_uuid (str): The unique identifier for the credit request.
+ provider_id (str): Identifier for the credit provider.
+ status (str): Either "approved" or "rejected"
+
+ Returns: None
+
+ Raises:
+ CreditRequestNotFound: No request exists that is associated with the given provider.
+ InvalidCreditStatus: The status is not either "approved" or "rejected".
+
+ """
+ if status not in [CreditRequest.REQUEST_STATUS_APPROVED, CreditRequest.REQUEST_STATUS_REJECTED]:
+ raise InvalidCreditStatus
+
+ try:
+ request = CreditRequest.objects.get(uuid=request_uuid, provider__provider_id=provider_id)
+ old_status = request.status
+ request.status = status
+ request.save()
+
+ log.info(
+ u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".',
+ request_uuid, old_status, status, provider_id
+ )
+ except CreditRequest.DoesNotExist:
+ msg = (
+ u'Credit provider with ID "{provider_id}" attempted to '
+ u'update request with UUID "{request_uuid}", but no request '
+ u'with this UUID is associated with the provider.'
+ ).format(provider_id=provider_id, request_uuid=request_uuid)
+ log.warning(msg)
+ raise CreditRequestNotFound(msg)
+
+
+def get_credit_requests_for_user(username):
+ """
+ Retrieve the status of a credit request.
+
+ Returns either "pending", "approved", or "rejected"
+
+ Arguments:
+ username (unicode): The username of the user who initiated the requests.
+
+ Returns: list
+
+ Example Usage:
+ >>> get_credit_request_status_for_user("bob")
+ [
+ {
+ "uuid": "557168d0f7664fe59097106c67c3f847",
+ "timestamp": 1434631630,
+ "course_key": "course-v1:HogwartsX+Potions101+1T2015",
+ "provider": {
+ "id": "HogwartsX",
+ "display_name": "Hogwarts School of Witchcraft and Wizardry",
+ },
+ "status": "pending" # or "approved" or "rejected"
+ }
+ ]
+
+ """
+ return CreditRequest.credit_requests_for_user(username)
+
+
+def get_credit_request_status(username, course_key):
+ """Get the credit request status.
+
+ This function returns the status of credit request of user for given course.
+ It returns the latest request status for the any credit provider.
+ The valid status are 'pending', 'approved' or 'rejected'.
+
+ Args:
+ username(str): The username of user
+ course_key(CourseKey): The course locator key
+
+ Returns:
+ A dictionary of credit request user has made if any
+
+ """
+ credit_request = CreditRequest.get_user_request_status(username, course_key)
+ return {
+ "uuid": credit_request.uuid,
+ "timestamp": credit_request.modified,
+ "course_key": credit_request.course.course_key,
+ "provider": {
+ "id": credit_request.provider.provider_id,
+ "display_name": credit_request.provider.display_name
+ },
+ "status": credit_request.status
+ } if credit_request else {}
diff --git a/openedx/core/djangoapps/credit/migrations/0012_remove_m2m_course_and_provider.py b/openedx/core/djangoapps/credit/migrations/0012_remove_m2m_course_and_provider.py
new file mode 100644
index 0000000000..765e44b735
--- /dev/null
+++ b/openedx/core/djangoapps/credit/migrations/0012_remove_m2m_course_and_provider.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Deleting field 'CreditProvider.eligibility_duration'
+ db.delete_column('credit_creditprovider', 'eligibility_duration')
+
+ # Removing M2M table for field providers on 'CreditCourse'
+ db.delete_table(db.shorten_name('credit_creditcourse_providers'))
+
+ # Adding field 'CreditEligibility.deadline'
+ db.add_column('credit_crediteligibility', 'deadline',
+ self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2016, 6, 26, 0, 0)),
+ keep_default=False)
+
+
+ def backwards(self, orm):
+ # Adding field 'CreditProvider.eligibility_duration'
+ db.add_column('credit_creditprovider', 'eligibility_duration',
+ self.gf('django.db.models.fields.PositiveIntegerField')(default=31556970),
+ keep_default=False)
+
+ # Adding M2M table for field providers on 'CreditCourse'
+ m2m_table_name = db.shorten_name('credit_creditcourse_providers')
+ db.create_table(m2m_table_name, (
+ ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+ ('creditcourse', models.ForeignKey(orm['credit.creditcourse'], null=False)),
+ ('creditprovider', models.ForeignKey(orm['credit.creditprovider'], null=False))
+ ))
+ db.create_unique(m2m_table_name, ['creditcourse_id', 'creditprovider_id'])
+
+ # Deleting field 'CreditEligibility.deadline'
+ db.delete_column('credit_crediteligibility', 'deadline')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'credit.creditcourse': {
+ 'Meta': {'object_name': 'CreditCourse'},
+ 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
+ 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'credit.crediteligibility': {
+ 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'deadline': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2016, 6, 26, 0, 0)'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ },
+ 'credit.creditprovider': {
+ 'Meta': {'object_name': 'CreditProvider'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+ 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'})
+ },
+ 'credit.creditrequest': {
+ 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'parameters': ('jsonfield.fields.JSONField', [], {}),
+ 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'})
+ },
+ 'credit.creditrequirement': {
+ 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'criteria': ('jsonfield.fields.JSONField', [], {}),
+ 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+ },
+ 'credit.creditrequirementstatus': {
+ 'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'},
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
+ 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ },
+ 'credit.historicalcreditrequest': {
+ 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
+ u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+ u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
+ 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'parameters': ('jsonfield.fields.JSONField', [], {}),
+ 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
+ },
+ 'credit.historicalcreditrequirementstatus': {
+ 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'},
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
+ u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+ u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
+ 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
+ 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ }
+ }
+
+ complete_apps = ['credit']
diff --git a/openedx/core/djangoapps/credit/migrations/0013_add_provider_status_url.py b/openedx/core/djangoapps/credit/migrations/0013_add_provider_status_url.py
new file mode 100644
index 0000000000..5e70329286
--- /dev/null
+++ b/openedx/core/djangoapps/credit/migrations/0013_add_provider_status_url.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding field 'CreditProvider.provider_status_url'
+ db.add_column('credit_creditprovider', 'provider_status_url',
+ self.gf('django.db.models.fields.URLField')(default='', max_length=200),
+ keep_default=False)
+
+
+ def backwards(self, orm):
+ # Deleting field 'CreditProvider.provider_status_url'
+ db.delete_column('credit_creditprovider', 'provider_status_url')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'credit.creditcourse': {
+ 'Meta': {'object_name': 'CreditCourse'},
+ 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
+ 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'credit.crediteligibility': {
+ 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'deadline': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2016, 6, 26, 0, 0)'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ },
+ 'credit.creditprovider': {
+ 'Meta': {'object_name': 'CreditProvider'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+ 'provider_status_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}),
+ 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'})
+ },
+ 'credit.creditrequest': {
+ 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'parameters': ('jsonfield.fields.JSONField', [], {}),
+ 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'})
+ },
+ 'credit.creditrequirement': {
+ 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'criteria': ('jsonfield.fields.JSONField', [], {}),
+ 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+ },
+ 'credit.creditrequirementstatus': {
+ 'Meta': {'unique_together': "(('username', 'requirement'),)", 'object_name': 'CreditRequirementStatus'},
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
+ 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ },
+ 'credit.historicalcreditrequest': {
+ 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'},
+ 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}),
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
+ u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+ u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
+ 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'parameters': ('jsonfield.fields.JSONField', [], {}),
+ 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
+ },
+ 'credit.historicalcreditrequirementstatus': {
+ 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequirementStatus'},
+ 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
+ u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
+ u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+ u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
+ 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
+ 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
+ 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}),
+ 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditRequirement']"}),
+ 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+ }
+ }
+
+ complete_apps = ['credit']
diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py
index eda112508d..9c6bc34f8c 100644
--- a/openedx/core/djangoapps/credit/models.py
+++ b/openedx/core/djangoapps/credit/models.py
@@ -6,9 +6,15 @@ Credit courses allow students to receive university credit for
successful completion of a course on EdX
"""
+import datetime
from collections import defaultdict
import logging
+import pytz
+
+from django.conf import settings
+from django.core.cache import cache
+from django.dispatch import receiver
from django.db import models, transaction, IntegrityError
from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords
@@ -23,7 +29,8 @@ log = logging.getLogger(__name__)
class CreditProvider(TimeStampedModel):
- """This model represents an institution that can grant credit for a course.
+ """
+ This model represents an institution that can grant credit for a course.
Each provider is identified by unique ID (e.g., 'ASU'). CreditProvider also
includes a `url` where the student will be sent when he/she will try to
@@ -78,14 +85,55 @@ class CreditProvider(TimeStampedModel):
)
)
- # Default is one year
- DEFAULT_ELIGIBILITY_DURATION = 31556970
-
- eligibility_duration = models.PositiveIntegerField(
- help_text=ugettext_lazy(u"Number of seconds to show eligibility message"),
- default=DEFAULT_ELIGIBILITY_DURATION
+ provider_status_url = models.URLField(
+ default="",
+ help_text=ugettext_lazy(
+ "URL from the credit provider where the user can check the status "
+ "of his or her request for credit. This is displayed to students "
+ "*after* they have requested credit."
+ )
)
+ CREDIT_PROVIDERS_CACHE_KEY = "credit.providers.list"
+
+ @classmethod
+ def get_credit_providers(cls):
+ """
+ Retrieve a list of all credit providers, represented
+ as dictionaries.
+ """
+ # Attempt to retrieve the credit provider list from the cache
+ # The cache key is invalidated when the provider list is updated
+ # (a post-save signal handler on the CreditProvider model)
+ # This doesn't happen very often, so we would expect a *very* high
+ # cache hit rate.
+ providers = cache.get(cls.CREDIT_PROVIDERS_CACHE_KEY)
+
+ # Cache miss: construct the provider list and save it in the cache
+ if providers is None:
+ providers = [
+ {
+ "id": provider.provider_id,
+ "display_name": provider.display_name,
+ "status_url": provider.provider_status_url,
+ }
+ for provider in CreditProvider.objects.filter(active=True)
+ ]
+ cache.set(cls.CREDIT_PROVIDERS_CACHE_KEY, providers)
+
+ return providers
+
+ def __unicode__(self):
+ """Unicode representation of the credit provider. """
+ return self.provider_id
+
+
+@receiver(models.signals.post_save, sender=CreditProvider)
+@receiver(models.signals.post_delete, sender=CreditProvider)
+def invalidate_provider_cache(sender, **kwargs): # pylint: disable=unused-argument
+ """Invalidate the cache of credit providers. """
+ cache.delete(CreditProvider.CREDIT_PROVIDERS_CACHE_KEY)
+
class CreditCourse(models.Model):
"""
@@ -94,23 +142,35 @@ class CreditCourse(models.Model):
course_key = CourseKeyField(max_length=255, db_index=True, unique=True)
enabled = models.BooleanField(default=False)
- providers = models.ManyToManyField(CreditProvider)
+
+ CREDIT_COURSES_CACHE_KEY = "credit.courses.set"
@classmethod
def is_credit_course(cls, course_key):
- """Check that given course is credit or not.
+ """
+ Check whether the course has been configured for credit.
Args:
- course_key(CourseKey): The course identifier
+ course_key (CourseKey): Identifier of the course.
Returns:
- Bool True if the course is marked credit else False
+ bool: True iff this is a credit course.
+
"""
- return cls.objects.filter(course_key=course_key, enabled=True).exists()
+ credit_courses = cache.get(cls.CREDIT_COURSES_CACHE_KEY)
+ if credit_courses is None:
+ credit_courses = set(
+ unicode(course.course_key)
+ for course in cls.objects.filter(enabled=True)
+ )
+ cache.set(cls.CREDIT_COURSES_CACHE_KEY, credit_courses)
+
+ return unicode(course_key) in credit_courses
@classmethod
def get_credit_course(cls, course_key):
- """Get the credit course if exists for the given 'course_key'.
+ """
+ Get the credit course if exists for the given 'course_key'.
Args:
course_key(CourseKey): The course identifier
@@ -123,6 +183,17 @@ class CreditCourse(models.Model):
"""
return cls.objects.get(course_key=course_key, enabled=True)
+ def __unicode__(self):
+ """Unicode representation of the credit course. """
+ return unicode(self.course_key)
+
+
+@receiver(models.signals.post_save, sender=CreditCourse)
+@receiver(models.signals.post_delete, sender=CreditCourse)
+def invalidate_credit_courses_cache(sender, **kwargs): # pylint: disable=unused-argument
+ """Invalidate the cache of credit courses. """
+ cache.delete(CreditCourse.CREDIT_COURSES_CACHE_KEY)
+
class CreditRequirement(TimeStampedModel):
"""
@@ -227,7 +298,8 @@ class CreditRequirement(TimeStampedModel):
@classmethod
def get_course_requirement(cls, course_key, namespace, name):
- """Get credit requirement of a given course.
+ """
+ Get credit requirement of a given course.
Args:
course_key(CourseKey): The identifier for a course
@@ -286,7 +358,8 @@ class CreditRequirementStatus(TimeStampedModel):
@classmethod
def get_statuses(cls, requirements, username):
- """ Get credit requirement statuses of given requirement and username
+ """
+ Get credit requirement statuses of given requirement and username
Args:
requirement(CreditRequirement): The identifier for a requirement
@@ -300,7 +373,8 @@ class CreditRequirementStatus(TimeStampedModel):
@classmethod
@transaction.commit_on_success
def add_or_update_requirement_status(cls, username, requirement, status="satisfied", reason=None):
- """Add credit requirement status for given username.
+ """
+ Add credit requirement status for given username.
Args:
username(str): Username of the user
@@ -328,21 +402,23 @@ class CreditEligibility(TimeStampedModel):
username = models.CharField(max_length=255, db_index=True)
course = models.ForeignKey(CreditCourse, related_name="eligibilities")
+ # Deadline for when credit eligibility will expire.
+ # Once eligibility expires, users will no longer be able to purchase
+ # or request credit.
+ # We save the deadline as a database field just in case
+ # we need to override the deadline for particular students.
+ deadline = models.DateTimeField(
+ default=lambda: (
+ datetime.datetime.now(pytz.UTC) + datetime.timedelta(
+ days=getattr(settings, "CREDIT_ELIGIBILITY_EXPIRATION_DAYS", 365)
+ )
+ ),
+ help_text=ugettext_lazy("Deadline for purchasing and requesting credit.")
+ )
+
class Meta(object): # pylint: disable=missing-docstring
unique_together = ('username', 'course')
-
- @classmethod
- def get_user_eligibility(cls, username):
- """Returns the eligibilities of given user.
-
- Args:
- username(str): Username of the user
-
- Returns:
- CreditEligibility queryset for the user
-
- """
- return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers')
+ verbose_name_plural = "Credit eligibilities"
@classmethod
def update_eligibility(cls, requirements, username, course_key):
@@ -378,9 +454,28 @@ class CreditEligibility(TimeStampedModel):
except IntegrityError:
pass
+ @classmethod
+ def get_user_eligibilities(cls, username):
+ """
+ Returns the eligibilities of given user.
+
+ Args:
+ username(str): Username of the user
+
+ Returns:
+ CreditEligibility queryset for the user
+
+ """
+ return cls.objects.filter(
+ username=username,
+ course__enabled=True,
+ deadline__gt=datetime.datetime.now(pytz.UTC)
+ ).select_related('course')
+
@classmethod
def is_user_eligible_for_credit(cls, course_key, username):
- """Check if the given user is eligible for the provided credit course
+ """
+ Check if the given user is eligible for the provided credit course
Args:
course_key(CourseKey): The course identifier
@@ -389,7 +484,19 @@ class CreditEligibility(TimeStampedModel):
Returns:
Bool True if the user eligible for credit course else False
"""
- return cls.objects.filter(course__course_key=course_key, username=username).exists()
+ return cls.objects.filter(
+ course__course_key=course_key,
+ course__enabled=True,
+ username=username,
+ deadline__gt=datetime.datetime.now(pytz.UTC),
+ ).exists()
+
+ def __unicode__(self):
+ """Unicode representation of the credit eligibility. """
+ return u"{user}, {course}".format(
+ user=self.username,
+ course=self.course.course_key,
+ )
class CreditRequest(TimeStampedModel):
@@ -476,7 +583,8 @@ class CreditRequest(TimeStampedModel):
@classmethod
def get_user_request_status(cls, username, course_key):
- """Returns the latest credit request of user against the given course.
+ """
+ Returns the latest credit request of user against the given course.
Args:
username(str): The username of requesting user
@@ -492,3 +600,11 @@ class CreditRequest(TimeStampedModel):
).select_related('course', 'provider').latest()
except cls.DoesNotExist:
return None
+
+ def __unicode__(self):
+ """Unicode representation of a credit request."""
+ return u"{course}, {provider}, {status}".format(
+ course=self.course.course_key,
+ provider=self.provider.provider_id, # pylint: disable=no-member
+ status=self.status,
+ )
diff --git a/openedx/core/djangoapps/credit/signals.py b/openedx/core/djangoapps/credit/signals.py
index 043cb940bd..6c7886857a 100644
--- a/openedx/core/djangoapps/credit/signals.py
+++ b/openedx/core/djangoapps/credit/signals.py
@@ -39,24 +39,24 @@ def listen_for_grade_calculation(sender, username, grade_summary, course_key, de
kwargs : None
"""
- from openedx.core.djangoapps.credit.api import (
- is_credit_course, get_credit_requirement, set_credit_requirement_status
- )
+ # This needs to be imported here to avoid a circular dependency
+ # that can cause syncdb to fail.
+ from openedx.core.djangoapps.credit import api
course_id = CourseKey.from_string(unicode(course_key))
- is_credit = is_credit_course(course_id)
+ is_credit = api.is_credit_course(course_id)
if is_credit:
- requirement = get_credit_requirement(course_id, 'grade', 'grade')
- if requirement:
- criteria = requirement.get('criteria')
+ requirements = api.get_credit_requirements(course_id, namespace='grade')
+ if requirements:
+ criteria = requirements[0].get('criteria')
if criteria:
min_grade = criteria.get('min_grade')
if grade_summary['percent'] >= min_grade:
reason_dict = {'final_grade': grade_summary['percent']}
- set_credit_requirement_status(
+ api.set_credit_requirement_status(
username, course_id, 'grade', 'grade', status="satisfied", reason=reason_dict
)
elif deadline and deadline < timezone.now():
- set_credit_requirement_status(
+ api.set_credit_requirement_status(
username, course_id, 'grade', 'grade', status="failed", reason={}
)
diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py
index 217eb73375..45a2ac1fb4 100644
--- a/openedx/core/djangoapps/credit/tests/test_api.py
+++ b/openedx/core/djangoapps/credit/tests/test_api.py
@@ -1,15 +1,12 @@
"""
Tests for the API functions in the credit app.
"""
-import unittest
import datetime
import ddt
import pytz
from django.test import TestCase
from django.test.utils import override_settings
from django.db import connection, transaction
-from django.core.urlresolvers import reverse
-from django.conf import settings
from opaque_keys.edx.keys import CourseKey
@@ -30,11 +27,7 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirementStatus,
CreditEligibility
)
-from student.models import CourseEnrollment
-from student.views import _create_credit_availability_message
from student.tests.factories import UserFactory
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory
TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6"
@@ -53,6 +46,7 @@ class CreditApiTestBase(TestCase):
PROVIDER_ID = "hogwarts"
PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry"
PROVIDER_URL = "https://credit.example.com/request"
+ PROVIDER_STATUS_URL = "https://credit.example.com/status"
def setUp(self, **kwargs):
super(CreditApiTestBase, self).setUp()
@@ -62,14 +56,13 @@ class CreditApiTestBase(TestCase):
"""Mark the course as a credit """
credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled)
- # Associate a credit provider with the course.
- credit_provider = CreditProvider.objects.create(
+ CreditProvider.objects.create(
provider_id=self.PROVIDER_ID,
display_name=self.PROVIDER_NAME,
provider_url=self.PROVIDER_URL,
+ provider_status_url=self.PROVIDER_STATUS_URL,
enable_integration=True,
)
- credit_course.providers.add(credit_provider)
return credit_course
@@ -223,34 +216,41 @@ class CreditRequirementApiTests(CreditApiTestBase):
is_eligible = api.is_user_eligible_for_credit('abc', credit_course.course_key)
self.assertFalse(is_eligible)
- def test_get_credit_requirement(self):
- self.add_credit_course()
- requirements = [
- {
- "namespace": "grade",
- "name": "grade",
- "display_name": "Grade",
- "criteria": {
- "min_grade": 0.8
- },
- }
- ]
- requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
- self.assertIsNone(requirement)
+ def test_eligibility_expired(self):
+ # Configure a credit eligibility that expired yesterday
+ credit_course = self.add_credit_course()
+ CreditEligibility.objects.create(
+ course=credit_course,
+ username="staff",
+ deadline=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
+ )
- expected_requirement = {
- "course_key": self.course_key,
- "namespace": "grade",
- "name": "grade",
- "display_name": "Grade",
- "criteria": {
- "min_grade": 0.8
- }
- }
- api.set_credit_requirements(self.course_key, requirements)
- requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
- self.assertIsNotNone(requirement)
- self.assertEqual(requirement, expected_requirement)
+ # The user should NOT be eligible for credit
+ is_eligible = api.is_user_eligible_for_credit("staff", credit_course.course_key)
+ self.assertFalse(is_eligible)
+
+ # The eligibility should NOT show up in the user's list of eligibilities
+ eligibilities = api.get_eligibilities_for_user("staff")
+ self.assertEqual(eligibilities, [])
+
+ def test_eligibility_disabled_course(self):
+ # Configure a credit eligibility for a disabled course
+ credit_course = self.add_credit_course()
+ credit_course.enabled = False
+ credit_course.save()
+
+ CreditEligibility.objects.create(
+ course=credit_course,
+ username="staff",
+ )
+
+ # The user should NOT be eligible for credit
+ is_eligible = api.is_user_eligible_for_credit("staff", credit_course.course_key)
+ self.assertFalse(is_eligible)
+
+ # The eligibility should NOT show up in the user's list of eligibilities
+ eligibilities = api.get_eligibilities_for_user("staff")
+ self.assertEqual(eligibilities, [])
def test_set_credit_requirement_status(self):
self.add_credit_course()
@@ -427,6 +427,25 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
# credit requirement that the user has satisfied (minimum grade)
self._configure_credit()
+ def test_get_credit_providers(self):
+ # The provider should show up in the list
+ result = api.get_credit_providers()
+ self.assertEqual(result, [
+ {
+ "id": self.PROVIDER_ID,
+ "display_name": self.PROVIDER_NAME,
+ "status_url": self.PROVIDER_STATUS_URL,
+ }
+ ])
+
+ # Disable the provider; it should be hidden from the list
+ provider = CreditProvider.objects.get()
+ provider.active = False
+ provider.save()
+
+ result = api.get_credit_providers()
+ self.assertEqual(result, [])
+
def test_credit_request(self):
# Initiate a credit request
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
@@ -611,7 +630,6 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
self.assertEqual(requests, [])
def _configure_credit(self):
-
"""
Configure a credit course and its requirements.
@@ -643,123 +661,3 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
"""Check the user's credit status. """
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(statuses[0]["status"], expected_status)
-
-
-@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
-class CreditMessagesTests(ModuleStoreTestCase, CreditApiTestBase):
- """
- Test dashboard messages of credit course.
- """
-
- FINAL_GRADE = 0.8
-
- def setUp(self):
- super(CreditMessagesTests, self).setUp()
- self.student = UserFactory()
- self.student.set_password('test') # pylint: disable=no-member
- self.student.save() # pylint: disable=no-member
-
- self.client.login(username=self.student.username, password='test')
- # New Course
- self.course = CourseFactory.create()
- self.enrollment = CourseEnrollment.enroll(self.student, self.course.id)
-
- def _set_creditcourse(self):
- """
- Mark the course to credit
-
- """
- # pylint: disable=attribute-defined-outside-init
- self.first_provider = CreditProvider.objects.create(
- provider_id="ASU",
- display_name="Arizona State University",
- provider_url="google.com",
- enable_integration=True
- ) # pylint: disable=attribute-defined-outside-init
- self.second_provider = CreditProvider.objects.create(
- provider_id="MIT",
- display_name="Massachusetts Institute of Technology",
- provider_url="MIT.com",
- enable_integration=True
- ) # pylint: disable=attribute-defined-outside-init
-
- self.credit_course = CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=attribute-defined-outside-init
- self.credit_course.providers.add(self.first_provider)
- self.credit_course.providers.add(self.second_provider)
-
- def _set_user_eligible(self, credit_course, username):
- """
- Mark the user eligible for credit for the given credit course.
- """
- self.eligibility = CreditEligibility.objects.create(username=username, course=credit_course) # pylint: disable=attribute-defined-outside-init
-
- def test_user_request_status(self):
- request_status = api.get_credit_request_status(self.student.username, self.course.id)
- self.assertEqual(len(request_status), 0)
-
- def test_credit_messages(self):
- self._set_creditcourse()
-
- requirement = CreditRequirement.objects.create(
- course=self.credit_course,
- namespace="grade",
- name="grade",
- active=True
- )
- status = CreditRequirementStatus.objects.create(
- username=self.student.username,
- requirement=requirement,
- )
- status.status = "satisfied"
- status.reason = {"final_grade": self.FINAL_GRADE}
- status.save()
-
- self._set_user_eligible(self.credit_course, self.student.username)
- response = self.client.get(reverse("dashboard"))
- self.assertContains(
- response,
- "
Congratulations {}, 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='
{provider_name}'.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
- )
diff --git a/openedx/core/djangoapps/credit/tests/test_signals.py b/openedx/core/djangoapps/credit/tests/test_signals.py
index d3ba4aa064..8499a9267d 100644
--- a/openedx/core/djangoapps/credit/tests/test_signals.py
+++ b/openedx/core/djangoapps/credit/tests/test_signals.py
@@ -41,19 +41,17 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
self.client.login(username=self.user.username, password=self.user.password)
# Enable the course for credit
- credit_course = CreditCourse.objects.create(
+ CreditCourse.objects.create(
course_key=self.course.id,
enabled=True,
)
# Configure a credit provider for the course
- credit_provider = CreditProvider.objects.create(
+ CreditProvider.objects.create(
provider_id="ASU",
enable_integration=True,
provider_url="https://credit.example.com/request",
)
- credit_course.providers.add(credit_provider)
- credit_course.save()
requirements = [{
"namespace": "grade",
diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py
index 0ca8bc7f88..dcf907ae7d 100644
--- a/openedx/core/djangoapps/credit/tests/test_views.py
+++ b/openedx/core/djangoapps/credit/tests/test_views.py
@@ -73,13 +73,11 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
)
# Configure a credit provider for the course
- credit_provider = CreditProvider.objects.create(
+ CreditProvider.objects.create(
provider_id=self.PROVIDER_ID,
enable_integration=True,
provider_url=self.PROVIDER_URL,
)
- credit_course.providers.add(credit_provider)
- credit_course.save()
# Add a single credit requirement (final grade)
requirement = CreditRequirement.objects.create(
@@ -256,11 +254,8 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
other_provider_id = "other_provider"
other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d"
- # Create an additional credit provider and associate it with the course.
- credit_course = CreditCourse.objects.get(course_key=self.COURSE_KEY)
- credit_provider = CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
- credit_course.providers.add(credit_provider)
- credit_course.save()
+ # Create an additional credit provider
+ CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True)
# Initiate a credit request with the first provider
request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY)