diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 0fa7b0f33d..c4f73447c5 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -81,17 +81,7 @@ class CourseMode(models.Model): """ modes_by_course = defaultdict(list) for mode in cls.objects.filter(course_id__in=course_id_list): - modes_by_course[mode.course_id].append( - Mode( - mode.mode_slug, - mode.mode_display_name, - mode.min_price, - mode.suggested_prices, - mode.currency, - mode.expiration_datetime, - mode.description - ) - ) + modes_by_course[mode.course_id].append(mode.to_tuple()) # Assign default modes if nothing available in the database missing_courses = set(course_id_list) - set(modes_by_course.keys()) @@ -130,6 +120,31 @@ class CourseMode(models.Model): return (all_modes, unexpired_modes) + @classmethod + def paid_modes_for_course(cls, course_id): + """ + Returns a list of non-expired modes for a course ID that have a set minimum price. + + If no modes have been set, returns an empty list. + + Args: + course_id (CourseKey): The course to find paid modes for. + + Returns: + A list of CourseModes with a minimum price. + + """ + now = datetime.now(pytz.UTC) + found_course_modes = cls.objects.filter( + Q(course_id=course_id) & + Q(min_price__gt=0) & + ( + Q(expiration_datetime__isnull=True) | + Q(expiration_datetime__gte=now) + ) + ) + return [mode.to_tuple() for mode in found_course_modes] + @classmethod def modes_for_course(cls, course_id): """ @@ -141,15 +156,7 @@ class CourseMode(models.Model): found_course_modes = cls.objects.filter(Q(course_id=course_id) & (Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=now))) - modes = ([Mode( - mode.mode_slug, - mode.mode_display_name, - mode.min_price, - mode.suggested_prices, - mode.currency, - mode.expiration_datetime, - mode.description - ) for mode in found_course_modes]) + modes = ([mode.to_tuple() for mode in found_course_modes]) if not modes: modes = [cls.DEFAULT_MODE] return modes @@ -359,6 +366,24 @@ class CourseMode(models.Model): modes = cls.modes_for_course(course_id) return min(mode.min_price for mode in modes if mode.currency == currency) + def to_tuple(self): + """ + Takes a mode model and turns it into a model named tuple. + + Returns: + A 'Model' namedtuple with all the same attributes as the model. + + """ + return Mode( + self.mode_slug, + self.mode_display_name, + self.min_price, + self.suggested_prices, + self.currency, + self.expiration_datetime, + self.description + ) + def __unicode__(self): return u"{} : {}, min={}, prices={}".format( self.course_id.to_deprecated_string(), self.mode_slug, self.min_price, self.suggested_prices diff --git a/common/djangoapps/student/migrations/0042_grant_sales_admin_roles.py b/common/djangoapps/student/migrations/0042_grant_sales_admin_roles.py new file mode 100644 index 0000000000..ccbb58f7fc --- /dev/null +++ b/common/djangoapps/student/migrations/0042_grant_sales_admin_roles.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models, IntegrityError + + +class Migration(DataMigration): + + def forwards(self, orm): + """Map all Finance Admins to Sales Admins.""" + finance_admins = orm['student.courseaccessrole'].objects.filter(role='finance_admin') + + for finance_admin in finance_admins: + sales_admin = orm['student.courseaccessrole']( + role='sales_admin', + user=finance_admin.user, + org=finance_admin.org, + course_id=finance_admin.course_id, + ) + try: + sales_admin.save() + except IntegrityError: + pass # If sales admin roles exist, continue. + + def backwards(self, orm): + """Remove all sales administrators, as they did not exist before this migration. """ + sales_admins = orm['student.courseaccessrole'].objects.filter(role='sales_admin') + sales_admins.delete() + + 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'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseaccessrole': { + 'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}), + 'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.dashboardconfiguration': { + 'Meta': {'object_name': 'DashboardConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usersignupsource': { + 'Meta': {'object_name': 'UserSignupSource'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] + symmetrical = True diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 5165966acb..8727aad0cf 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -204,13 +204,21 @@ class CourseInstructorRole(CourseRole): class CourseFinanceAdminRole(CourseRole): - """A course Instructor""" + """A course staff member with privileges to review financial data.""" ROLE = 'finance_admin' def __init__(self, *args, **kwargs): super(CourseFinanceAdminRole, self).__init__(self.ROLE, *args, **kwargs) +class CourseSalesAdminRole(CourseRole): + """A course staff member with privileges to perform sales operations. """ + ROLE = 'sales_admin' + + def __init__(self, *args, **kwargs): + super(CourseSalesAdminRole, self).__init__(self.ROLE, *args, **kwargs) + + class CourseBetaTesterRole(CourseRole): """A course Beta Tester""" ROLE = 'beta_testers' diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 14b5eedf1a..a747c37598 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -280,8 +280,9 @@ class DashboardTest(ModuleStoreTestCase): recipient_name='Testw_1', recipient_email='test2@test.com', internal_reference="A", course_id=self.course.id, is_valid=False ) - course_reg_code = shoppingcart.models.CourseRegistrationCode(code="abcde", course_id=self.course.id, - created_by=self.user, invoice=sale_invoice_1) + course_reg_code = shoppingcart.models.CourseRegistrationCode( + code="abcde", course_id=self.course.id, created_by=self.user, invoice=sale_invoice_1, mode_slug='honor' + ) course_reg_code.save() cart = shoppingcart.models.Order.get_cart_for_user(self.user) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index e884c693df..10789d3743 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -13,7 +13,6 @@ import random import requests import shutil import tempfile -from unittest import TestCase from urllib import quote from django.conf import settings @@ -45,8 +44,8 @@ from shoppingcart.models import ( from student.models import ( CourseEnrollment, CourseEnrollmentAllowed, NonExistentCourseError ) -from student.tests.factories import UserFactory -from student.roles import CourseBetaTesterRole +from student.tests.factories import UserFactory, CourseModeFactory +from student.roles import CourseBetaTesterRole, CourseSalesAdminRole, CourseFinanceAdminRole from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -1722,7 +1721,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for i in range(2): course_registration_code = CourseRegistrationCode( code='sale_invoice{}'.format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=self.sale_invoice_1 + created_by=self.instructor, invoice=self.sale_invoice_1, mode_slug='honor' ) course_registration_code.save() @@ -1807,6 +1806,10 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa percentage_discount='10', created_by=self.instructor, is_active=True ) coupon.save() + + # Coupon Redeem Count only visible for Financial Admins. + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + PaidCourseRegistration.add_to_order(self.cart, self.course.id) # apply the coupon code to the item in the cart resp = self.client.post(reverse('shoppingcart.views.use_code'), {'code': coupon.code}) @@ -1838,7 +1841,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for i in range(2): course_registration_code = CourseRegistrationCode( code='sale_invoice{}'.format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=self.sale_invoice_1 + created_by=self.instructor, invoice=self.sale_invoice_1, mode_slug='honor' ) course_registration_code.save() @@ -1853,7 +1856,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for i in range(5): course_registration_code = CourseRegistrationCode( code='sale_invoice{}'.format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=self.sale_invoice_1 + created_by=self.instructor, invoice=self.sale_invoice_1, mode_slug='honor' ) course_registration_code.save() @@ -1872,7 +1875,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for i in range(5): course_registration_code = CourseRegistrationCode( code='qwerty{}'.format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=self.sale_invoice_1 + created_by=self.instructor, invoice=self.sale_invoice_1, mode_slug='honor' ) course_registration_code.save() @@ -1886,7 +1889,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa for i in range(5): course_registration_code = CourseRegistrationCode( code='xyzmn{}'.format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=sale_invoice_2 + created_by=self.instructor, invoice=sale_invoice_2, mode_slug='honor' ) course_registration_code.save() @@ -2994,8 +2997,10 @@ class TestCourseRegistrationCodes(ModuleStoreTestCase): Fixtures. """ self.course = CourseFactory.create() + CourseModeFactory.create(course_id=self.course.id, min_price=50) self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') + CourseSalesAdminRole(self.course.id).add_users(self.instructor) url = reverse('generate_registration_codes', kwargs={'course_id': self.course.id.to_deprecated_string()}) diff --git a/lms/djangoapps/instructor/tests/test_ecommerce.py b/lms/djangoapps/instructor/tests/test_ecommerce.py index 98271f510e..7f9dcd4b78 100644 --- a/lms/djangoapps/instructor/tests/test_ecommerce.py +++ b/lms/djangoapps/instructor/tests/test_ecommerce.py @@ -50,6 +50,8 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): """ response = self.client.get(self.url) self.assertTrue(self.e_commerce_link in response.content) + # Coupons should show up for White Label sites with priced honor modes. + self.assertTrue('Coupons' in response.content) def test_user_has_finance_admin_rights_in_e_commerce_tab(self): response = self.client.get(self.url) @@ -190,7 +192,8 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): self.assertTrue('Please Enter the Integer Value for Coupon Discount' in response.content) course_registration = CourseRegistrationCode( - code='Vs23Ws4j', course_id=self.course.id.to_deprecated_string(), created_by=self.instructor + code='Vs23Ws4j', course_id=unicode(self.course.id), created_by=self.instructor, + mode_slug='honor' ) course_registration.save() @@ -288,3 +291,20 @@ class TestECommerceDashboardViews(ModuleStoreTestCase): data['coupon_id'] = '' # Coupon id is not provided response = self.client.post(update_coupon_url, data=data) self.assertTrue('coupon id not found' in response.content) + + def test_verified_course(self): + """Verify the e-commerce panel shows up for verified courses as well, without Coupons """ + # Change honor mode to verified. + original_mode = CourseMode.objects.get(course_id=self.course.id, mode_slug='honor') + original_mode.delete() + new_mode = CourseMode( + course_id=unicode(self.course.id), mode_slug='verified', + mode_display_name='verified', min_price=10, currency='usd' + ) + new_mode.save() + + # Get the response value, ensure the Coupon section is not included. + response = self.client.get(self.url) + self.assertTrue(self.e_commerce_link in response.content) + # Coupons should show up for White Label sites with priced honor modes. + self.assertFalse('Coupons List' in response.content) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 56f6a80979..a2270e17a9 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -28,6 +28,8 @@ import string # pylint: disable=deprecated-module import random import unicodecsv import urllib +from student import auth +from student.roles import CourseSalesAdminRole from util.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator import datetime import pytz @@ -214,7 +216,7 @@ def require_level(level): def decorator(func): # pylint: disable=missing-docstring def wrapped(*args, **kwargs): # pylint: disable=missing-docstring request = args[0] - course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(kwargs['course_id'])) + course = get_course_by_id(CourseKey.from_string(kwargs['course_id'])) if has_access(request.user, level, course): return func(*args, **kwargs) @@ -224,6 +226,31 @@ def require_level(level): return decorator +def require_sales_admin(func): + """ + Decorator for checking sales administrator access before executing an HTTP endpoint. This decorator + is designed to be used for a request based action on a course. It assumes that there will be a + request object as well as a course_id attribute to leverage to check course level privileges. + + If the user does not have privileges for this operation, this will return HttpResponseForbidden (403). + """ + def wrapped(request, course_id): # pylint: disable=missing-docstring + + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + log.error(u"Unable to find course with course key %s", course_id) + return HttpResponseNotFound() + + access = auth.has_access(request.user, CourseSalesAdminRole(course_key)) + + if access: + return func(request, course_id) + else: + return HttpResponseForbidden() + return wrapped + + EMAIL_INDEX = 0 USERNAME_INDEX = 1 NAME_INDEX = 2 @@ -1024,10 +1051,21 @@ def get_coupon_codes(request, course_id): # pylint: disable=unused-argument return instructor_analytics.csvs.create_csv_response('Coupons.csv', header, data_rows) -def save_registration_code(user, course_id, invoice=None, order=None): +def save_registration_code(user, course_id, mode_slug, invoice=None, order=None): """ recursive function that generate a new code every time and saves in the Course Registration Table if validation check passes + + Args: + user (User): The user creating the course registration codes. + course_id (str): The string representation of the course ID. + mode_slug (str): The Course Mode Slug associated with any enrollment made by these codes. + invoice (Invoice): (Optional) The associated invoice for this code. + order (Order): (Optional) The associated order for this code. + + Returns: + The newly created CourseRegistrationCode. + """ code = random_code_generator() @@ -1038,10 +1076,11 @@ def save_registration_code(user, course_id, invoice=None, order=None): course_registration = CourseRegistrationCode( code=code, - course_id=course_id.to_deprecated_string(), + course_id=unicode(course_id), created_by=user, invoice=invoice, - order=order + order=order, + mode_slug=mode_slug ) try: course_registration.save() @@ -1101,13 +1140,13 @@ def get_registration_codes(request, course_id): # pylint: disable=unused-argume @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_level('staff') +@require_sales_admin @require_POST def generate_registration_codes(request, course_id): """ Respond with csv which contains a summary of all Generated Codes. """ - course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course_id = CourseKey.from_string(course_id) invoice_copy = False # covert the course registration code number into integer @@ -1155,15 +1194,32 @@ def generate_registration_codes(request, course_id): internal_reference=internal_reference, customer_reference_number=customer_reference_number ) + + course = get_course_by_id(course_id, depth=0) + paid_modes = CourseMode.paid_modes_for_course(course_id) + + if len(paid_modes) != 1: + msg = ( + u"Generating Code Redeem Codes for Course '{course_id}', which must have a single paid course mode. " + u"This is a configuration issue. Current course modes with payment options: {paid_modes}" + ).format(course_id=course_id, paid_modes=paid_modes) + log.error(msg) + return HttpResponse( + status=500, + content=_(u"Unable to generate redeem codes because of course misconfiguration.") + ) + + course_mode = paid_modes[0] + course_price = course_mode.min_price + registration_codes = [] - for _ in range(course_code_number): # pylint: disable=redefined-outer-name - generated_registration_code = save_registration_code(request.user, course_id, sale_invoice, order=None) + for __ in range(course_code_number): # pylint: disable=redefined-outer-name + generated_registration_code = save_registration_code( + request.user, course_id, course_mode.slug, sale_invoice, order=None + ) registration_codes.append(generated_registration_code) - site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME) - course = get_course_by_id(course_id, depth=None) - course_honor_mode = CourseMode.mode_for_course(course_id, 'honor') - course_price = course_honor_mode.min_price + site_name = microsite.get_value('SITE_NAME', 'localhost') quantity = course_code_number discount = (float(quantity * course_price) - float(sale_price)) course_url = '{base_url}{course_about}'.format( diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index a32c864f95..8db5957c14 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -4,6 +4,8 @@ Instructor Dashboard Views import logging import datetime +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey import uuid import pytz @@ -15,7 +17,7 @@ from django.views.decorators.cache import cache_control from edxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from django.utils.html import escape -from django.http import Http404 +from django.http import Http404, HttpResponseServerError from django.conf import settings from util.json_request import JsonResponse from mock import patch @@ -33,7 +35,7 @@ from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from student.models import CourseEnrollment from shoppingcart.models import Coupon, PaidCourseRegistration from course_modes.models import CourseMode, CourseModesArchive -from student.roles import CourseFinanceAdminRole +from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course @@ -47,13 +49,19 @@ log = logging.getLogger(__name__) @cache_control(no_cache=True, no_store=True, must_revalidate=True) def instructor_dashboard_2(request, course_id): """ Display the instructor dashboard for a course. """ - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) - course = get_course_by_id(course_key, depth=None) + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + log.error(u"Unable to find course with course key %s while loading the Instructor Dashboard.", course_id) + return HttpResponseServerError() + + course = get_course_by_id(course_key, depth=0) access = { 'admin': request.user.is_staff, 'instructor': has_access(request.user, 'instructor', course), 'finance_admin': CourseFinanceAdminRole(course_key).has_user(request.user), + 'sales_admin': CourseSalesAdminRole(course_key).has_user(request.user), 'staff': has_access(request.user, 'staff', course), 'forum_admin': has_forum_access( request.user, course_key, FORUM_ROLE_ADMINISTRATOR @@ -72,10 +80,18 @@ def instructor_dashboard_2(request, course_id): ] #check if there is corresponding entry in the CourseMode Table related to the Instructor Dashboard course - course_honor_mode = CourseMode.mode_for_course(course_key, 'honor') course_mode_has_price = False - if course_honor_mode and course_honor_mode.min_price > 0: + paid_modes = CourseMode.paid_modes_for_course(course_key) + if len(paid_modes) == 1: course_mode_has_price = True + elif len(paid_modes) > 1: + log.error( + u"Course %s has %s course modes with payment options. Course must only have " + u"one paid course mode to enable eCommerce options.", + unicode(course_key), len(paid_modes) + ) + + is_white_label = CourseMode.is_white_label(course_key) if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') and access['instructor']): sections.insert(3, _section_extensions(course)) @@ -89,8 +105,8 @@ def instructor_dashboard_2(request, course_id): sections.append(_section_metrics(course, access)) # Gate access to Ecommerce tab - if course_mode_has_price: - sections.append(_section_e_commerce(course, access)) + if course_mode_has_price and (access['finance_admin'] or access['sales_admin']): + sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label)) disable_buttons = not _is_small_course(course_key) @@ -126,15 +142,13 @@ def instructor_dashboard_2(request, course_id): ## section_display_name will be used to generate link titles in the nav bar. -def _section_e_commerce(course, access): +def _section_e_commerce(course, access, paid_mode, coupons_enabled): """ Provide data for the corresponding dashboard section """ course_key = course.id coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active') - course_price = None + course_price = paid_mode.min_price + total_amount = None - course_honor_mode = CourseMode.mode_for_course(course_key, 'honor') - if course_honor_mode and course_honor_mode.min_price > 0: - course_price = course_honor_mode.min_price if access['finance_admin']: total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(course_key) @@ -160,6 +174,8 @@ def _section_e_commerce(course, access): 'set_course_mode_url': reverse('set_course_mode_price', kwargs={'course_id': unicode(course_key)}), 'download_coupon_codes_url': reverse('get_coupon_codes', kwargs={'course_id': unicode(course_key)}), 'coupons': coupons, + 'sales_admin': access['sales_admin'], + 'coupons_enabled': coupons_enabled, 'course_price': course_price, 'total_amount': total_amount } diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 794e4aacc3..a532ccaa35 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -6,7 +6,8 @@ import json from student.models import CourseEnrollment from django.core.urlresolvers import reverse from mock import patch -from student.tests.factories import UserFactory +from student.roles import CourseSalesAdminRole +from student.tests.factories import UserFactory, CourseModeFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey from shoppingcart.models import ( CourseRegistrationCode, RegistrationCodeRedemption, Order, @@ -149,7 +150,7 @@ class TestCourseSaleRecordsAnalyticsBasic(ModuleStoreTestCase): for i in range(5): course_code = CourseRegistrationCode( code="test_code{}".format(i), course_id=self.course.id.to_deprecated_string(), - created_by=self.instructor, invoice=sale_invoice + created_by=self.instructor, invoice=sale_invoice, mode_slug='honor' ) course_code.save() @@ -259,6 +260,13 @@ class TestCourseRegistrationCodeAnalyticsBasic(ModuleStoreTestCase): self.course = CourseFactory.create() self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') + CourseSalesAdminRole(self.course.id).add_users(self.instructor) + + # Create a paid course mode. + mode = CourseModeFactory.create() + mode.course_id = self.course.id + mode.min_price = 1 + mode.save() url = reverse('generate_registration_codes', kwargs={'course_id': self.course.id.to_deprecated_string()}) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index c4170e629f..a93c89bcec 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -37,6 +37,11 @@ class MultipleCouponsNotAllowedException(InvalidCartItem): pass +class RedemptionCodeError(Exception): + """An error occurs while processing redemption codes. """ + pass + + class ReportException(Exception): pass diff --git a/lms/djangoapps/shoppingcart/migrations/0024_auto__add_field_courseregistrationcode_mode_slug.py b/lms/djangoapps/shoppingcart/migrations/0024_auto__add_field_courseregistrationcode_mode_slug.py new file mode 100644 index 0000000000..105e441fb3 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0024_auto__add_field_courseregistrationcode_mode_slug.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +import 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 'CourseRegistrationCode.mode_slug' + db.add_column('shoppingcart_courseregistrationcode', 'mode_slug', + self.gf('django.db.models.fields.CharField')(max_length=100, null=True), + keep_default=False) + + def backwards(self, orm): + # Deleting field 'CourseRegistrationCode.mode_slug' + db.delete_column('shoppingcart_courseregistrationcode', 'mode_slug') + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.coupon': { + 'Meta': {'object_name': 'Coupon'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 12, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'expiration_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.courseregcodeitem': { + 'Meta': {'object_name': 'CourseRegCodeItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.courseregcodeitemannotation': { + 'Meta': {'object_name': 'CourseRegCodeItemAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 12, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']", 'null': 'True'}), + 'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100', 'null': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'purchase_order'", 'null': 'True', 'to': "orm['shoppingcart.Order']"}) + }, + 'shoppingcart.donation': { + 'Meta': {'object_name': 'Donation', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'donation_type': ('django.db.models.fields.CharField', [], {'default': "'general'", 'max_length': '32'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.donationconfiguration': { + 'Meta': {'object_name': 'DonationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.invoice': { + 'Meta': {'object_name': 'Invoice'}, + 'address_line_1': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'address_line_2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'address_line_3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'internal_reference': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'is_valid': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'total_amount': ('django.db.models.fields.FloatField', [], {}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '15', 'null': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order_type': ('django.db.models.fields.CharField', [], {'default': "'personal'", 'max_length': '32'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.registrationcoderedemption': { + 'Meta': {'object_name': 'RegistrationCodeRedemption'}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']", 'null': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 1, 12, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'registration_code': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCode']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 2e4908cca7..4f592bcefb 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -745,6 +745,7 @@ class CourseRegistrationCode(models.Model): created_at = models.DateTimeField(default=datetime.now(pytz.utc)) order = models.ForeignKey(Order, db_index=True, null=True, related_name="purchase_order") invoice = models.ForeignKey(Invoice, null=True) + mode_slug = models.CharField(max_length=100, null=True) class RegistrationCodeRedemption(models.Model): @@ -1135,7 +1136,7 @@ class CourseRegCodeItem(OrderItem): # is in another PR (for another feature) from instructor.views.api import save_registration_code for i in range(total_registration_codes): # pylint: disable=unused-variable - save_registration_code(self.user, self.course_id, invoice=None, order=self.order) + save_registration_code(self.user, self.course_id, self.mode, invoice=None, order=self.order) log.info("Enrolled {0} in paid course {1}, paid ${2}" .format(self.user.email, self.course_id, self.line_cost)) # pylint: disable=no-member diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 7e203116f3..6f6b72f8c4 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -28,6 +28,7 @@ from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, mixed_store_config ) from xmodule.modulestore.tests.factories import CourseFactory +from student.roles import CourseSalesAdminRole from util.date_utils import get_default_time_display from util.testing import UrlResetMixin @@ -37,7 +38,7 @@ from shoppingcart.models import ( Coupon, CourseRegistrationCode, RegistrationCodeRedemption, DonationConfiguration ) -from student.tests.factories import UserFactory, AdminFactory +from student.tests.factories import UserFactory, AdminFactory, CourseModeFactory from courseware.tests.factories import InstructorFactory from student.models import CourseEnrollment from course_modes.models import CourseMode @@ -104,6 +105,10 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.cart = Order.get_cart_for_user(self.user) self.addCleanup(patcher.stop) + self.now = datetime.now(pytz.UTC) + self.yesterday = self.now - timedelta(days=1) + self.tomorrow = self.now + timedelta(days=1) + def get_discount(self, cost): """ This method simple return the discounted amount @@ -119,13 +124,27 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): percentage_discount=self.percentage_discount, created_by=self.user, is_active=is_active) coupon.save() - def add_reg_code(self, course_key): + def add_reg_code(self, course_key, mode_slug='honor'): """ add dummy registration code into models """ - course_reg_code = CourseRegistrationCode(code=self.reg_code, course_id=course_key, created_by=self.user) + course_reg_code = CourseRegistrationCode( + code=self.reg_code, course_id=course_key, created_by=self.user, mode_slug=mode_slug + ) course_reg_code.save() + def _add_course_mode(self, min_price=50, mode_slug='honor', expiration_date=None): + """ + Adds a course mode to the test course. + """ + mode = CourseModeFactory.create() + mode.course_id = self.course.id + mode.min_price = min_price + mode.mode_slug = mode_slug + mode.expiration_date = expiration_date + mode.save() + return mode + def add_course_to_user_cart(self, course_key): """ adding course to user cart @@ -392,6 +411,31 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 404) self.assertIn("Cart item quantity should not be greater than 1 when applying activation code", resp.content) + @ddt.data(True, False) + def test_reg_code_uses_associated_mode(self, expired_mode): + """Tests the use of reg codes on verified courses, expired or active. """ + course_key = self.course_key.to_deprecated_string() + expiration_date = self.yesterday if expired_mode else self.tomorrow + self._add_course_mode(mode_slug='verified', expiration_date=expiration_date) + self.add_reg_code(course_key, mode_slug='verified') + self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('register_code_redemption', args=[self.reg_code]), HTTP_HOST='localhost') + self.assertEqual(resp.status_code, 200) + self.assertIn(self.course.display_name, resp.content) + + @ddt.data(True, False) + def test_reg_code_uses_unknown_mode(self, expired_mode): + """Tests the use of reg codes on verified courses, expired or active. """ + course_key = self.course_key.to_deprecated_string() + expiration_date = self.yesterday if expired_mode else self.tomorrow + self._add_course_mode(mode_slug='verified', expiration_date=expiration_date) + self.add_reg_code(course_key, mode_slug='bananas') + self.add_course_to_user_cart(self.course_key) + resp = self.client.post(reverse('register_code_redemption', args=[self.reg_code]), HTTP_HOST='localhost') + self.assertEqual(resp.status_code, 200) + self.assertIn(self.course.display_name, resp.content) + self.assertIn("error processing your redeem code", resp.content) + def test_course_discount_for_valid_active_coupon_code(self): self.add_coupon(self.course_key, True, self.coupon_code) @@ -1472,6 +1516,10 @@ class RegistrationCodeRedemptionCourseEnrollment(ModuleStoreTestCase): cache.clear() instructor = InstructorFactory(course_key=self.course_key) self.client.login(username=instructor.username, password='test') + + # Registration Code Generation only available to Sales Admins. + CourseSalesAdminRole(self.course.id).add_users(instructor) + url = reverse('generate_registration_codes', kwargs={'course_id': self.course.id.to_deprecated_string()}) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f717b182cd..1ad35a7a70 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -10,6 +10,7 @@ from django.http import ( HttpResponseBadRequest, HttpResponseForbidden, Http404 ) from django.utils.translation import ugettext as _ +from course_modes.models import CourseMode from util.json_request import JsonResponse from django.views.decorators.http import require_POST, require_http_methods from django.core.urlresolvers import reverse @@ -26,12 +27,13 @@ from courseware.courses import get_course_by_id from courseware.views import registered_for_course from config_models.decorators import require_config from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport -from student.models import CourseEnrollment +from student.models import CourseEnrollment, EnrollmentClosedError, CourseFullError, \ + AlreadyEnrolledError from .exceptions import ( ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, MultipleCouponsNotAllowedException, InvalidCartItem, - ItemNotFoundInCartException + ItemNotFoundInCartException, RedemptionCodeError ) from .models import ( Order, OrderTypes, @@ -307,7 +309,6 @@ def get_reg_code_validity(registration_code, request, limiter): @require_http_methods(["GET", "POST"]) @login_required -@enforce_shopping_cart_enabled def register_code_redemption(request, registration_code): """ This view allows the student to redeem the registration code @@ -338,8 +339,14 @@ def register_code_redemption(request, registration_code): elif request.method == "POST": reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity(registration_code, request, limiter) - course = get_course_by_id(getattr(course_registration, 'course_id'), depth=0) + context = { + 'reg_code': registration_code, + 'site_name': site_name, + 'course': course, + 'reg_code_is_valid': reg_code_is_valid, + 'reg_code_already_redeemed': reg_code_already_redeemed, + } if reg_code_is_valid and not reg_code_already_redeemed: # remove the course from the cart if it was added there. cart = Order.get_cart_for_user(request.user) @@ -355,23 +362,30 @@ def register_code_redemption(request, registration_code): #now redeem the reg code. redemption = RegistrationCodeRedemption.create_invoice_generated_registration_redemption(course_registration, request.user) - redemption.course_enrollment = CourseEnrollment.enroll(request.user, course.id) - redemption.save() - context = { - 'redemption_success': True, - 'reg_code': registration_code, - 'site_name': site_name, - 'course': course, - } + try: + kwargs = {} + if course_registration.mode_slug is not None: + if CourseMode.mode_for_course(course.id, course_registration.mode_slug): + kwargs['mode'] = course_registration.mode_slug + else: + raise RedemptionCodeError() + redemption.course_enrollment = CourseEnrollment.enroll(request.user, course.id, **kwargs) + redemption.save() + context['redemption_success'] = True + except RedemptionCodeError: + context['redeem_code_error'] = True + context['redemption_success'] = False + except EnrollmentClosedError: + context['enrollment_closed'] = True + context['redemption_success'] = False + except CourseFullError: + context['course_full'] = True + context['redemption_success'] = False + except AlreadyEnrolledError: + context['registered_for_course'] = True + context['redemption_success'] = False else: - context = { - 'reg_code_is_valid': reg_code_is_valid, - 'reg_code_already_redeemed': reg_code_already_redeemed, - 'redemption_success': False, - 'reg_code': registration_code, - 'site_name': site_name, - 'course': course, - } + context['redemption_success'] = False return render_to_response(template_to_render, context) diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index 68c96b8cae..f884cdf169 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -294,7 +294,7 @@ } } - input[type="submit"] { + input[type="submit"], button { text-transform: none; width: 450px; height: 70px; diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html index 0930e711bb..b63b6cfa1f 100644 --- a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -12,9 +12,11 @@

${_('Registration Codes')}

+ %if section_data['sales_admin']: ${_('Click to generate Registration Codes')} ${_('Generate Registration Codes')} + %endif

${_('Click to generate a CSV file of all Course Registrations Codes:')}

@@ -43,6 +45,7 @@
+ %if section_data['coupons_enabled']:

${_("Course Price")}

@@ -53,8 +56,9 @@
+ %endif - %if section_data['access']['finance_admin'] is True: + %if section_data['access']['finance_admin']:

${_("Sales")}

@@ -79,6 +83,7 @@
%endif + %if section_data['coupons_enabled']:

${_("Coupons List")}

@@ -132,6 +137,7 @@
+ %endif diff --git a/lms/templates/shoppingcart/registration_code_receipt.html b/lms/templates/shoppingcart/registration_code_receipt.html new file mode 100644 index 0000000000..7d291e2538 --- /dev/null +++ b/lms/templates/shoppingcart/registration_code_receipt.html @@ -0,0 +1,96 @@ +<%! +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from courseware.courses import course_image_url, get_course_about_section +%> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> +<%block name="pagetitle">${_("Confirm Enrollment")} + +<%block name="content"> +
+
+ +
+
+ ${course.display_number_with_default | h} ${get_course_about_section(course, 'title')} Cover Image +
+
+
${_("Confirm your enrollment for:")} + ${_("course dates")} + +
+
+ +
+

+ ${_("{course_name}").format(course_name=course.display_name)} + ${_("{start_date}").format(start_date=course.start_datetime_text())} - ${_("{end_date}").format(end_date=course.end_datetime_text())} + +

+
+
+
+ % if reg_code_already_redeemed: + <% dashboard_url = reverse('dashboard')%> +

+ ${_("You've clicked a link for an enrollment code that has already been used." + " Check your course dashboard to see if you're enrolled in the course," + " or contact your company's administrator.").format(dashboard_url=dashboard_url)} +

+ % elif redemption_success: +

+ ${_("You have successfully enrolled in {course_name}." + " This course has now been added to your dashboard.").format(course_name=course.display_name)} +

+ % elif registered_for_course: + <% dashboard_url = reverse('dashboard')%> +

+ ${_("You're already registered for this course." + " Visit your dashboard to see the course.").format(dashboard_url=dashboard_url)} +

+ % elif course_full: + <% dashboard_url = reverse('dashboard')%> +

+ ${_("The course you are registering for is full.")} +

+ % elif enrollment_closed: + <% dashboard_url = reverse('dashboard')%> +

+ ${_("The course you are registering for is closed.")} +

+ % elif redeem_code_error: + <% dashboard_url = reverse('dashboard')%> +

+ ${_("There was an error processing your redeem code.")} +

+ % else: +

+ ${_("You're about to activate an enrollment code for {course_name} by {site_name}. " + "This code can only be used one time, so you should only activate this code if you're its intended" + " recipient.").format(course_name=course.display_name, site_name=site_name)} +

+ % endif +
+
+ % if not reg_code_already_redeemed: + %if redemption_success: + <% course_url = reverse('info', args=[course.id.to_deprecated_string()]) %> + ${_("View Course")} + %elif not registered_for_course: + + + + + %endif + %endif +
+
+
+ diff --git a/lms/templates/shoppingcart/registration_code_redemption.html b/lms/templates/shoppingcart/registration_code_redemption.html index f21f0b1b2f..e8c244a0b2 100644 --- a/lms/templates/shoppingcart/registration_code_redemption.html +++ b/lms/templates/shoppingcart/registration_code_redemption.html @@ -73,6 +73,10 @@ from courseware.courses import course_image_url, get_course_about_section link_end='', )}

+ % elif redeem_code_error: +

+ ${_( "There was an error processing your redeem code.")} +

% else:

${_( @@ -90,12 +94,13 @@ from courseware.courses import course_image_url, get_course_about_section % if not reg_code_already_redeemed: %if redemption_success: - ${_("View Dashboard     ▸")} + ${_("View Dashboard")} %elif not registered_for_course:

- +
%endif %endif