Commit 3f9c52cd by Jason Bau Committed by Diana Huang

Move shopping cart from session into model/db

parent ea7cf3a2
......@@ -386,7 +386,7 @@ def change_enrollment(request):
CourseEnrollment.unenroll(user, course_id)
org, course_num, run = course_id.split("/")
log.increment("common.student.unenrollment",
statsd.increment("common.student.unenrollment",
tags=["org:{0}".format(org),
"course:{0}".format(course_num),
"run:{0}".format(run)])
......
import logging
from django.contrib.auth.models import User
from student.views import course_from_id
from student.models import CourseEnrollmentAllowed, CourseEnrollment
from statsd import statsd
log = logging.getLogger("shoppingcart")
class InventoryItem(object):
"""
This is the abstract interface for inventory items.
Inventory items are things that fill up the shopping cart.
Each implementation of InventoryItem should have purchased_callback as
a method and data attributes as defined in __init__ below
"""
def __init__(self):
# Set up default data attribute values
self.qty = 1
self.unit_cost = 0 # in dollars
self.line_cost = 0 # qty * unit_cost
self.line_desc = "Misc Item"
def purchased_callback(self, user_id):
"""
This is called on each inventory item in the shopping cart when the
purchase goes through. The parameter provided is the id of the user who
made the purchase.
"""
raise NotImplementedError
class PaidCourseRegistration(InventoryItem):
"""
This is an inventory item for paying for a course registration
"""
def __init__(self, course_id, unit_cost):
course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to
# throw errors if it doesn't
self.qty = 1
self.unit_cost = unit_cost
self.line_cost = unit_cost
self.course_id = course_id
self.line_desc = "Registration for Course {0}".format(course_id)
def purchased_callback(self, user_id):
"""
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.
"""
user = User.objects.get(id=user_id)
course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to
# throw errors if it doesn't
# use get_or_create here to gracefully handle case where the user is already enrolled in the course, for
# whatever reason.
# Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency
# with rest of codebase.
CourseEnrollmentAllowed.objects.get_or_create(email=user.email, course_id=self.course_id, auto_enroll=True)
CourseEnrollment.objects.get_or_create(user=user, course_id=self.course_id)
log.info("Enrolled {0} in paid course {1}, paid ${2}".format(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)])
# -*- 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'])),
('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)),
('nonce', self.gf('django.db.models.fields.CharField')(max_length=128)),
('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, 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.FloatField')(default=0.0)),
('line_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)),
('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)),
))
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'])
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')
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.order': {
'Meta': {'object_name': 'Order'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'nonce': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'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'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}),
'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.FloatField', [], {'default': '0.0'}),
'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'})
}
}
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 student.views import course_from_id
from student.models import CourseEnrollmentAllowed, CourseEnrollment
from statsd import statsd
log = logging.getLogger("shoppingcart")
# Create your models here.
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.
THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart' PER USER.
"""
user = models.ForeignKey(User, db_index=True)
status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES)
# Because we allow an external service to tell us when something is purchased, and our order numbers
# are their pk and therefore predicatble, let's protect against
# forged/replayed replies with a nonce.
nonce = models.CharField(max_length=128)
purchase_time = models.DateTimeField(null=True, blank=True)
@classmethod
def get_cart_for_user(cls, user):
"""
Use this to enforce the property that at most 1 order per user has status = 'cart'
"""
order, created = cls.objects.get_or_create(user=user, status='cart')
return order
@property
def total_cost(self):
return sum([i.line_cost for i in self.orderitem_set.all()])
def purchase(self):
"""
Call to mark this order as purchased. Iterates through its OrderItems and calls
their purchased_callback
"""
self.status = 'purchased'
self.purchase_time = datetime.now(pytz.utc)
self.save()
for item in self.orderitem_set.all():
item.status = 'purchased'
item.purchased_callback()
item.save()
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.
"""
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.FloatField(default=0.0)
line_cost = models.FloatField(default=0.0) # qty * unit_cost
line_desc = models.CharField(default="Misc. Item", max_length=1024)
def add_to_order(self, *args, **kwargs):
"""
A suggested convenience function for subclasses.
"""
raise NotImplementedError
def purchased_callback(self):
"""
This is called on each inventory item in the shopping cart when the
purchase goes through.
NOTE: We want to provide facilities for doing something like
for item in OrderItem.objects.filter(order_id=order_id):
item.purchased_callback()
Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific
subclasses. That means this parent class implementation of purchased_callback needs to act as
a dispatcher to call the callback the proper subclasses, and as such it needs to know about all subclasses.
So please add
"""
for classname, lc_classname in ORDER_ITEM_SUBTYPES:
try:
sub_instance = getattr(self,lc_classname)
sub_instance.purchased_callback()
except (ObjectDoesNotExist, AttributeError):
log.exception('Cannot call purchase_callback on non-existent subclass attribute {0} of OrderItem'\
.format(lc_classname))
pass
# Each entry is a tuple of ('ModelName', 'lower_case_model_name')
# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for
# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem
ORDER_ITEM_SUBTYPES = [
('PaidCourseRegistration', 'paidcourseregistration')
]
class PaidCourseRegistration(OrderItem):
"""
This is an inventory item for paying for a course registration
"""
course_id = models.CharField(max_length=128, db_index=True)
@classmethod
def add_to_order(cls, order, course_id, cost):
"""
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
item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id)
item.status = order.status
item.qty = 1
item.unit_cost = cost
item.line_cost = cost
item.line_desc = "Registration for Course {0}".format(course_id)
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.
"""
course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to
# throw errors if it doesn't
# use get_or_create here to gracefully handle case where the user is already enrolled in the course, for
# whatever reason.
# Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency
# with rest of codebase.
CourseEnrollmentAllowed.objects.get_or_create(email=self.user.email, course_id=self.course_id, auto_enroll=True)
CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id)
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)])
......@@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url
urlpatterns = patterns('shoppingcart.views', # nopep8
url(r'^$','show_cart'),
url(r'^(?P<course_id>[^/]+/[^/]+/[^/]+)/$','test'),
url(r'^add/(?P<course_id>[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'),
url(r'^add/course/(?P<course_id>[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'),
url(r'^clear/$','clear_cart'),
url(r'^remove_item/$', 'remove_item'),
url(r'^purchased/$', 'purchased'),
)
\ No newline at end of file
......@@ -7,10 +7,10 @@ from hashlib import sha1
from django.conf import settings
from collections import OrderedDict
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response
from .inventory_types import *
from .models import *
log = logging.getLogger("shoppingcart")
......@@ -21,49 +21,61 @@ def test(request, course_id):
return HttpResponse('OK')
@login_required
def purchased(request):
#verify() -- signatures, total cost match up, etc. Need error handling code (
# If verify fails probaly need to display a contact email/number)
cart = Order.get_cart_for_user(request.user)
cart.purchase()
return HttpResponseRedirect('/')
@login_required
def add_course_to_cart(request, course_id):
cart = request.session.get('shopping_cart', [])
course_ids_in_cart = [i.course_id for i in cart if isinstance(i, PaidCourseRegistration)]
if course_id not in course_ids_in_cart:
cart = Order.get_cart_for_user(request.user)
# TODO: Catch 500 here for course that does not exist, period
item = PaidCourseRegistration(course_id, 200)
cart.append(item)
request.session['shopping_cart'] = cart
return HttpResponse('Added')
else:
return HttpResponse("Item exists, not adding")
PaidCourseRegistration.add_to_order(cart, course_id, 200)
return HttpResponse("Added")
@login_required
def show_cart(request):
cart = request.session.get('shopping_cart', [])
total_cost = "{0:0.2f}".format(sum([i.line_cost for i in cart]))
cart = Order.get_cart_for_user(request.user)
total_cost = cart.total_cost
cart_items = cart.orderitem_set.all()
params = OrderedDict()
params['amount'] = total_cost
params['currency'] = 'usd'
params['orderPage_transactionType'] = 'sale'
params['orderNumber'] = "{0:d}".format(random.randint(1, 10000))
params['orderNumber'] = "{0:d}".format(cart.id)
params['billTo_email'] = request.user.email
idx=1
for item in cart_items:
prefix = "item_{0:d}_".format(idx)
params[prefix+'productSKU'] = "{0:d}".format(item.id)
params[prefix+'quantity'] = item.qty
params[prefix+'productName'] = item.line_desc
params[prefix+'unitPrice'] = item.unit_cost
params[prefix+'taxAmount'] = "0.00"
signed_param_dict = cybersource_sign(params)
return render_to_response("shoppingcart/list.html",
{'shoppingcart_items': cart,
{'shoppingcart_items': cart_items,
'total_cost': total_cost,
'params': signed_param_dict,
})
@login_required
def clear_cart(request):
request.session['shopping_cart'] = []
cart = Order.get_cart_for_user(request.user)
cart.orderitem_set.all().delete()
return HttpResponse('Cleared')
@login_required
def remove_item(request):
# doing this with indexes to replicate the function that generated the list on the HTML page
item_idx = request.REQUEST.get('idx', 'blank')
item_id = request.REQUEST.get('id', '-1')
try:
cart = request.session.get('shopping_cart', [])
cart.pop(int(item_idx))
request.session['shopping_cart'] = cart
except IndexError, ValueError:
log.exception('Cannot remove element at index {0} from cart'.format(item_idx))
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')
......
......@@ -778,6 +778,9 @@ INSTALLED_APPS = (
'rest_framework',
'user_api',
# shopping cart
'shoppingcart',
# Notification preferences setting
'notification_prefs',
......
......@@ -13,9 +13,9 @@
<tr><td>Qty</td><td>Description</td><td>Unit Price</td><td>Price</td></tr>
</thead>
<tbody>
% for idx,item in enumerate(shoppingcart_items):
% for item in shoppingcart_items:
<tr><td>${item.qty}</td><td>${item.line_desc}</td><td>${item.unit_cost}</td><td>${item.line_cost}</td>
<td><a data-item-idx="${idx}" class='remove_line_item' href='#'>[x]</a></td></tr>
<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 Cost</td></tr>
<tr><td></td><td></td><td></td><td>${total_cost}</td></tr>
......@@ -41,7 +41,7 @@
$('a.remove_line_item').click(function(event) {
event.preventDefault();
var post_url = "${reverse('shoppingcart.views.remove_item')}";
$.post(post_url, {idx:$(this).data('item-idx')})
$.post(post_url, {id:$(this).data('item-id')})
.always(function(data){
location.reload(true);
});
......
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