diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 561c078b3b..7a5e711f44 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -33,6 +33,7 @@ class CourseMode(models.Model): currency = models.CharField(default="usd", max_length=8) DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd') + DEFAULT_MODE_SLUG = 'honor' class Meta: """ meta attributes of this model """ @@ -51,3 +52,18 @@ class CourseMode(models.Model): if not modes: modes = [cls.DEFAULT_MODE] return modes + + @classmethod + def mode_for_course(cls, course_id, mode_slug): + """ + Returns the mode for the course corresponding to mode_slug. + + If this particular mode is not set for the course, returns None + """ + modes = cls.modes_for_course(course_id) + + matched = [m for m in modes if m.slug == mode_slug] + if matched: + return matched[0] + else: + return None diff --git a/common/djangoapps/course_modes/tests.py b/common/djangoapps/course_modes/tests.py index 907797bf17..1fba5ca197 100644 --- a/common/djangoapps/course_modes/tests.py +++ b/common/djangoapps/course_modes/tests.py @@ -60,3 +60,6 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course(self.course_id) self.assertEqual(modes, set_modes) + self.assertEqual(mode1, CourseMode.mode_for_course(self.course_id, u'honor')) + self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified')) + self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE')) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 6b5897e97d..3d977b28c9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -827,9 +827,6 @@ class CourseEnrollment(models.Model): @classmethod def is_enrolled(cls, user, course_id): """ - Remove the user from a given course. If the relevant `CourseEnrollment` - object doesn't exist, we log an error but don't throw an exception. - Returns True if the user is enrolled in the course (the entry must exist and it must have `is_active=True`). Otherwise, returns False. diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 4555395fef..10bcf4a4e3 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -147,51 +147,51 @@ class TextbookList(List): class CourseFields(object): textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", - default=[], scope=Scope.content) + default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", - # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the - # time of first invocation of this stmt on the server - default=datetime.fromtimestamp(0, UTC()), - scope=Scope.settings) + # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the + # time of first invocation of this stmt on the server + default=datetime.fromtimestamp(0, UTC()), + scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Dict(help="Grading policy definition for this class", - default={"GRADER": [ - { - "type": "Homework", - "min_count": 12, - "drop_count": 2, - "short_label": "HW", - "weight": 0.15 - }, - { - "type": "Lab", - "min_count": 12, - "drop_count": 2, - "weight": 0.15 - }, - { - "type": "Midterm Exam", - "short_label": "Midterm", - "min_count": 1, - "drop_count": 0, - "weight": 0.3 - }, - { - "type": "Final Exam", - "short_label": "Final", - "min_count": 1, - "drop_count": 0, - "weight": 0.4 - } - ], - "GRADE_CUTOFFS": { - "Pass": 0.5 - }}, - scope=Scope.content) + default={"GRADER": [ + { + "type": "Homework", + "min_count": 12, + "drop_count": 2, + "short_label": "HW", + "weight": 0.15 + }, + { + "type": "Lab", + "min_count": 12, + "drop_count": 2, + "weight": 0.15 + }, + { + "type": "Midterm Exam", + "short_label": "Midterm", + "min_count": 1, + "drop_count": 0, + "weight": 0.3 + }, + { + "type": "Final Exam", + "short_label": "Final", + "min_count": 1, + "drop_count": 0, + "weight": 0.4 + } + ], + "GRADE_CUTOFFS": { + "Pass": 0.5 + }}, + scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) @@ -201,7 +201,7 @@ class CourseFields(object): discussion_topics = Dict( help="Map of topics names to ids", scope=Scope.settings - ) + ) testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) @@ -216,128 +216,124 @@ class CourseFields(object): advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) has_children = True checklists = List(scope=Scope.settings, - default=[ - {"short_description" : "Getting Started With Studio", - "items" : [{"short_description": "Add Course Team Members", - "long_description": "Grant your collaborators permission to edit your course so you can work together.", - "is_checked": False, - "action_url": "ManageUsers", - "action_text": "Edit Course Team", - "action_external": False}, - {"short_description": "Set Important Dates for Your Course", - "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Details & Schedule", - "action_external": False}, - {"short_description": "Draft Your Course's Grading Policy", - "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", - "is_checked": False, - "action_url": "SettingsGrading", - "action_text": "Edit Grading Settings", - "action_external": False}, - {"short_description": "Explore the Other Studio Checklists", - "long_description": "Discover other available course authoring tools, and find help when you need it.", - "is_checked": False, - "action_url": "", - "action_text": "", - "action_external": False}] - }, - {"short_description" : "Draft a Rough Course Outline", - "items" : [{"short_description": "Create Your First Section and Subsection", - "long_description": "Use your course outline to build your first Section and Subsection.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Set Section Release Dates", - "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Designate a Subsection as Graded", - "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Reordering Course Content", - "long_description": "Use drag and drop to reorder the content in your course.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Renaming Sections", - "long_description": "Rename Sections by clicking the Section name from the Course Outline.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Deleting Course Content", - "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Add an Instructor-Only Section to Your Outline", - "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}] - }, - {"short_description" : "Explore edX's Support Tools", - "items" : [{"short_description": "Explore the Studio Help Forum", - "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", - "is_checked": False, - "action_url": "http://help.edge.edx.org/", - "action_text": "Visit Studio Help", - "action_external": True}, - {"short_description": "Enroll in edX 101", - "long_description": "Register for edX 101, edX's primer for course creation.", - "is_checked": False, - "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", - "action_text": "Register for edX 101", - "action_external": True}, - {"short_description": "Download the Studio Documentation", - "long_description": "Download the searchable Studio reference documentation in PDF form.", - "is_checked": False, - "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", - "action_text": "Download Documentation", - "action_external": True}] - }, - {"short_description" : "Draft Your Course About Page", - "items" : [{"short_description": "Draft a Course Description", - "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Staff Bios", - "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course FAQs", - "long_description": "Include a short list of frequently asked questions about your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course Prerequisites", - "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}] - } + default=[ + {"short_description": "Getting Started With Studio", + "items": [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": False, + "action_url": "ManageUsers", + "action_text": "Edit Course Team", + "action_external": False}, + {"short_description": "Set Important Dates for Your Course", + "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Details & Schedule", + "action_external": False}, + {"short_description": "Draft Your Course's Grading Policy", + "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", + "is_checked": False, + "action_url": "SettingsGrading", + "action_text": "Edit Grading Settings", + "action_external": False}, + {"short_description": "Explore the Other Studio Checklists", + "long_description": "Discover other available course authoring tools, and find help when you need it.", + "is_checked": False, + "action_url": "", + "action_text": "", + "action_external": False}]}, + {"short_description": "Draft a Rough Course Outline", + "items": [{"short_description": "Create Your First Section and Subsection", + "long_description": "Use your course outline to build your first Section and Subsection.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Set Section Release Dates", + "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Designate a Subsection as Graded", + "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Reordering Course Content", + "long_description": "Use drag and drop to reorder the content in your course.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Renaming Sections", + "long_description": "Rename Sections by clicking the Section name from the Course Outline.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Deleting Course Content", + "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Add an Instructor-Only Section to Your Outline", + "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}]}, + {"short_description": "Explore edX's Support Tools", + "items": [{"short_description": "Explore the Studio Help Forum", + "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", + "is_checked": False, + "action_url": "http://help.edge.edx.org/", + "action_text": "Visit Studio Help", + "action_external": True}, + {"short_description": "Enroll in edX 101", + "long_description": "Register for edX 101, edX's primer for course creation.", + "is_checked": False, + "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", + "action_text": "Register for edX 101", + "action_external": True}, + {"short_description": "Download the Studio Documentation", + "long_description": "Download the searchable Studio reference documentation in PDF form.", + "is_checked": False, + "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", + "action_text": "Download Documentation", + "action_external": True}]}, + {"short_description": "Draft Your Course About Page", + "items": [{"short_description": "Draft a Course Description", + "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Staff Bios", + "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course FAQs", + "long_description": "Include a short list of frequently asked questions about your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course Prerequisites", + "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}]} ]) info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", - scope=Scope.settings) + scope=Scope.settings) course_image = String( help="Filename of the course image", scope=Scope.settings, diff --git a/lms/djangoapps/shoppingcart/__init__.py b/lms/djangoapps/shoppingcart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py new file mode 100644 index 0000000000..029dc079bb --- /dev/null +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -0,0 +1,10 @@ +class PaymentException(Exception): + pass + + +class PurchasedCallbackException(PaymentException): + pass + + +class InvalidCartItem(PaymentException): + pass diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py new file mode 100644 index 0000000000..24ffeb1e59 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -0,0 +1,166 @@ +# -*- 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 model 'Order' + db.create_table('shoppingcart_order', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + ('bill_to_first', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_last', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_street1', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_street2', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_city', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_state', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_postalcode', self.gf('django.db.models.fields.CharField')(max_length=16, blank=True)), + ('bill_to_country', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_ccnum', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_cardtype', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)), + ('processor_reply_dump', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('shoppingcart', ['Order']) + + # Adding model 'OrderItem' + db.create_table('shoppingcart_orderitem', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('order', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Order'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('qty', self.gf('django.db.models.fields.IntegerField')(default=1)), + ('unit_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), + ('line_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), + ('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), + )) + db.send_create_signal('shoppingcart', ['OrderItem']) + + # Adding model 'PaidCourseRegistration' + db.create_table('shoppingcart_paidcourseregistration', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + )) + db.send_create_signal('shoppingcart', ['PaidCourseRegistration']) + + # Adding model 'CertificateItem' + db.create_table('shoppingcart_certificateitem', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), + ('mode', self.gf('django.db.models.fields.SlugField')(max_length=50)), + )) + db.send_create_signal('shoppingcart', ['CertificateItem']) + + def backwards(self, orm): + # Deleting model 'Order' + db.delete_table('shoppingcart_order') + + # Deleting model 'OrderItem' + db.delete_table('shoppingcart_orderitem') + + # Deleting model 'PaidCourseRegistration' + db.delete_table('shoppingcart_paidcourseregistration') + + # Deleting model 'CertificateItem' + db.delete_table('shoppingcart_certificateitem') + + 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': ('django.db.models.fields.CharField', [], {'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.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'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_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'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + '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_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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/migrations/0002_auto__add_field_paidcourseregistration_mode.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py new file mode 100644 index 0000000000..97f46aee81 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py @@ -0,0 +1,112 @@ +# -*- 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 'PaidCourseRegistration.mode' + db.add_column('shoppingcart_paidcourseregistration', 'mode', + self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50), + keep_default=False) + + def backwards(self, orm): + # Deleting field 'PaidCourseRegistration.mode' + db.delete_column('shoppingcart_paidcourseregistration', 'mode') + + 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': ('django.db.models.fields.CharField', [], {'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.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'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_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'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + '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_id': ('django.db.models.fields.CharField', [], {'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'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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/migrations/0003_auto__del_field_orderitem_line_cost.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py new file mode 100644 index 0000000000..080a6f1af2 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py @@ -0,0 +1,111 @@ +# -*- 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): + # Deleting field 'OrderItem.line_cost' + db.delete_column('shoppingcart_orderitem', 'line_cost') + + def backwards(self, orm): + # Adding field 'OrderItem.line_cost' + db.add_column('shoppingcart_orderitem', 'line_cost', + self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2), + keep_default=False) + + 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': ('django.db.models.fields.CharField', [], {'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.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'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_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'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + '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_id': ('django.db.models.fields.CharField', [], {'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'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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/migrations/0004_auto__add_field_orderitem_fulfilled_time.py b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py new file mode 100644 index 0000000000..bbaf185184 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py @@ -0,0 +1,114 @@ +# -*- 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 'OrderItem.fulfilled_time' + db.add_column('shoppingcart_orderitem', 'fulfilled_time', + self.gf('django.db.models.fields.DateTimeField')(null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'OrderItem.fulfilled_time' + db.delete_column('shoppingcart_orderitem', 'fulfilled_time') + + + 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': ('django.db.models.fields.CharField', [], {'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.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'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_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'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + '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_id': ('django.db.models.fields.CharField', [], {'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'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/__init__.py b/lms/djangoapps/shoppingcart/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py new file mode 100644 index 0000000000..1ad71ff625 --- /dev/null +++ b/lms/djangoapps/shoppingcart/models.py @@ -0,0 +1,322 @@ +import pytz +import logging +from datetime import datetime +from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ +from django.db import transaction +from model_utils.managers import InheritanceManager +from courseware.courses import get_course_about_section + +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor + +from course_modes.models import CourseMode +from student.views import course_from_id +from student.models import CourseEnrollment +from statsd import statsd +from .exceptions import * + +log = logging.getLogger("shoppingcart") + +ORDER_STATUSES = ( + ('cart', 'cart'), + ('purchased', 'purchased'), + ('refunded', 'refunded'), # Not used for now +) + + +class Order(models.Model): + """ + This is the model for an order. Before purchase, an Order and its related OrderItems are used + as the shopping cart. + FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'. + """ + user = models.ForeignKey(User, db_index=True) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + purchase_time = models.DateTimeField(null=True, blank=True) + # Now we store data needed to generate a reasonable receipt + # These fields only make sense after the purchase + bill_to_first = models.CharField(max_length=64, blank=True) + bill_to_last = models.CharField(max_length=64, blank=True) + bill_to_street1 = models.CharField(max_length=128, blank=True) + bill_to_street2 = models.CharField(max_length=128, blank=True) + bill_to_city = models.CharField(max_length=64, blank=True) + bill_to_state = models.CharField(max_length=8, blank=True) + bill_to_postalcode = models.CharField(max_length=16, blank=True) + bill_to_country = models.CharField(max_length=64, blank=True) + bill_to_ccnum = models.CharField(max_length=8, blank=True) # last 4 digits + bill_to_cardtype = models.CharField(max_length=32, blank=True) + # a JSON dump of the CC processor response, for completeness + processor_reply_dump = models.TextField(blank=True) + + @classmethod + def get_cart_for_user(cls, user): + """ + Always use this to preserve the property that at most 1 order per user has status = 'cart' + """ + # find the newest element in the db + try: + cart_order = cls.objects.filter(user=user, status='cart').order_by('-id')[:1].get() + except ObjectDoesNotExist: + # if nothing exists in the database, create a new cart + cart_order, _created = cls.objects.get_or_create(user=user, status='cart') + return cart_order + + @property + def total_cost(self): + """ + Return the total cost of the cart. If the order has been purchased, returns total of + all purchased and not refunded items. + """ + return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) + + def clear(self): + """ + Clear out all the items in the cart + """ + self.orderitem_set.all().delete() + + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', + country='', ccnum='', cardtype='', processor_reply_dump=''): + """ + Call to mark this order as purchased. Iterates through its OrderItems and calls + their purchased_callback + + `first` - first name of person billed (e.g. John) + `last` - last name of person billed (e.g. Smith) + `street1` - first line of a street address of the billing address (e.g. 11 Cambridge Center) + `street2` - second line of a street address of the billing address (e.g. Suite 101) + `city` - city of the billing address (e.g. Cambridge) + `state` - code of the state, province, or territory of the billing address (e.g. MA) + `postalcode` - postal code of the billing address (e.g. 02142) + `country` - country code of the billing address (e.g. US) + `ccnum` - last 4 digits of the credit card number of the credit card billed (e.g. 1111) + `cardtype` - 3-digit code representing the card type used (e.g. 001) + `processor_reply_dump` - all the parameters returned by the processor + + """ + self.status = 'purchased' + self.purchase_time = datetime.now(pytz.utc) + self.bill_to_first = first + self.bill_to_last = last + self.bill_to_street1 = street1 + self.bill_to_street2 = street2 + self.bill_to_city = city + self.bill_to_state = state + self.bill_to_postalcode = postalcode + self.bill_to_country = country + self.bill_to_ccnum = ccnum + self.bill_to_cardtype = cardtype + self.processor_reply_dump = processor_reply_dump + # save these changes on the order, then we can tell when we are in an + # inconsistent state + self.save() + # this should return all of the objects with the correct types of the + # subclasses + orderitems = OrderItem.objects.filter(order=self).select_subclasses() + for item in orderitems: + item.purchase_item() + + +class OrderItem(models.Model): + """ + This is the basic interface for order items. + Order items are line items that fill up the shopping carts and orders. + + Each implementation of OrderItem should provide its own purchased_callback as + a method. + """ + objects = InheritanceManager() + order = models.ForeignKey(Order, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user + user = models.ForeignKey(User, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + qty = models.IntegerField(default=1) + unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) + line_desc = models.CharField(default="Misc. Item", max_length=1024) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + fulfilled_time = models.DateTimeField(null=True) + + @property + def line_cost(self): + """ Return the total cost of this OrderItem """ + return self.qty * self.unit_cost + + @classmethod + def add_to_order(cls, order, *args, **kwargs): + """ + A suggested convenience function for subclasses. + + NOTE: This does not add anything to the cart. That is left up to the + subclasses to implement for themselves + """ + # this is a validation step to verify that the currency of the item we + # are adding is the same as the currency of the order we are adding it + # to + currency = kwargs.get('currency', 'usd') + if order.currency != currency and order.orderitem_set.exists(): + raise InvalidCartItem(_("Trying to add a different currency into the cart")) + + @transaction.commit_on_success + def purchase_item(self): + """ + This is basically a wrapper around purchased_callback that handles + modifying the OrderItem itself + """ + self.purchased_callback() + self.status = 'purchased' + self.fulfilled_time = datetime.now(pytz.utc) + self.save() + + def purchased_callback(self): + """ + This is called on each inventory item in the shopping cart when the + purchase goes through. + """ + raise NotImplementedError + + +class PaidCourseRegistration(OrderItem): + """ + This is an inventory item for paying for a course registration + """ + course_id = models.CharField(max_length=128, db_index=True) + mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) + + @classmethod + def part_of_order(cls, order, course_id): + """ + Is the course defined by course_id in the order? + """ + return course_id in [item.paidcourseregistration.course_id + for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] + + @classmethod + @transaction.commit_on_success + def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): + """ + A standardized way to create these objects, with sensible defaults filled in. + Will update the cost if called on an order that already carries the course. + + Returns the order item + """ + # TODO: Possibly add checking for whether student is already enrolled in course + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + + ### handle default arguments for mode_slug, cost, currency + course_mode = CourseMode.mode_for_course(course_id, mode_slug) + if not course_mode: + # user could have specified a mode that's not set, in that case return the DEFAULT_MODE + course_mode = CourseMode.DEFAULT_MODE + if not cost: + cost = course_mode.min_price + if not currency: + currency = course_mode.currency + + super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) + + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) + item.status = order.status + + item.mode = course_mode.slug + item.qty = 1 + item.unit_cost = cost + item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), + course_mode.name) + item.currency = currency + order.currency = currency + order.save() + item.save() + return item + + def purchased_callback(self): + """ + When purchased, this should enroll the user in the course. We are assuming that + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found + in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + would in fact be quite silly since there's a clear back door. + """ + try: + course_loc = CourseDescriptor.id_to_location(self.course_id) + course_exists = modulestore().has_item(self.course_id, course_loc) + except ValueError: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + + if not course_exists: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + + CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) + + log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) + org, course_num, run = self.course_id.split("/") + statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) + + +class CertificateItem(OrderItem): + """ + This is an inventory item for purchasing certificates + """ + course_id = models.CharField(max_length=128, db_index=True) + course_enrollment = models.ForeignKey(CourseEnrollment) + mode = models.SlugField() + + @classmethod + @transaction.commit_on_success + def add_to_order(cls, order, course_id, cost, mode, currency='usd'): + """ + Add a CertificateItem to an order + + Returns the CertificateItem object after saving + + `order` - an order that this item should be added to, generally the cart order + `course_id` - the course that we would like to purchase as a CertificateItem + `cost` - the amount the user will be paying for this CertificateItem + `mode` - the course mode that this certificate is going to be issued for + + This item also creates a new enrollment if none exists for this user and this course. + + Example Usage: + cart = Order.get_cart_for_user(user) + CertificateItem.add_to_order(cart, 'edX/Test101/2013_Fall', 30, 'verified') + + """ + super(CertificateItem, cls).add_to_order(order, course_id, cost, currency=currency) + try: + course_enrollment = CourseEnrollment.objects.get(user=order.user, course_id=course_id) + except ObjectDoesNotExist: + course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode=mode) + item, _created = cls.objects.get_or_create( + order=order, + user=order.user, + course_id=course_id, + course_enrollment=course_enrollment, + mode=mode + ) + item.status = order.status + item.qty = 1 + item.unit_cost = cost + item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, + course_id=course_id) + item.currency = currency + order.currency = currency + order.save() + item.save() + return item + + def purchased_callback(self): + """ + When purchase goes through, activate and update the course enrollment for the correct mode + """ + self.course_enrollment.mode = self.mode + self.course_enrollment.save() + self.course_enrollment.activate() diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py new file mode 100644 index 0000000000..5952668d8f --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -0,0 +1,405 @@ +### Implementation of support for the Cybersource Credit card processor +### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting +### Implementes interface as specified by __init__.py + +import time +import hmac +import binascii +import re +import json +from collections import OrderedDict, defaultdict +from decimal import Decimal, InvalidOperation +from hashlib import sha1 +from textwrap import dedent +from django.conf import settings +from django.utils.translation import ugettext as _ +from mitxmako.shortcuts import render_to_string +from shoppingcart.models import Order +from shoppingcart.processors.exceptions import * + + +def process_postpay_callback(params): + """ + The top level call to this module, basically + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external Hosted Order Page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + try: + verify_signatures(params) + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: + return {'success': False, + 'order': result['order'], + 'error_html': get_processor_decline_html(params)} + except CCProcessorException as error: + return {'success': False, + 'order': None, # due to exception we may not have the order + 'error_html': get_processor_exception_html(error)} + + +def processor_hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET', '') + hash_obj = hmac.new(shared_secret, value, sha1) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + + +def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID', '') + order_page_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION', '7') + serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER', '') + + params['merchantID'] = merchant_id + params['orderPage_timestamp'] = int(time.time() * 1000) + params['orderPage_version'] = order_page_version + params['orderPage_serialNumber'] = serial_number + fields = ",".join(params.keys()) + values = ",".join(["{0}={1}".format(i, params[i]) for i in params.keys()]) + fields_sig = processor_hash(fields) + values += ",signedFieldsPublicSignature=" + fields_sig + params[full_sig_key] = processor_hash(values) + params[signed_fields_key] = fields + + return params + + +def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='signedDataPublicSignature'): + """ + Verify the signatures accompanying the POST back from Cybersource Hosted Order Page + + returns silently if verified + + raises CCProcessorSignatureException if not verified + """ + signed_fields = params.get(signed_fields_key, '').split(',') + data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + signed_fields_sig = processor_hash(params.get(signed_fields_key, '')) + data += ",signedFieldsPublicSignature=" + signed_fields_sig + returned_sig = params.get(full_sig_key, '') + if processor_hash(data) != returned_sig: + raise CCProcessorSignatureException() + + +def render_purchase_form_html(cart): + """ + Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource + """ + purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') + + total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) + cart_items = cart.orderitem_set.all() + params = OrderedDict() + params['amount'] = amount + params['currency'] = cart.currency + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "{0:d}".format(cart.id) + signed_param_dict = sign(params) + + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': purchase_endpoint, + 'params': signed_param_dict, + }) + + +def payment_accepted(params): + """ + Check that cybersource has accepted the payment + params: a dictionary of POST parameters returned by CyberSource in their post-payment callback + + returns: true if the payment was correctly accepted, for the right amount + false if the payment was not accepted + + raises: CCProcessorDataException if the returned message did not provide required parameters + CCProcessorWrongAmountException if the amount charged is different than the order amount + + """ + #make sure required keys are present and convert their values to the right type + valid_params = {} + for key, key_type in [('orderNumber', int), + ('orderCurrency', str), + ('decision', str)]: + if key not in params: + raise CCProcessorDataException( + _("The payment processor did not return a required parameter: {0}".format(key)) + ) + try: + valid_params[key] = key_type(params[key]) + except ValueError: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + try: + order = Order.objects.get(id=valid_params['orderNumber']) + except Order.DoesNotExist: + raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) + + if valid_params['decision'] == 'ACCEPT': + try: + # Moved reading of charged_amount here from the valid_params loop above because + # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter + charged_amt = Decimal(params['ccAuthReply_amount']) + except InvalidOperation: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format( + params['ccAuthReply_amount'], 'ccAuthReply_amount')) + ) + + if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency: + return {'accepted': True, + 'amt_charged': charged_amt, + 'currency': valid_params['orderCurrency'], + 'order': order} + else: + raise CCProcessorWrongAmountException( + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}." + .format(charged_amt, valid_params['orderCurrency'], + order.total_cost, order.currency)) + ) + else: + return {'accepted': False, + 'amt_charged': 0, + 'currency': 'usd', + 'order': order} + + +def record_purchase(params, order): + """ + Record the purchase and run purchased_callbacks + """ + ccnum_str = params.get('card_accountNumber', '') + m = re.search("\d", ccnum_str) + if m: + ccnum = ccnum_str[m.start():] + else: + ccnum = "####" + + order.purchase( + first=params.get('billTo_firstName', ''), + last=params.get('billTo_lastName', ''), + street1=params.get('billTo_street1', ''), + street2=params.get('billTo_street2', ''), + city=params.get('billTo_city', ''), + state=params.get('billTo_state', ''), + country=params.get('billTo_country', ''), + postalcode=params.get('billTo_postalCode', ''), + ccnum=ccnum, + cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')], + processor_reply_dump=json.dumps(params) + ) + + +def get_processor_decline_html(params): + """Have to parse through the error codes to return a helpful message""" + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + + msg = _(dedent( + """ +

