Commit 7cd385bb by Diana Huang

Merge pull request #736 from edx/diana/verified-certs-payment

Payment/Shopping Cart Backend
parents 26a301f4 b7619761
......@@ -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,
......
class PaymentException(Exception):
pass
class PurchasedCallbackException(PaymentException):
pass
class InvalidCartItem(PaymentException):
pass
# -*- 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']
# -*- 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']
# -*- 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']
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'OrderItem.fulfilled_time'
db.add_column('shoppingcart_orderitem', 'fulfilled_time',
self.gf('django.db.models.fields.DateTimeField')(null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'OrderItem.fulfilled_time'
db.delete_column('shoppingcart_orderitem', 'fulfilled_time')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'shoppingcart.certificateitem': {
'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']},
'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
},
'shoppingcart.order': {
'Meta': {'object_name': 'Order'},
'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}),
'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}),
'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.orderitem': {
'Meta': {'object_name': 'OrderItem'},
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}),
'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}),
'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}),
'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'shoppingcart.paidcourseregistration': {
'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}),
'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'})
},
'student.courseenrollment': {
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['shoppingcart']
\ No newline at end of file
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()
### 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.
""")),
}
)
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)
from shoppingcart.exceptions import PaymentException
class CCProcessorException(PaymentException):
pass
class CCProcessorSignatureException(CCProcessorException):
pass
class CCProcessorDataException(CCProcessorException):
pass
class CCProcessorWrongAmountException(CCProcessorException):
pass
"""
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'])
"""
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')
"""
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'])
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'),
)
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>
......
<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>
<%! 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>
<%! 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>
<%! 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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment