Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion common/djangoapps/course_modes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class CourseMode(models.Model):
currency = models.CharField(default="usd", max_length=8)

# turn this mode off after the given expiration date
expiration_date = models.DateField(default=None, null=True)
expiration_date = models.DateField(default=None, null=True, blank=True)

DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd')
DEFAULT_MODE_SLUG = 'honor'
Expand Down Expand Up @@ -86,6 +86,15 @@ def mode_for_course(cls, course_id, mode_slug):
else:
return None

@classmethod
def min_course_price_for_currency(cls, course_id, currency):
"""
Returns the minimum price of the course in the appropriate currency over all the course's modes.
If there is no mode found, will return the price of DEFAULT_MODE, which is 0
"""
modes = cls.modes_for_course(course_id)
return min(mode.min_price for mode in modes if mode.currency == currency)

def __unicode__(self):
return u"{} : {}, min={}, prices={}".format(
self.course_id, self.mode_slug, self.min_price, self.suggested_prices
Expand Down
18 changes: 18 additions & 0 deletions common/djangoapps/course_modes/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ def test_modes_for_course_multiple(self):
self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified'))
self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE'))

def test_min_course_price_for_currency(self):
"""
Get the min course price for a course according to currency
"""
# no modes, should get 0
self.assertEqual(0, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))

# create some modes
mode1 = Mode(u'honor', u'Honor Code Certificate', 10, '', 'usd')
mode2 = Mode(u'verified', u'Verified Certificate', 20, '', 'usd')
mode3 = Mode(u'honor', u'Honor Code Certificate', 80, '', 'cny')
set_modes = [mode1, mode2, mode3]
for mode in set_modes:
self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices, mode.currency)

self.assertEqual(10, CourseMode.min_course_price_for_currency(self.course_id, 'usd'))
self.assertEqual(80, CourseMode.min_course_price_for_currency(self.course_id, 'cny'))

def test_modes_for_course_expired(self):
expired_mode, _status = self.create_mode('verified', 'Verified Certificate')
expired_mode.expiration_date = datetime.now(pytz.UTC) + timedelta(days=-1)
Expand Down
40 changes: 39 additions & 1 deletion common/djangoapps/student/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,29 @@

from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from django.test.client import RequestFactory
from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36
from django.core.urlresolvers import reverse

from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE

from mock import Mock, patch
from textwrap import dedent

from student.models import unique_id_for_user, CourseEnrollment
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
change_enrollment)
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string

import shoppingcart

COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012'

Expand Down Expand Up @@ -343,3 +352,32 @@ def test_activation(self):
# for that user/course_id combination
CourseEnrollment.enroll(user, course_id)
self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))


@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class PaidRegistrationTest(ModuleStoreTestCase):
"""
Tests for paid registration functionality (not verified student), involves shoppingcart
"""
# arbitrary constant
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "EDX"

def setUp(self):
# Create course
self.req_factory = RequestFactory()
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course)
self.user = User.objects.create(username="jack", email="jack@fake.edx.org")