+ Sorry! Our payment processor did not accept your payment. + The decision in they returned was {decision}, + and the reason was {reason_code}:{reason_msg}. + You were not charged. Please try a different form of payment. + Contact us with payment-specific questions at {email}. +

+ """)) + + return msg.format( + decision=params['decision'], + reason_code=params['reasonCode'], + reason_msg=REASONCODE_MAP[params['reasonCode']], + email=payment_support_email) + + +def get_processor_exception_html(exception): + """Return error HTML associated with exception""" + + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + if isinstance(exception, CCProcessorDataException): + msg = _(dedent( + """ +

+ Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: {msg}. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

+ """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorWrongAmountException): + msg = _(dedent( + """ +

+ Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: {msg}. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. +

+ """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorSignatureException): + msg = _(dedent( + """ +

+ Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are + unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. + We apologize that we cannot verify whether the charge went through and take further action on your order. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

+ """.format(msg=exception.message, email=payment_support_email))) + return msg + + # fallthrough case, which basically never happens + return '

EXCEPTION!

' + + +CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN") +CARDTYPE_MAP.update( + { + '001': 'Visa', + '002': 'MasterCard', + '003': 'American Express', + '004': 'Discover', + '005': 'Diners Club', + '006': 'Carte Blanche', + '007': 'JCB', + '014': 'EnRoute', + '021': 'JAL', + '024': 'Maestro', + '031': 'Delta', + '033': 'Visa Electron', + '034': 'Dankort', + '035': 'Laser', + '036': 'Carte Bleue', + '037': 'Carta Si', + '042': 'Maestro', + '043': 'GE Money UK card' + } +) + +REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON") +REASONCODE_MAP.update( + { + '100': _('Successful transaction.'), + '101': _('The request is missing one or more required fields.'), + '102': _('One or more fields in the request contains invalid data.'), + '104': _(dedent( + """ + The merchantReferenceCode sent with this authorization request matches the + merchantReferenceCode of another authorization request that you sent in the last 15 minutes. + Possible fix: retry the payment after 15 minutes. + """)), + '150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), + '151': _(dedent( + """ + Error: The request was received but there was a server timeout. + This error does not include timeouts between the client and the server. + Possible fix: retry the payment after some time. + """)), + '152': _(dedent( + """ + Error: The request was received, but a service did not finish running in time + Possible fix: retry the payment after some time. + """)), + '201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), + '202': _(dedent( + """ + Expired card. You might also receive this if the expiration date you + provided does not match the date the issuing bank has on file. + Possible fix: retry with another form of payment + """)), + '203': _(dedent( + """ + General decline of the card. No other information provided by the issuing bank. + Possible fix: retry with another form of payment + """)), + '204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. + '205': _('Unknown reason'), + '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208': _(dedent( + """ + Inactive card or card not authorized for card-not-present transactions. + Possible fix: retry with another form of payment + """)), + '210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211': _('Invalid card verification number. Possible fix: retry with another form of payment'), + # 221 was The customer matched an entry on the processor's negative file. + # Might as well not show this message to the person using such a card. + '221': _('Unknown reason'), + '231': _('Invalid account number. Possible fix: retry with another form of payment'), + '232': _(dedent( + """ + The card type is not accepted by the payment processor. + Possible fix: retry with another form of payment + """)), + '233': _('General decline by the processor. Possible fix: retry with another form of payment'), + '234': _(dedent( + """ + There is a problem with our CyberSource merchant configuration. Please let us know at {0} + """.format(settings.PAYMENT_SUPPORT_EMAIL))), + # reason code 235 only applies if we are processing a capture through the API. so we should never see it + '235': _('The requested amount exceeds the originally authorized amount.'), + '236': _('Processor Failure. Possible fix: retry the payment'), + # reason code 238 only applies if we are processing a capture through the API. so we should never see it + '238': _('The authorization has already been captured'), + # reason code 239 only applies if we are processing a capture or credit through the API, + # so we should never see it + '239': _('The requested transaction amount must match the previous transaction amount.'), + '240': _(dedent( + """ + The card type sent is invalid or does not correlate with the credit card number. + Possible fix: retry with the same card or another form of payment + """)), + # reason code 241 only applies when we are processing a capture or credit through the API, + # so we should never see it + '241': _('The request ID is invalid.'), + # reason code 242 occurs if there was not a previously successful authorization request or + # if the previously successful authorization has already been used by another capture request. + # This reason code only applies when we are processing a capture through the API + # so we should never see it + '242': _(dedent( + """ + You requested a capture through the API, but there is no corresponding, unused authorization record. + """)), + # we should never see 243 + '243': _('The transaction has already been settled or reversed.'), + # reason code 246 applies only if we are processing a void through the API. so we should never see it + '246': _(dedent( + """ + The capture or credit is not voidable because the capture or credit information has already been + submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. + """)), + # reason code 247 applies only if we are processing a void through the API. so we should never see it + '247': _('You requested a credit for a capture that was previously voided'), + '250': _(dedent( + """ + Error: The request was received, but there was a timeout at the payment processor. + Possible fix: retry the payment. + """)), + '520': _(dedent( + """ + The authorization request was approved by the issuing bank but declined by CyberSource.' + Possible fix: retry with a different form of payment. + """)), + } +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py new file mode 100644 index 0000000000..4051d4c3ec --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -0,0 +1,33 @@ +from django.conf import settings + +### Now code that determines, using settings, which actual processor implementation we're using. +processor_name = settings.CC_PROCESSOR.keys()[0] +module = __import__('shoppingcart.processors.' + processor_name, + fromlist=['render_purchase_form_html' + 'process_postpay_callback', + ]) + + +def render_purchase_form_html(*args, **kwargs): + """ + The top level call to this module to begin the purchase. + Given a shopping cart, + Renders the HTML form for display on user's browser, which POSTS to Hosted Processors + Returns the HTML as a string + """ + return module.render_purchase_form_html(*args, **kwargs) + + +def process_postpay_callback(*args, **kwargs): + """ + The top level call to this module after the purchase. + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external payment page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + return module.process_postpay_callback(*args, **kwargs) diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py new file mode 100644 index 0000000000..202f143cce --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -0,0 +1,17 @@ +from shoppingcart.exceptions import PaymentException + + +class CCProcessorException(PaymentException): + pass + + +class CCProcessorSignatureException(CCProcessorException): + pass + + +class CCProcessorDataException(CCProcessorException): + pass + + +class CCProcessorWrongAmountException(CCProcessorException): + pass diff --git a/lms/djangoapps/shoppingcart/processors/tests/__init__.py b/lms/djangoapps/shoppingcart/processors/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py new file mode 100644 index 0000000000..de9e5939f0 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -0,0 +1,288 @@ +""" +Tests for the CyberSource processor handler +""" +from collections import OrderedDict +from django.test import TestCase +from django.test.utils import override_settings +from django.conf import settings +from student.tests.factories import UserFactory +from shoppingcart.models import Order, OrderItem +from shoppingcart.processors.CyberSource import * +from shoppingcart.processors.exceptions import * +from mock import patch, Mock + + +TEST_CC_PROCESSOR = { + 'CyberSource': { + 'SHARED_SECRET': 'secret', + 'MERCHANT_ID': 'edx_test', + 'SERIAL_NUMBER': '12345', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } +} + + +@override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) +class CyberSourceTests(TestCase): + + def setUp(self): + pass + + def test_override_settings(self): + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + + def test_hash(self): + """ + Tests the hash function. Basically just hardcodes the answer. + """ + self.assertEqual(processor_hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') + self.assertEqual(processor_hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') + + def test_sign_then_verify(self): + """ + "loopback" test: + Tests the that the verify function verifies parameters signed by the sign function + """ + params = OrderedDict() + params['amount'] = "12.34" + params['currency'] = 'usd' + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "567" + + verify_signatures(sign(params), signed_fields_key='orderPage_signedFields', + full_sig_key='orderPage_signaturePublic') + + # if the above verify_signature fails it will throw an exception, so basically we're just + # testing for the absence of that exception. the trivial assert below does that + self.assertEqual(1, 1) + + def test_verify_exception(self): + """ + Tests that failure to verify raises the proper CCProcessorSignatureException + """ + params = OrderedDict() + params['a'] = 'A' + params['b'] = 'B' + params['signedFields'] = 'A,B' + params['signedDataPublicSignature'] = 'WONTVERIFY' + + with self.assertRaises(CCProcessorSignatureException): + verify_signatures(params) + + def test_get_processor_decline_html(self): + """ + Tests the processor decline html message + """ + DECISION = 'REJECT' + for code, reason in REASONCODE_MAP.iteritems(): + params = { + 'decision': DECISION, + 'reasonCode': code, + } + html = get_processor_decline_html(params) + self.assertIn(DECISION, html) + self.assertIn(reason, html) + self.assertIn(code, html) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + + def test_get_processor_exception_html(self): + """ + Tests the processor exception html message + """ + for type in [CCProcessorSignatureException, CCProcessorWrongAmountException, CCProcessorDataException]: + error_msg = "An exception message of with exception type {0}".format(str(type)) + exception = type(error_msg) + html = get_processor_exception_html(exception) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + self.assertIn('Sorry!', html) + self.assertIn(error_msg, html) + + # test base case + self.assertIn("EXCEPTION!", get_processor_exception_html(CCProcessorException())) + + def test_record_purchase(self): + """ + Tests record_purchase with good and without returned CCNum + """ + student1 = UserFactory() + student1.save() + student2 = UserFactory() + student2.save() + params_cc = {'card_accountNumber': '1234', 'card_cardType': '001', 'billTo_firstName': student1.first_name} + params_nocc = {'card_accountNumber': '', 'card_cardType': '002', 'billTo_firstName': student2.first_name} + order1 = Order.get_cart_for_user(student1) + order2 = Order.get_cart_for_user(student2) + record_purchase(params_cc, order1) + record_purchase(params_nocc, order2) + self.assertEqual(order1.bill_to_ccnum, '1234') + self.assertEqual(order1.bill_to_cardtype, 'Visa') + self.assertEqual(order1.bill_to_first, student1.first_name) + self.assertEqual(order1.status, 'purchased') + + order2 = Order.objects.get(user=student2) + self.assertEqual(order2.bill_to_ccnum, '####') + self.assertEqual(order2.bill_to_cardtype, 'MasterCard') + self.assertEqual(order2.bill_to_first, student2.first_name) + self.assertEqual(order2.status, 'purchased') + + def test_payment_accepted_invalid_dict(self): + """ + Tests exception is thrown when params to payment_accepted don't have required key + or have an bad value + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + wrong = { + 'orderNumber': 'k', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + # tests for keys with value that can't be converted to proper type + for key in wrong: + params = baseline.copy() + params[key] = wrong[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + def test_payment_accepted_order(self): + """ + Tests payment_accepted cases with an order + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + + # tests for an order number that doesn't match up + params_bad_ordernum = params.copy() + params_bad_ordernum['orderNumber'] = str(order1.id + 10) + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_bad_ordernum) + + # tests for a reply amount of the wrong type + params_wrong_type_amt = params.copy() + params_wrong_type_amt['ccAuthReply_amount'] = 'ab' + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_wrong_type_amt) + + # tests for a reply amount of the wrong type + params_wrong_amt = params.copy() + params_wrong_amt['ccAuthReply_amount'] = '1.00' + with self.assertRaises(CCProcessorWrongAmountException): + payment_accepted(params_wrong_amt) + + # tests for a not accepted order + params_not_accepted = params.copy() + params_not_accepted['decision'] = "REJECT" + self.assertFalse(payment_accepted(params_not_accepted)['accepted']) + + # finally, tests an accepted order + self.assertTrue(payment_accepted(params)['accepted']) + + @patch('shoppingcart.processors.CyberSource.render_to_string', autospec=True) + def test_render_purchase_form_html(self, render): + """ + Tests the rendering of the purchase form + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + item1 = OrderItem(order=order1, user=student1, unit_cost=1.0, line_cost=1.0) + item1.save() + html = render_purchase_form_html(order1) + ((template, context), render_kwargs) = render.call_args + + self.assertEqual(template, 'shoppingcart/cybersource_form.html') + self.assertDictContainsSubset({'amount': '1.00', + 'currency': 'usd', + 'orderPage_transactionType': 'sale', + 'orderNumber': str(order1.id)}, + context['params']) + + def test_process_postpay_exception(self): + """ + Tests the exception path of process_postpay_callback + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertIsNone(result['order']) + self.assertIn('error_msg', result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_accepted(self): + """ + Tests the ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + result = process_postpay_callback(params) + self.assertTrue(result['success']) + self.assertEqual(result['order'], order1) + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + self.assertEqual(order1.status, 'purchased') + self.assertFalse(result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_not_accepted(self): + """ + Tests the non-ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'REJECT', + 'ccAuthReply_amount': '0.00', + 'reasonCode': '207' + } + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertEqual(result['order'], order1) + self.assertEqual(order1.status, 'cart') + self.assertIn(REASONCODE_MAP['207'], result['error_html']) diff --git a/lms/djangoapps/shoppingcart/tests/__init__.py b/lms/djangoapps/shoppingcart/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py new file mode 100644 index 0000000000..75789964b1 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -0,0 +1,185 @@ +""" +Tests for the Shopping Cart Models +""" + +from factory import DjangoModelFactory +from mock import patch +from django.test import TestCase +from django.test.utils import override_settings +from django.db import DatabaseError +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from course_modes.models import CourseMode +from shoppingcart.exceptions import PurchasedCallbackException + + +class OrderTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "test/course" + self.cost = 40 + + def test_get_cart_for_user(self): + # create a cart + cart = Order.get_cart_for_user(user=self.user) + # add something to it + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # should return the same cart + cart2 = Order.get_cart_for_user(user=self.user) + self.assertEquals(cart2.orderitem_set.count(), 1) + + def test_cart_clear(self): + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + CertificateItem.add_to_order(cart, 'test/course1', self.cost, 'verified') + self.assertEquals(cart.orderitem_set.count(), 2) + cart.clear() + self.assertEquals(cart.orderitem_set.count(), 0) + + def test_add_item_to_cart_currency_match(self): + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='eur') + # verify that a new item has been added + self.assertEquals(cart.orderitem_set.count(), 1) + # verify that the cart's currency was updated + self.assertEquals(cart.currency, 'eur') + with self.assertRaises(InvalidCartItem): + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='usd') + # assert that this item did not get added to the cart + self.assertEquals(cart.orderitem_set.count(), 1) + + def test_total_cost(self): + cart = Order.get_cart_for_user(user=self.user) + # add items to the order + course_costs = [('test/course1', 30), + ('test/course2', 40), + ('test/course3', 10), + ('test/course4', 20)] + for course, cost in course_costs: + CertificateItem.add_to_order(cart, course, cost, 'verified') + self.assertEquals(cart.orderitem_set.count(), len(course_costs)) + self.assertEquals(cart.total_cost, sum(cost for _course, cost in course_costs)) + + def test_purchase(self): + # This test is for testing the subclassing functionality of OrderItem, but in + # order to do this, we end up testing the specific functionality of + # CertificateItem, which is not quite good unit test form. Sorry. + cart = Order.get_cart_for_user(user=self.user) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # course enrollment object should be created but still inactive + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + def test_purchase_item_failure(self): + # once again, we're testing against the specific implementation of + # CertificateItem + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + with patch('shoppingcart.models.CertificateItem.save', side_effect=DatabaseError): + with self.assertRaises(DatabaseError): + cart.purchase() + # verify that we rolled back the entire transaction + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + +class OrderItemTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + + def test_orderItem_purchased_callback(self): + """ + This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError + """ + item = OrderItem(user=self.user, order=Order.get_cart_for_user(self.user)) + with self.assertRaises(NotImplementedError): + item.purchased_callback() + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class PaidCourseRegistrationTest(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def test_add_to_order(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + + self.assertEqual(reg1.unit_cost, self.cost) + self.assertEqual(reg1.line_cost, self.cost) + self.assertEqual(reg1.unit_cost, self.course_mode.min_price) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id + "abcd")) + self.assertEqual(self.cart.total_cost, self.cost) + + def test_add_with_default_mode(self): + """ + Tests add_to_cart where the mode specified in the argument is NOT in the database + and NOT the default "honor". In this case it just adds the user in the CourseMode.DEFAULT_MODE, 0 price + """ + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id, mode_slug="DNE") + + self.assertEqual(reg1.unit_cost, 0) + self.assertEqual(reg1.line_cost, 0) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertEqual(self.cart.total_cost, 0) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + def test_purchased_callback(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cart.purchase() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect + self.assertEqual(reg1.status, "purchased") + + def test_purchased_callback_exception(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + reg1.course_id = "changedforsomereason" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + reg1.course_id = "abc/efg/hij" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + +class CertificateItemTest(TestCase): + """ + Tests for verifying specific CertificateItem functionality + """ + def setUp(self): + self.user = UserFactory.create() + self.course_id = "test/course" + self.cost = 40 + + def test_existing_enrollment(self): + CourseEnrollment.enroll(self.user, self.course_id) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # verify that we are still enrolled + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) + self.assertEquals(enrollment.mode, u'verified') diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py new file mode 100644 index 0000000000..25ee914ce6 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -0,0 +1,222 @@ +""" +Tests for Shopping Cart views +""" +from urlparse import urlparse + +from django.test import TestCase +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.exceptions import ItemNotFoundError +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from shoppingcart.views import add_course_to_cart +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from course_modes.models import CourseMode +from ..exceptions import PurchasedCallbackException +from mitxmako.shortcuts import render_to_response +from shoppingcart.processors import render_purchase_form_html, process_postpay_callback +from mock import patch, Mock + + +def mock_render_purchase_form_html(*args, **kwargs): + return render_purchase_form_html(*args, **kwargs) + +form_mock = Mock(side_effect=mock_render_purchase_form_html) + +def mock_render_to_response(*args, **kwargs): + return render_to_response(*args, **kwargs) + +render_mock = Mock(side_effect=mock_render_to_response) + +postpay_mock = Mock() + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class ShoppingCartViewsTests(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def login_user(self): + self.client.login(username=self.user.username, password="password") + + def test_add_course_to_cart_anon(self): + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 403) + + def test_add_course_to_cart_already_in_cart(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_('The course {0} is already in your cart.'.format(self.course_id)), resp.content) + + def test_add_course_to_cart_already_registered(self): + CourseEnrollment.enroll(self.user, self.course_id) + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_('You are already registered in course {0}.'.format(self.course_id)), resp.content) + + def test_add_nonexistent_course_to_cart(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=['non/existent/course'])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_("The course you requested does not exist."), resp.content) + + def test_add_course_to_cart_success(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 200) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + + @patch('shoppingcart.views.render_purchase_form_html', form_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_cart(self): + self.login_user() + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + + ((purchase_form_arg_cart,), _) = form_mock.call_args + purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() + self.assertIn(reg_item, purchase_form_arg_cart_items) + self.assertIn(cert_item, purchase_form_arg_cart_items) + self.assertEqual(len(purchase_form_arg_cart_items), 2) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/list.html') + self.assertEqual(len(context['shoppingcart_items']), 2) + self.assertEqual(context['amount'], 80) + self.assertIn("80.00", context['form_html']) + + def test_clear_cart(self): + self.login_user() + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + + @patch('shoppingcart.views.log.exception') + def test_remove_item(self, exception_log): + self.login_user() + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 1) + self.assertNotIn(reg_item, self.cart.orderitem_set.all().select_subclasses()) + + self.cart.purchase() + resp2 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': cert_item.id}) + self.assertEqual(resp2.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(cert_item.id)) + + resp3 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': -1}) + self.assertEqual(resp3.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(-1)) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + def test_postpay_callback_success(self): + postpay_mock.return_value = {'success': True, 'order': self.cart} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 302) + self.assertEqual(urlparse(resp.__getitem__('location')).path, + reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_postpay_callback_failure(self): + postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html': 'ERROR_TEST!!!'} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn('ERROR_TEST!!!', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/error.html') + self.assertEqual(context['order'], self.cart) + self.assertEqual(context['error_html'], 'ERROR_TEST!!!') + + def test_show_receipt_404s(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase() + + user2 = UserFactory.create() + cart2 = Order.get_cart_for_user(user2) + PaidCourseRegistration.add_to_order(cart2, self.course_id) + cart2.purchase() + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[cart2.id])) + self.assertEqual(resp.status_code, 404) + + resp2 = self.client.get(reverse('shoppingcart.views.show_receipt', args=[1000])) + self.assertEqual(resp2.status_code, 404) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success(self): + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('FirstNameTesting123', resp.content) + self.assertIn('StreetTesting123', resp.content) + self.assertIn('80.00', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/receipt.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item.orderitem_ptr, context['order_items']) + self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertFalse(context['any_refunds']) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success_refund(self): + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + cert_item.status = "refunded" + cert_item.save() + self.assertEqual(self.cart.total_cost, 40) + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('40.00', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/receipt.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item.orderitem_ptr, context['order_items']) + self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertTrue(context['any_refunds']) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py new file mode 100644 index 0000000000..800c6077aa --- /dev/null +++ b/lms/djangoapps/shoppingcart/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import patterns, include, url +from django.conf import settings + +urlpatterns = patterns('shoppingcart.views', # nopep8 + url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here + url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), +) +if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: + urlpatterns += patterns( + 'shoppingcart.views', + url(r'^$', 'show_cart'), + url(r'^clear/$', 'clear_cart'), + url(r'^remove_item/$', 'remove_item'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), + ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py new file mode 100644 index 0000000000..a2f88c9c94 --- /dev/null +++ b/lms/djangoapps/shoppingcart/views.py @@ -0,0 +1,105 @@ +import logging +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 +from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST +from django.core.urlresolvers import reverse +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import login_required +from student.models import CourseEnrollment +from xmodule.modulestore.exceptions import ItemNotFoundError +from mitxmako.shortcuts import render_to_response +from .models import Order, PaidCourseRegistration, CertificateItem, OrderItem +from .processors import process_postpay_callback, render_purchase_form_html + +log = logging.getLogger("shoppingcart") + + +def add_course_to_cart(request, course_id): + if not request.user.is_authenticated(): + return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) + cart = Order.get_cart_for_user(request.user) + if PaidCourseRegistration.part_of_order(cart, course_id): + return HttpResponseNotFound(_('The course {0} is already in your cart.'.format(course_id))) + if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id): + return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) + try: + PaidCourseRegistration.add_to_order(cart, course_id) + except ItemNotFoundError: + return HttpResponseNotFound(_('The course you requested does not exist.')) + if request.method == 'GET': # This is temporary for testing purposes and will go away before we pull + return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) + return HttpResponse(_("Course added to cart.")) + + +@login_required +def show_cart(request): + cart = Order.get_cart_for_user(request.user) + total_cost = cart.total_cost + cart_items = cart.orderitem_set.all() + form_html = render_purchase_form_html(cart) + return render_to_response("shoppingcart/list.html", + {'shoppingcart_items': cart_items, + 'amount': total_cost, + 'form_html': form_html, + }) + + +@login_required +def clear_cart(request): + cart = Order.get_cart_for_user(request.user) + cart.clear() + return HttpResponse('Cleared') + + +@login_required +def remove_item(request): + item_id = request.REQUEST.get('id', '-1') + try: + item = OrderItem.objects.get(id=item_id, status='cart') + if item.user == request.user: + item.delete() + except OrderItem.DoesNotExist: + log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) + return HttpResponse('OK') + + +@csrf_exempt +@require_POST +def postpay_callback(request): + """ + Receives the POST-back from processor. + Mainly this calls the processor-specific code to check if the payment was accepted, and to record the order + if it was, and to generate an error page. + If successful this function should have the side effect of changing the "cart" into a full "order" in the DB. + The cart can then render a success page which links to receipt pages. + If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be + returned. + """ + params = request.POST.dict() + result = process_postpay_callback(params) + if result['success']: + return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) + else: + return render_to_response('shoppingcart/error.html', {'order': result['order'], + 'error_html': result['error_html']}) + + +@login_required +def show_receipt(request, ordernum): + """ + Displays a receipt for a particular order. + 404 if order is not yet purchased or request.user != order.user + """ + try: + order = Order.objects.get(id=ordernum) + except Order.DoesNotExist: + raise Http404('Order not found!') + + if order.user != request.user or order.status != 'purchased': + raise Http404('Order not found!') + + order_items = order.orderitem_set.all() + any_refunds = any(i.status == "refunded" for i in order_items) + return render_to_response('shoppingcart/receipt.html', {'order': order, + 'order_items': order_items, + 'any_refunds': any_refunds}) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 8d2ffba96e..f6eb45ec51 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -127,6 +127,7 @@ SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL) BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL) +PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_EMAIL) #Theme overrides THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) @@ -190,6 +191,7 @@ SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) +CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index 250552a40c..8181f97789 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -154,6 +154,9 @@ MITX_FEATURES = { # Toggle to enable chat availability (configured on a per-course # basis in Studio) 'ENABLE_CHAT': False, + + # Toggle the availability of the shopping cart page + 'ENABLE_SHOPPING_CART': False } # Used for A/B testing @@ -431,6 +434,19 @@ ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None +##### shoppingcart Payment ##### +PAYMENT_SUPPORT_EMAIL = 'payment@edx.org' +##### Using cybersource by default ##### +CC_PROCESSOR = { + 'CyberSource': { + 'SHARED_SECRET': '', + 'MERCHANT_ID': '', + 'SERIAL_NUMBER': '', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } +} + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, @@ -770,6 +786,9 @@ INSTALLED_APPS = ( 'rest_framework', 'user_api', + # shopping cart + 'shoppingcart', + # Notification preferences setting 'notification_prefs', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index d47c7bf82d..554c72dd89 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -30,6 +30,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" @@ -258,10 +259,21 @@ SEGMENT_IO_LMS_KEY = os.environ.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = True +###################### Payment ##############################3 + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '') +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') + ########################## USER API ######################## EDX_API_KEY = None + +####################### Shoppingcart ########################### +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + ##################################################################### # Lastly, see if the developer has any local overrides. try: diff --git a/lms/envs/test.py b/lms/envs/test.py index bf2df444f4..a9c51310f6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -32,6 +32,8 @@ MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index e4a453133d..4d22e6959c 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -59,7 +59,6 @@ %endif - })(this) @@ -93,6 +92,7 @@ ${_("View Courseware")} %endif + %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html new file mode 100644 index 0000000000..b29ea79aa1 --- /dev/null +++ b/lms/templates/shoppingcart/cybersource_form.html @@ -0,0 +1,6 @@ +
+ % for pk, pv in params.iteritems(): + + % endfor + +
diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html new file mode 100644 index 0000000000..da88dc1a78 --- /dev/null +++ b/lms/templates/shoppingcart/error.html @@ -0,0 +1,14 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Payment Error")} + + +
+

${_("There was an error processing your order!")}

+ ${error_html} + +

${_("Return to cart to retry payment")}

+
diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html new file mode 100644 index 0000000000..cf452baab0 --- /dev/null +++ b/lms/templates/shoppingcart/list.html @@ -0,0 +1,53 @@ +<%! from django.utils.translation import ugettext as _ %> + +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Your Shopping Cart")} + +
+

${_("Your selected items:")}

+ % if shoppingcart_items: + + + ${_("")} + + + % for item in shoppingcart_items: + + + + + % endfor + + + + +
QuantityDescriptionUnit PricePriceCurrency
${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}[x]
${_("Total Amount")}
${"{0:0.2f}".format(amount)}
+ + ${form_html} + % else: +

${_("You have selected no items for purchase.")}

+ % endif + +
+ + + + diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html new file mode 100644 index 0000000000..0386b6b353 --- /dev/null +++ b/lms/templates/shoppingcart/receipt.html @@ -0,0 +1,60 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%! from django.conf import settings %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Receipt for Order")} ${order.id} + +% if notification is not UNDEFINED: +
+ ${notification} +
+% endif + +
+

${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

+

${_("Order #")}${order.id}

+

${_("Date:")} ${order.purchase_time.date().isoformat()}

+

${_("Items ordered:")}

+ + + + ${_("")} + + + % for item in order_items: + + % if item.status == "purchased": + + + + + % elif item.status == "refunded": + + + + + % endif + % endfor + + + +
QtyDescriptionUnit PricePriceCurrency
${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
${_("Total Amount")}
${"{0:0.2f}".format(order.total_cost)}
+ % if any_refunds: +

+ ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} +

+ % endif + +

${_("Billed To:")}

+

+ ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
+ ${order.bill_to_first} ${order.bill_to_last}
+ ${order.bill_to_street1}
+ ${order.bill_to_street2}
+ ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
+ ${order.bill_to_country.upper()}
+

+ +
diff --git a/lms/urls.py b/lms/urls.py index b32c0263d0..53665f9ef6 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -377,6 +377,11 @@ if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): ) +# Shopping cart +urlpatterns += ( + url(r'^shoppingcart/', include('shoppingcart.urls')), +) + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..d700aaa195 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -53,6 +53,7 @@ South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 +django-model-utils==1.4.0 # Used for debugging ipython==0.13.1