Merge pull request #736 from edx/diana/verified-certs-payment
Payment/Shopping Cart Backend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
0
lms/djangoapps/shoppingcart/__init__.py
Normal file
0
lms/djangoapps/shoppingcart/__init__.py
Normal file
10
lms/djangoapps/shoppingcart/exceptions.py
Normal file
10
lms/djangoapps/shoppingcart/exceptions.py
Normal file
@@ -0,0 +1,10 @@
|
||||
class PaymentException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PurchasedCallbackException(PaymentException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidCartItem(PaymentException):
|
||||
pass
|
||||
166
lms/djangoapps/shoppingcart/migrations/0001_initial.py
Normal file
166
lms/djangoapps/shoppingcart/migrations/0001_initial.py
Normal file
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
0
lms/djangoapps/shoppingcart/migrations/__init__.py
Normal file
0
lms/djangoapps/shoppingcart/migrations/__init__.py
Normal file
322
lms/djangoapps/shoppingcart/models.py
Normal file
322
lms/djangoapps/shoppingcart/models.py
Normal file
@@ -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()
|
||||
405
lms/djangoapps/shoppingcart/processors/CyberSource.py
Normal file
405
lms/djangoapps/shoppingcart/processors/CyberSource.py
Normal file
@@ -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(
|
||||
"""
|
||||
<p class="error_msg">
|
||||
Sorry! Our payment processor did not accept your payment.
|
||||
The decision in they returned was <span class="decision">{decision}</span>,
|
||||
and the reason was <span class="reason">{reason_code}:{reason_msg}</span>.
|
||||
You were not charged. Please try a different form of payment.
|
||||
Contact us with payment-specific questions at {email}.
|
||||
</p>
|
||||
"""))
|
||||
|
||||
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(
|
||||
"""
|
||||
<p class="error_msg">
|
||||
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: <span class="exception_msg">{msg}</span>.
|
||||
Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.
|
||||
</p>
|
||||
""".format(msg=exception.message, email=payment_support_email)))
|
||||
return msg
|
||||
elif isinstance(exception, CCProcessorWrongAmountException):
|
||||
msg = _(dedent(
|
||||
"""
|
||||
<p class="error_msg">
|
||||
Sorry! Due to an error your purchase was charged for a different amount than the order total!
|
||||
The specific error message is: <span class="exception_msg">{msg}</span>.
|
||||
Your credit card has probably been charged. Contact us with payment-specific questions at {email}.
|
||||
</p>
|
||||
""".format(msg=exception.message, email=payment_support_email)))
|
||||
return msg
|
||||
elif isinstance(exception, CCProcessorSignatureException):
|
||||
msg = _(dedent(
|
||||
"""
|
||||
<p class="error_msg">
|
||||
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: <span class="exception_msg">{msg}</span>.
|
||||
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}.
|
||||
</p>
|
||||
""".format(msg=exception.message, email=payment_support_email)))
|
||||
return msg
|
||||
|
||||
# fallthrough case, which basically never happens
|
||||
return '<p class="error_msg">EXCEPTION!</p>'
|
||||
|
||||
|
||||
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.
|
||||
""")),
|
||||
}
|
||||
)
|
||||
33
lms/djangoapps/shoppingcart/processors/__init__.py
Normal file
33
lms/djangoapps/shoppingcart/processors/__init__.py
Normal file
@@ -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)
|
||||
17
lms/djangoapps/shoppingcart/processors/exceptions.py
Normal file
17
lms/djangoapps/shoppingcart/processors/exceptions.py
Normal file
@@ -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
|
||||
288
lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py
Normal file
288
lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py
Normal file
@@ -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'])
|
||||
0
lms/djangoapps/shoppingcart/tests/__init__.py
Normal file
0
lms/djangoapps/shoppingcart/tests/__init__.py
Normal file
185
lms/djangoapps/shoppingcart/tests/test_models.py
Normal file
185
lms/djangoapps/shoppingcart/tests/test_models.py
Normal file
@@ -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')
|
||||
222
lms/djangoapps/shoppingcart/tests/test_views.py
Normal file
222
lms/djangoapps/shoppingcart/tests/test_views.py
Normal file
@@ -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'])
|
||||
15
lms/djangoapps/shoppingcart/urls.py
Normal file
15
lms/djangoapps/shoppingcart/urls.py
Normal file
@@ -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<ordernum>[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<course_id>[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'),
|
||||
)
|
||||
105
lms/djangoapps/shoppingcart/views.py
Normal file
105
lms/djangoapps/shoppingcart/views.py
Normal file
@@ -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})
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
|
||||
%endif
|
||||
|
||||
|
||||
})(this)
|
||||
</script>
|
||||
|
||||
@@ -93,6 +92,7 @@
|
||||
<strong>${_("View Courseware")}</strong>
|
||||
</a>
|
||||
%endif
|
||||
|
||||
%else:
|
||||
<a href="#" class="register">${_("Register for {course.display_number_with_default}").format(course=course) | h}</a>
|
||||
|
||||
|
||||
6
lms/templates/shoppingcart/cybersource_form.html
Normal file
6
lms/templates/shoppingcart/cybersource_form.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<form action="${action}" method="post">
|
||||
% for pk, pv in params.iteritems():
|
||||
<input type="hidden" name="${pk}" value="${pv}" />
|
||||
% endfor
|
||||
<input type="submit" value="Check Out" />
|
||||
</form>
|
||||
14
lms/templates/shoppingcart/error.html
Normal file
14
lms/templates/shoppingcart/error.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="title"><title>${_("Payment Error")}</title></%block>
|
||||
|
||||
|
||||
<section class="container">
|
||||
<p><h1>${_("There was an error processing your order!")}</h1></p>
|
||||
${error_html}
|
||||
|
||||
<p><a href="${reverse('shoppingcart.views.show_cart')}">${_("Return to cart to retry payment")}</a></p>
|
||||
</section>
|
||||
53
lms/templates/shoppingcart/list.html
Normal file
53
lms/templates/shoppingcart/list.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="title"><title>${_("Your Shopping Cart")}</title></%block>
|
||||
|
||||
<section class="container cart-list">
|
||||
<h2>${_("Your selected items:")}</h2>
|
||||
% if shoppingcart_items:
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${_("<td>Quantity</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for item in shoppingcart_items:
|
||||
<tr><td>${item.qty}</td><td>${item.line_desc}</td>
|
||||
<td>${"{0:0.2f}".format(item.unit_cost)}</td><td>${"{0:0.2f}".format(item.line_cost)}</td>
|
||||
<td>${item.currency.upper()}</td>
|
||||
<td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td></tr>
|
||||
% endfor
|
||||
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
|
||||
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(amount)}</td></tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- <input id="back_input" type="submit" value="Return" /> -->
|
||||
${form_html}
|
||||
% else:
|
||||
<p>${_("You have selected no items for purchase.")}</p>
|
||||
% endif
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$('a.remove_line_item').click(function(event) {
|
||||
event.preventDefault();
|
||||
var post_url = "${reverse('shoppingcart.views.remove_item')}";
|
||||
$.post(post_url, {id:$(this).data('item-id')})
|
||||
.always(function(data){
|
||||
location.reload(true);
|
||||
});
|
||||
});
|
||||
|
||||
$('#back_input').click(function(){
|
||||
history.back();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
60
lms/templates/shoppingcart/receipt.html
Normal file
60
lms/templates/shoppingcart/receipt.html
Normal file
@@ -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"><title>${_("Receipt for Order")} ${order.id}</title></%block>
|
||||
|
||||
% if notification is not UNDEFINED:
|
||||
<section class="notification">
|
||||
${notification}
|
||||
</section>
|
||||
% endif
|
||||
|
||||
<section class="container cart-list">
|
||||
<p><h1>${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}</h1></p>
|
||||
<h2>${_("Order #")}${order.id}</h2>
|
||||
<h2>${_("Date:")} ${order.purchase_time.date().isoformat()}</h2>
|
||||
<h2>${_("Items ordered:")}</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${_("<td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td><td>Currency</td>")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for item in order_items:
|
||||
<tr>
|
||||
% if item.status == "purchased":
|
||||
<td>${item.qty}</td><td>${item.line_desc}</td>
|
||||
<td>${"{0:0.2f}".format(item.unit_cost)}</td>
|
||||
<td>${"{0:0.2f}".format(item.line_cost)}</td>
|
||||
<td>${item.currency.upper()}</td></tr>
|
||||
% elif item.status == "refunded":
|
||||
<td><del>${item.qty}</del></td><td><del>${item.line_desc}</del></td>
|
||||
<td><del>${"{0:0.2f}".format(item.unit_cost)}</del></td>
|
||||
<td><del>${"{0:0.2f}".format(item.line_cost)}</del></td>
|
||||
<td><del>${item.currency.upper()}</del></td></tr>
|
||||
% endif
|
||||
% endfor
|
||||
<tr><td></td><td></td><td></td><td>${_("Total Amount")}</td></tr>
|
||||
<tr><td></td><td></td><td></td><td>${"{0:0.2f}".format(order.total_cost)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
% if any_refunds:
|
||||
<p>
|
||||
${_("Note: items with strikethough like ")}<del>this</del>${_(" have been refunded.")}
|
||||
</p>
|
||||
% endif
|
||||
|
||||
<h2>${_("Billed To:")}</h2>
|
||||
<p>
|
||||
${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}<br />
|
||||
${order.bill_to_first} ${order.bill_to_last}<br />
|
||||
${order.bill_to_street1}<br />
|
||||
${order.bill_to_street2}<br />
|
||||
${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}<br />
|
||||
${order.bill_to_country.upper()}<br />
|
||||
</p>
|
||||
|
||||
</section>
|
||||
@@ -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 += (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user