@unittest.skipUnless(settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
def test_change_enrollment_add_to_cart(self):
request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id,
'enrollment_action': 'add_to_cart'})
request.user = self.user
response = change_enrollment(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, were there import problems that keep you from using the shorter versions of the names?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there were. So I went back to the safest thing of just importing the module.

14 changes: 14 additions & 0 deletions common/djangoapps/student/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import external_auth.views

from bulk_email.models import Optout
import shoppingcart

import track.views

Expand Down Expand Up @@ -405,6 +406,19 @@ def change_enrollment(request):

return HttpResponse()

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment should make this clear, but this code is to handle the case where AnonymousUser tries to add a course to the cart, so they must register and then try_change_enrollment is called

elif action == "add_to_cart":
# Pass the request handling to shoppingcart.views
# The view in shoppingcart.views performs error handling and logs different errors. But this elif clause
# is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
# This means there's no good way to display error messages to the user. So we log the errors and send
# the user to the shopping cart page always, where they can reasonably discern the status of their cart,
# whether things got added, etc

shoppingcart.views.add_course_to_cart(request, course_id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't catch this before, but I'm not sure it makes sense to push this through the views. Would it be possible to do the same sort of error handling in the models? Fat models, skinny views, etc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure I understand the point you're objecting to here. This particular line of code is calling another view (in shoppingcart).

Do you mean that it should be calling a model function instead?
I have it calling the shopping cart view for code reuse (otherwise it'd have to have all the cases of shoppingcart.views.add_course_to_cart). Also note that in the student app views functions wrap other views all the time (including this function getting called by try_change_enrollment).

Or do you mean that all the clauses in shoppingcart.views.add_course_to_cart should themselves be in the model? In that case I don't think the model function should be returning HttpResponse and its relatives, which doesn't seem like right layer to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or do you mean that the model function PaidCourseRegistration.add_to_order should be raising various subtypes of InvalidCartItem, and shoppingcart.views.add_course_to_cart should be handling those by translating them to HttpResponse codes? I think I'm most amenable to that, though it then doesn't change anything about this particular call.

return HttpResponse(
reverse("shoppingcart.views.show_cart")
)

elif action == "unenroll":
try:
CourseEnrollment.unenroll(user, course_id)
Expand Down
36 changes: 34 additions & 2 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from mock import MagicMock
"""
Tests courseware views.py
"""
from mock import MagicMock, patch
import datetime
import unittest

from django.test import TestCase
from django.http import Http404
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.contrib.auth.models import User, AnonymousUser
from django.test.client import RequestFactory

from django.conf import settings
Expand All @@ -15,12 +19,14 @@
from mitxmako.middleware import MakoMiddleware

from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.factories import CourseFactory

import courseware.views as views
from xmodule.modulestore import Location
from pytz import UTC
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from course_modes.models import CourseMode
import shoppingcart


@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
Expand Down Expand Up @@ -78,6 +84,32 @@ def setUp(self):
chapter = 'Overview'
self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter)

@unittest.skipUnless(settings.MITX_FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
@patch.dict(settings.MITX_FEATURES, {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_course_about_in_cart(self):
in_cart_span = '<span class="add-to-cart">'
# don't mock this course due to shopping cart existence checking
course = CourseFactory.create(org="new", number="unenrolled", display_name="course")
request = self.request_factory.get(reverse('about_course', args=[course.id]))
request.user = AnonymousUser()
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertNotIn(in_cart_span, response.content)

# authenticated user with nothing in cart
request.user = self.user
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertNotIn(in_cart_span, response.content)

# now add the course to the cart
cart = shoppingcart.models.Order.get_cart_for_user(self.user)
shoppingcart.models.PaidCourseRegistration.add_to_order(cart, course.id)
response = views.course_about(request, course.id)
self.assertEqual(response.status_code, 200)
self.assertIn(in_cart_span, response.content)


def test_user_groups(self):
# depreciated function
mock_user = MagicMock()
Expand Down
18 changes: 18 additions & 0 deletions lms/djangoapps/courseware/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
from xmodule.course_module import CourseDescriptor
import shoppingcart

import comment_client

Expand Down Expand Up @@ -604,10 +605,27 @@ def course_about(request, course_id):
show_courseware_link = (has_access(request.user, course, 'load') or
settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'))

# Note: this is a flow for payment for course registration, not the Verified Certificate flow.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be the best place to start review? Basically the course_about page will have an "add-to-cart" button instead of a register button.

registration_price = 0
in_cart = False
reg_then_add_to_cart_link = ""
if settings.MITX_FEATURES.get('ENABLE_PAID_COURSE_REGISTRATION'):
registration_price = CourseMode.min_course_price_for_currency(course_id,
settings.PAID_COURSE_REGISTRATION_CURRENCY[0])
if request.user.is_authenticated():
cart = shoppingcart.models.Order.get_cart_for_user(request.user)
in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_id)

reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format(
reg_url=reverse('register_user'), course_id=course.id)

return render_to_response('courseware/course_about.html',
{'course': course,
'registered': registered,
'course_target': course_target,
'registration_price': registration_price,
'in_cart': in_cart,
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
'show_courseware_link': show_courseware_link})


Expand Down
18 changes: 18 additions & 0 deletions lms/djangoapps/shoppingcart/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""
Exceptions for the shoppingcart app
"""
# (Exception Class Names are sort of self-explanatory, so skipping docstring requirement)
# pylint: disable=C0111

class PaymentException(Exception):
pass

Expand All @@ -8,3 +14,15 @@ class PurchasedCallbackException(PaymentException):

class InvalidCartItem(PaymentException):
pass


class ItemAlreadyInCartException(InvalidCartItem):
pass


class AlreadyEnrolledInCourseException(InvalidCartItem):
pass


class CourseDoesNotExistException(InvalidCartItem):
pass
Loading