diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 92f9d7f814..4d59b5cc66 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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)]) diff --git a/lms/djangoapps/shoppingcart/inventory_types.py b/lms/djangoapps/shoppingcart/inventory_types.py deleted file mode 100644 index 0230760cb5..0000000000 --- a/lms/djangoapps/shoppingcart/inventory_types.py +++ /dev/null @@ -1,68 +0,0 @@ -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)]) diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py new file mode 100644 index 0000000000..779eccc94d --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# -*- 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 diff --git a/lms/djangoapps/shoppingcart/migrations/__init__.py b/lms/djangoapps/shoppingcart/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 71a8362390..4b8ac259dd 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,3 +1,161 @@ +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)]) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 47bd3c4c3d..80653f93cb 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^$','show_cart'), url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), - url(r'^add/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','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 diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a2680fd845..4c2a4dd091 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -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") @@ -20,50 +20,62 @@ def test(request, course_id): item1.purchased_callback(request.user.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: - # 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") + cart = Order.get_cart_for_user(request.user) + # TODO: Catch 500 here for course that does not exist, period + 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') diff --git a/lms/envs/common.py b/lms/envs/common.py index d397371cc2..420068f7bd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -778,6 +778,9 @@ INSTALLED_APPS = ( 'rest_framework', 'user_api', + # shopping cart + 'shoppingcart', + # Notification preferences setting 'notification_prefs', diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index a1f785c8b4..a37aa0fb5f 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -13,9 +13,9 @@ QtyDescriptionUnit PricePrice - % for idx,item in enumerate(shoppingcart_items): + % for item in shoppingcart_items: ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost} - [x] + [x] % endfor Total Cost ${total_cost} @@ -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); });