Commit 987889fc by aamir-khan

ECOM-1524: Display credit availability on the dashboard

parent 13dbfb86
......@@ -7,8 +7,10 @@ import uuid
import time
import json
import warnings
from datetime import timedelta
from collections import defaultdict
from pytz import UTC
from requests import HTTPError
from ipware.ip import get_ip
from django.conf import settings
......@@ -25,21 +27,19 @@ from django.db import IntegrityError, transaction
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
HttpResponseServerError, Http404)
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.translation import ungettext
from django.utils.http import cookie_date, base36_to_int
from django.utils.translation import ugettext as _, get_language
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.http import require_POST, require_GET
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.template.response import TemplateResponse
from ratelimitbackend.exceptions import RateLimitException
from requests import HTTPError
from social.apps.django_app import utils as social_utils
from social.backends import oauth as social_oauth
......@@ -123,13 +123,12 @@ 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")
AUDIT_LOG = logging.getLogger("audit")
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
......@@ -523,6 +522,13 @@ def dashboard(request):
course_enrollment_pairs, course_modes_by_course
)
# Retrieve the course modes for each course
enrolled_courses_dict = {}
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 = ""
......@@ -628,6 +634,7 @@ def dashboard(request):
context = {
'enrollment_message': enrollment_message,
'credit_messages': credit_messages,
'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts,
'message': message,
......@@ -692,6 +699,47 @@ 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.
......
......@@ -1339,6 +1339,12 @@ certificates_web_view_js = [
'js/src/logger.js',
]
credit_web_view_js = [
'js/vendor/jquery.min.js',
'js/vendor/jquery.cookie.js',
'js/src/logger.js',
]
PIPELINE_CSS = {
'style-vendor': {
'source_filenames': [
......@@ -1578,6 +1584,10 @@ PIPELINE_JS = {
'utility': {
'source_filenames': ['js/src/utility.js'],
'output_filename': 'js/utility.js'
},
'credit_wv': {
'source_filenames': credit_web_view_js,
'output_filename': 'js/credit/web_view.js'
}
}
......
......@@ -531,6 +531,10 @@
padding: 0;
}
.purchase_credit {
float: right;
}
.message {
@extend %ui-depth1;
border-radius: 3px;
......
......@@ -83,7 +83,8 @@ 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) %>
<%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_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" />
% endfor
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
......
<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings" />
<%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" />
<%!
import urllib
......@@ -273,7 +273,11 @@ from student.helpers import (
<ul class="messages-list">
% if course.may_certify() and cert_status:
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
% endif
% endif
% if credit_message:
<%include file='_dashboard_credit_information.html' args='credit_message=credit_message'/>
% endif
% if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked:
<div class="message message-status wrapper-message-primary is-shown">
......
<%page args="credit_message" />
<%!
from django.utils.translation import ugettext as _
from course_modes.models import CourseMode
from util.date_utils import get_default_time_display
%>
<%namespace name='static' file='../static_content.html'/>
<%block name="js_extra" args="credit_message">
<%static:js group='credit_wv'/>
<script type="text/javascript">
$(document).ready(function() {
$.ajaxSetup({
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
dataType: 'json'
});
$(".purchase-credit-btn").click(function() {
var data = {
user_id: "${credit_message['user_id']}",
course_id: "${credit_message['course_id']}"
};
Logger.log('edx.credit.shared', data);
});
});
</script>
</%block>
<div class="message message-status is-shown">
<p>
% if credit_message["status"] == "requirements_meet":
<span>
% if credit_message["urgent"]:
${_("{username}, your eligibility for credit expires on {expiry}. Don't miss out!").format(
username=credit_message["user_full_name"],
expiry=get_default_time_display(credit_message["expiry"])
)
}
% else:
${_("{congrats} {username}, You have meet requirements for credit.").format(
congrats="<b>Congratulations</b>",
username=credit_message["user_full_name"]
)
}
% endif
</span>
<span class="purchase_credit"> <a class="btn purchase-credit-btn" href="" target="_blank">${_("Purchase Credit")}</a> </span>
% elif credit_message["status"] == "pending":
${_("Thank you, your payment is complete, your credit is processing. Please see {provider_link} for more information.").format(
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
)
}
% elif credit_message["status"] == "approved":
${_("Thank you, your credit is approved. Please see {provider_link} for more information.").format(
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
)
}
% elif credit_message["status"] == "rejected":
${_("Your credit has been denied. Please contact {provider_link} for more information.").format(
provider_link='<a href="#" target="_blank">{}</a>'.format(credit_message["provider"]["display_name"])
)
}
% endif
</p>
</div>
......@@ -26,6 +26,7 @@ from .exceptions import (
)
from .models import (
CreditCourse,
CreditProvider,
CreditRequirement,
CreditRequirementStatus,
CreditRequest,
......@@ -33,6 +34,7 @@ from .models import (
)
from .signature import signature, get_shared_secret_key
log = logging.getLogger(__name__)
......@@ -211,14 +213,13 @@ def create_credit_request(course_key, provider_id, username):
"""
try:
user_eligibility = CreditEligibility.objects.select_related('course', 'provider').get(
user_eligibility = CreditEligibility.objects.select_related('course').get(
username=username,
course__course_key=course_key,
provider__provider_id=provider_id
course__course_key=course_key
)
credit_course = user_eligibility.course
credit_provider = user_eligibility.provider
except CreditEligibility.DoesNotExist:
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
......@@ -614,3 +615,132 @@ def is_credit_course(course_key):
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 {}
......@@ -307,12 +307,24 @@ class CreditEligibility(TimeStampedModel):
"""
username = models.CharField(max_length=255, db_index=True)
course = models.ForeignKey(CreditCourse, related_name="eligibilities")
provider = models.ForeignKey(CreditProvider, related_name="eligibilities")
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')
@classmethod
def is_user_eligible_for_credit(cls, course_key, username):
"""Check if the given user is eligible for the provided credit course
......@@ -361,6 +373,12 @@ class CreditRequest(TimeStampedModel):
history = HistoricalRecords()
class Meta(object): # pylint: disable=missing-docstring
# Enforce the constraint that each user can have exactly one outstanding
# request to a given provider. Multiple requests use the same UUID.
unique_together = ('username', 'course', 'provider')
get_latest_by = 'created'
@classmethod
def credit_requests_for_user(cls, username):
"""
......@@ -402,7 +420,21 @@ class CreditRequest(TimeStampedModel):
for request in cls.objects.select_related('course', 'provider').filter(username=username)
]
class Meta(object): # pylint: disable=missing-docstring
# Enforce the constraint that each user can have exactly one outstanding
# request to a given provider. Multiple requests use the same UUID.
unique_together = ('username', 'course', 'provider')
@classmethod
def get_user_request_status(cls, username, course_key):
"""Returns the latest credit request of user against the given course.
Args:
username(str): The username of requesting user
course_key(CourseKey): The course identifier
Returns:
CreditRequest if any otherwise None
"""
try:
return cls.objects.filter(
username=username, course__course_key=course_key
).select_related('course', 'provider').latest()
except cls.DoesNotExist:
return None
"""
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
from student.tests.factories import UserFactory
from util.date_utils import from_timestamp
from openedx.core.djangoapps.credit import api
from openedx.core.djangoapps.credit.exceptions import (
......@@ -34,13 +35,20 @@ from openedx.core.djangoapps.credit.api import (
set_credit_requirement_status,
get_credit_requirement
)
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"
@override_settings(CREDIT_PROVIDER_SECRET_KEYS={
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY
"hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY,
"ASU": TEST_CREDIT_PROVIDER_SECRET_KEY,
"MIT": TEST_CREDIT_PROVIDER_SECRET_KEY
})
class CreditApiTestBase(TestCase):
"""
......@@ -212,7 +220,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
def test_is_user_eligible_for_credit(self):
credit_course = self.add_credit_course()
CreditEligibility.objects.create(
course=credit_course, username="staff", provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID)
course=credit_course, username="staff"
)
is_eligible = api.is_user_eligible_for_credit('staff', credit_course.course_key)
self.assertTrue(is_eligible)
......@@ -380,19 +388,26 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
# Initial status should be "pending"
self._assert_credit_status("pending")
credit_request_status = api.get_credit_request_status(self.USER_INFO['username'], self.course_key)
self.assertEqual(credit_request_status["status"], "pending")
# Update the status
api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status)
self._assert_credit_status(status)
credit_request_status = api.get_credit_request_status(self.USER_INFO['username'], self.course_key)
self.assertEqual(credit_request_status["status"], status)
def test_query_counts(self):
# Yes, this is a lot of queries, but this API call is also doing a lot of work :)
# - 1 query: Check the user's eligibility and retrieve the credit course and provider.
# - 1 query: Check the user's eligibility and retrieve the credit course
# - 1 Get the provider of the credit course.
# - 2 queries: Get-or-create the credit request.
# - 1 query: Retrieve user account and profile information from the user API.
# - 1 query: Look up the user's final grade from the credit requirements table.
# - 2 queries: Update the request.
# - 2 queries: Update the history table for the request.
with self.assertNumQueries(9):
with self.assertNumQueries(10):
request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username'])
# - 3 queries: Retrieve and update the request
......@@ -522,12 +537,131 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase):
status.save()
CreditEligibility.objects.create(
username=self.USER_INFO["username"],
course=CreditCourse.objects.get(course_key=self.course_key),
provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID)
username=self.USER_INFO['username'],
course=CreditCourse.objects.get(course_key=self.course_key)
)
def _assert_credit_status(self, expected_status):
"""Check the user's credit status. """
statuses = api.get_credit_requests_for_user(self.USER_INFO["username"])
self.assertEqual(statuses[0]["status"], expected_status)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CreditMessagesTests(ModuleStoreTestCase, CreditApiTestBase):
"""
Test dashboard messages of credit course.
"""
FINAL_GRADE = 0.8
def setUp(self):
super(CreditMessagesTests, self).setUp()
self.student = UserFactory()
self.student.set_password('test') # pylint: disable=no-member
self.student.save() # pylint: disable=no-member
self.client.login(username=self.student.username, password='test')
# New Course
self.course = CourseFactory.create()
self.enrollment = CourseEnrollment.enroll(self.student, self.course.id)
def _set_creditcourse(self):
"""
Mark the course to credit
"""
# pylint: disable=attribute-defined-outside-init
self.first_provider = CreditProvider.objects.create(
provider_id="ASU",
display_name="Arizona State University",
provider_url="google.com",
enable_integration=True
) # pylint: disable=attribute-defined-outside-init
self.second_provider = CreditProvider.objects.create(
provider_id="MIT",
display_name="Massachusetts Institute of Technology",
provider_url="MIT.com",
enable_integration=True
) # pylint: disable=attribute-defined-outside-init
self.credit_course = CreditCourse.objects.create(course_key=self.course.id, enabled=True) # pylint: disable=attribute-defined-outside-init
self.credit_course.providers.add(self.first_provider)
self.credit_course.providers.add(self.second_provider)
def _set_user_eligible(self, credit_course, username):
"""
Mark the user eligible for credit for the given credit course.
"""
self.eligibility = CreditEligibility.objects.create(username=username, course=credit_course) # pylint: disable=attribute-defined-outside-init
def test_user_request_status(self):
request_status = api.get_credit_request_status(self.student.username, self.course.id)
self.assertEqual(len(request_status), 0)
def test_credit_messages(self):
self._set_creditcourse()
requirement = CreditRequirement.objects.create(
course=self.credit_course,
namespace="grade",
name="grade",
active=True
)
status = CreditRequirementStatus.objects.create(
username=self.student.username,
requirement=requirement,
)
status.status = "satisfied"
status.reason = {"final_grade": self.FINAL_GRADE}
status.save()
self._set_user_eligible(self.credit_course, self.student.username)
response = self.client.get(reverse("dashboard"))
self.assertContains(
response,
"<b>Congratulations</b> {}, You have meet requirements for credit.".format(
self.student.get_full_name() # pylint: disable=no-member
)
)
api.create_credit_request(self.course.id, self.first_provider.provider_id, self.student.username)
response = self.client.get(reverse("dashboard"))
self.assertContains(
response,
'Thank you, your payment is complete, your credit is processing. '
'Please see {provider_link} for more information.'.format(
provider_link='<a href="#" target="_blank">{provider_name}</a>'.format(
provider_name=self.first_provider.display_name
)
)
)
def test_query_counts(self):
# This check the number of queries executed while rendering the
# credit message to display on the dashboard.
# - 1 query: Check the user's eligibility.
# - 1 query: Get the user credit requests.
self._set_creditcourse()
requirement = CreditRequirement.objects.create(
course=self.credit_course,
namespace="grade",
name="grade",
active=True
)
status = CreditRequirementStatus.objects.create(
username=self.student.username,
requirement=requirement,
)
status.status = "satisfied"
status.reason = {"final_grade": self.FINAL_GRADE}
status.save()
with self.assertNumQueries(2):
enrollment_dict = {unicode(self.course.id): self.course}
_create_credit_availability_message(
enrollment_dict, self.student
)
......@@ -99,7 +99,6 @@ class CreditProviderViewTests(UrlResetMixin, TestCase):
CreditEligibility.objects.create(
username=self.USERNAME,
course=credit_course,
provider=credit_provider,
)
def test_credit_request_and_response(self):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment