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 +

+
+ ## TODO: set the URL for the link to the provider selection page on the E-Commerce service + ${_("Purchase Course Credit")} +
+ % 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'/> - - - -
-

- % 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)