diff --git a/lms/djangoapps/shoppingcart/migrations/0027_add_invoice_history.py b/lms/djangoapps/shoppingcart/migrations/0027_add_invoice_history.py new file mode 100644 index 0000000000..57e1e9822c --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0027_add_invoice_history.py @@ -0,0 +1,262 @@ +# -*- 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 'InvoiceHistory' + db.create_table('shoppingcart_invoicehistory', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('timestamp', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('invoice', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Invoice'])), + ('snapshot', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('shoppingcart', ['InvoiceHistory']) + + + def backwards(self, orm): + # Deleting model 'InvoiceHistory' + db.delete_table('shoppingcart_invoicehistory') + + + 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': ('xmodule_django.models.CourseKeyField', [], {'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.coupon': { + 'Meta': {'object_name': 'Coupon'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 8, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'expiration_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.courseregcodeitem': { + 'Meta': {'object_name': 'CourseRegCodeItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'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'}) + }, + 'shoppingcart.courseregcodeitemannotation': { + 'Meta': {'object_name': 'CourseRegCodeItemAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 8, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']", 'null': 'True'}), + 'invoice_item': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCodeInvoiceItem']", 'null': 'True'}), + 'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100', 'null': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'purchase_order'", 'null': 'True', 'to': "orm['shoppingcart.Order']"}) + }, + 'shoppingcart.courseregistrationcodeinvoiceitem': { + 'Meta': {'object_name': 'CourseRegistrationCodeInvoiceItem', '_ormbases': ['shoppingcart.InvoiceItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'invoiceitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.InvoiceItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.donation': { + 'Meta': {'object_name': 'Donation', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'donation_type': ('django.db.models.fields.CharField', [], {'default': "'general'", 'max_length': '32'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.donationconfiguration': { + 'Meta': {'object_name': 'DonationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.invoice': { + 'Meta': {'object_name': 'Invoice'}, + 'address_line_1': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'address_line_2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'address_line_3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'internal_reference': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'is_valid': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}), + 'total_amount': ('django.db.models.fields.FloatField', [], {}), + 'zip': ('django.db.models.fields.CharField', [], {'max_length': '15', 'null': 'True'}) + }, + 'shoppingcart.invoicehistory': { + 'Meta': {'object_name': 'InvoiceHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']"}), + 'snapshot': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}) + }, + 'shoppingcart.invoiceitem': { + 'Meta': {'object_name': 'InvoiceItem'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']"}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'unit_price': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}) + }, + 'shoppingcart.invoicetransaction': { + 'Meta': {'object_name': 'InvoiceTransaction'}, + 'amount': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'comments': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Invoice']"}), + 'last_modified_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'last_modified_by_user'", 'to': "orm['auth.User']"}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'started'", 'max_length': '32'}) + }, + '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'}), + 'company_contact_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_contact_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'customer_reference_number': ('django.db.models.fields.CharField', [], {'max_length': '63', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order_type': ('django.db.models.fields.CharField', [], {'default': "'personal'", 'max_length': '32'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'recipient_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'recipient_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'refunded_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'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + '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_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'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'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'shoppingcart.registrationcoderedemption': { + 'Meta': {'object_name': 'RegistrationCodeRedemption'}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']", 'null': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2015, 2, 8, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'registration_code': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.CourseRegistrationCode']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 76cf81d6fb..b11eb15db4 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -4,6 +4,7 @@ from collections import namedtuple from datetime import datetime from datetime import timedelta from decimal import Decimal +import json import analytics from io import BytesIO import pytz @@ -21,6 +22,8 @@ from django.contrib.auth.models import User from django.utils.translation import ugettext as _, ugettext_lazy from django.db import transaction from django.db.models import Sum +from django.db.models.signals import post_save, post_delete + from django.core.urlresolvers import reverse from model_utils.managers import InheritanceManager from model_utils.models import TimeStampedModel @@ -846,6 +849,48 @@ class Invoice(TimeStampedModel): return pdf_buffer + def snapshot(self): + """Create a snapshot of the invoice. + + A snapshot is a JSON-serializable representation + of the invoice's state, including its line items + and associated transactions (payments/refunds). + + This is useful for saving the history of changes + to the invoice. + + Returns: + dict + + """ + return { + 'internal_reference': self.internal_reference, + 'customer_reference': self.customer_reference_number, + 'is_valid': self.is_valid, + 'contact_info': { + 'company_name': self.company_name, + 'company_contact_name': self.company_contact_name, + 'company_contact_email': self.company_contact_email, + 'recipient_name': self.recipient_name, + 'recipient_email': self.recipient_email, + 'address_line_1': self.address_line_1, + 'address_line_2': self.address_line_2, + 'address_line_3': self.address_line_3, + 'city': self.city, + 'state': self.state, + 'zip': self.zip, + 'country': self.country, + }, + 'items': [ + item.snapshot() + for item in InvoiceItem.objects.filter(invoice=self).select_subclasses() + ], + 'transactions': [ + trans.snapshot() + for trans in InvoiceTransaction.objects.filter(invoice=self) + ], + } + def __unicode__(self): label = ( unicode(self.internal_reference) @@ -927,6 +972,24 @@ class InvoiceTransaction(TimeStampedModel): created_by = models.ForeignKey(User) last_modified_by = models.ForeignKey(User, related_name='last_modified_by_user') + def snapshot(self): + """Create a snapshot of the invoice transaction. + + The returned dictionary is JSON-serializable. + + Returns: + dict + + """ + return { + 'amount': unicode(self.amount), + 'currency': self.currency, + 'comments': self.comments, + 'status': self.status, + 'created_by': self.created_by.username, # pylint: disable=no-member + 'last_modified_by': self.last_modified_by.username # pylint: disable=no-member + } + class InvoiceItem(TimeStampedModel): """ @@ -956,6 +1019,21 @@ class InvoiceItem(TimeStampedModel): help_text=ugettext_lazy("Lower-case ISO currency codes") ) + def snapshot(self): + """Create a snapshot of the invoice item. + + The returned dictionary is JSON-serializable. + + Returns: + dict + + """ + return { + 'qty': self.qty, + 'unit_price': unicode(self.unit_price), + 'currency': self.currency + } + class CourseRegistrationCodeInvoiceItem(InvoiceItem): """ @@ -965,6 +1043,89 @@ class CourseRegistrationCodeInvoiceItem(InvoiceItem): """ course_id = CourseKeyField(max_length=128, db_index=True) + def snapshot(self): + """Create a snapshot of the invoice item. + + This is the same as a snapshot for other invoice items, + with the addition of a `course_id` field. + + Returns: + dict + + """ + snapshot = super(CourseRegistrationCodeInvoiceItem, self).snapshot() + snapshot['course_id'] = unicode(self.course_id) + return snapshot + + +class InvoiceHistory(models.Model): + """History of changes to invoices. + + This table stores snapshots of invoice state, + including the associated line items and transactions + (payments/refunds). + + Entries in the table are created, but never deleted + or modified. + + We use Django signals to save history entries on change + events. These signals are fired within a database + transaction, so the history record is created only + if the invoice change is successfully persisted. + + """ + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + invoice = models.ForeignKey(Invoice) + + # JSON-serialized representation of the current state + # of the invoice, including its line items and + # transactions (payments/refunds). + snapshot = models.TextField(blank=True) + + @classmethod + def save_invoice_snapshot(cls, invoice): + """Save a snapshot of the invoice's current state. + + Arguments: + invoice (Invoice): The invoice to save. + + """ + cls.objects.create( + invoice=invoice, + snapshot=json.dumps(invoice.snapshot()) + ) + + @staticmethod + def snapshot_receiver(sender, instance, **kwargs): # pylint: disable=unused-argument + """Signal receiver that saves a snapshot of an invoice. + + Arguments: + sender: Not used, but required by Django signals. + instance (Invoice, InvoiceItem, or InvoiceTransaction) + + """ + if isinstance(instance, Invoice): + InvoiceHistory.save_invoice_snapshot(instance) + elif hasattr(instance, 'invoice'): + InvoiceHistory.save_invoice_snapshot(instance.invoice) + + class Meta: # pylint: disable=missing-docstring,old-style-class + get_latest_by = "timestamp" + + +# Hook up Django signals to record changes in the history table. +# We record any change to an invoice, invoice item, or transaction. +# We also record any deletion of a transaction, since users can delete +# transactions via Django admin. +# Note that we need to include *each* InvoiceItem subclass +# here, since Django signals do not fire automatically for subclasses +# of the "sender" class. +post_save.connect(InvoiceHistory.snapshot_receiver, sender=Invoice) +post_save.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceItem) +post_save.connect(InvoiceHistory.snapshot_receiver, sender=CourseRegistrationCodeInvoiceItem) +post_save.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceTransaction) +post_delete.connect(InvoiceHistory.snapshot_receiver, sender=InvoiceTransaction) + class CourseRegistrationCode(models.Model): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 1662fd5b08..81646fafed 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -4,6 +4,8 @@ Tests for the Shopping Cart Models from decimal import Decimal import datetime import sys +import json +import copy import smtplib from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors @@ -18,15 +20,14 @@ from django.db import DatabaseError from django.test import TestCase from django.test.utils import override_settings from django.contrib.auth.models import AnonymousUser -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, mixed_store_config -) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from shoppingcart.models import ( Order, OrderItem, CertificateItem, InvalidCartItem, CourseRegistrationCode, PaidCourseRegistration, CourseRegCodeItem, - Donation, OrderItemSubclassPK + Donation, OrderItemSubclassPK, + Invoice, CourseRegistrationCodeInvoiceItem, InvoiceTransaction, InvoiceHistory ) from student.tests.factories import UserFactory from student.models import CourseEnrollment @@ -850,3 +851,164 @@ class DonationTest(ModuleStoreTestCase): # Verify that the donation is marked as purchased donation = Donation.objects.get(pk=donation.id) self.assertEqual(donation.status, "purchased") + + +class InvoiceHistoryTest(TestCase): + """Tests for the InvoiceHistory model. """ + + INVOICE_INFO = { + 'is_valid': True, + 'internal_reference': 'Test Internal Ref Num', + 'customer_reference_number': 'Test Customer Ref Num', + } + + CONTACT_INFO = { + 'company_name': 'Test Company', + 'company_contact_name': 'Test Company Contact Name', + 'company_contact_email': 'test-contact@example.com', + 'recipient_name': 'Test Recipient Name', + 'recipient_email': 'test-recipient@example.com', + 'address_line_1': 'Test Address 1', + 'address_line_2': 'Test Address 2', + 'address_line_3': 'Test Address 3', + 'city': 'Test City', + 'state': 'Test State', + 'zip': '12345', + 'country': 'US', + } + + def setUp(self): + invoice_data = copy.copy(self.INVOICE_INFO) + invoice_data.update(self.CONTACT_INFO) + self.invoice = Invoice.objects.create(total_amount="123.45", **invoice_data) + self.course_key = CourseLocator('edX', 'DemoX', 'Demo_Course') + self.user = UserFactory.create() + + def test_invoice_contact_info_history(self): + self._assert_history_invoice_info( + is_valid=True, + internal_ref=self.INVOICE_INFO['internal_reference'], + customer_ref=self.INVOICE_INFO['customer_reference_number'] + ) + self._assert_history_contact_info(**self.CONTACT_INFO) + self._assert_history_items([]) + self._assert_history_transactions([]) + + def test_invoice_history_items(self): + # Create an invoice item + CourseRegistrationCodeInvoiceItem.objects.create( + invoice=self.invoice, + qty=1, + unit_price='123.45', + course_id=self.course_key + ) + self._assert_history_items([{ + 'qty': 1, + 'unit_price': '123.45', + 'currency': 'usd', + 'course_id': unicode(self.course_key) + }]) + + # Create a second invoice item + CourseRegistrationCodeInvoiceItem.objects.create( + invoice=self.invoice, + qty=2, + unit_price='456.78', + course_id=self.course_key + ) + self._assert_history_items([ + { + 'qty': 1, + 'unit_price': '123.45', + 'currency': 'usd', + 'course_id': unicode(self.course_key) + }, + { + 'qty': 2, + 'unit_price': '456.78', + 'currency': 'usd', + 'course_id': unicode(self.course_key) + } + ]) + + def test_invoice_history_transactions(self): + # Create an invoice transaction + first_transaction = InvoiceTransaction.objects.create( + invoice=self.invoice, + amount='123.45', + currency='usd', + comments='test comments', + status='completed', + created_by=self.user, + last_modified_by=self.user + ) + self._assert_history_transactions([{ + 'amount': '123.45', + 'currency': 'usd', + 'comments': 'test comments', + 'status': 'completed', + 'created_by': self.user.username, + 'last_modified_by': self.user.username, + }]) + + # Create a second invoice transaction + second_transaction = InvoiceTransaction.objects.create( + invoice=self.invoice, + amount='456.78', + currency='usd', + comments='test more comments', + status='started', + created_by=self.user, + last_modified_by=self.user + ) + self._assert_history_transactions([ + { + 'amount': '123.45', + 'currency': 'usd', + 'comments': 'test comments', + 'status': 'completed', + 'created_by': self.user.username, + 'last_modified_by': self.user.username, + }, + { + 'amount': '456.78', + 'currency': 'usd', + 'comments': 'test more comments', + 'status': 'started', + 'created_by': self.user.username, + 'last_modified_by': self.user.username, + } + ]) + + # Delete the transactions + first_transaction.delete() + second_transaction.delete() + self._assert_history_transactions([]) + + def _assert_history_invoice_info(self, is_valid=True, customer_ref=None, internal_ref=None): + """Check top-level invoice information in the latest history record. """ + latest = self._latest_history() + self.assertEqual(latest['is_valid'], is_valid) + self.assertEqual(latest['customer_reference'], customer_ref) + self.assertEqual(latest['internal_reference'], internal_ref) + + def _assert_history_contact_info(self, **kwargs): + """Check contact info in the latest history record. """ + contact_info = self._latest_history()['contact_info'] + for key, value in kwargs.iteritems(): + self.assertEqual(contact_info[key], value) + + def _assert_history_items(self, expected_items): + """Check line item info in the latest history record. """ + items = self._latest_history()['items'] + self.assertItemsEqual(items, expected_items) + + def _assert_history_transactions(self, expected_transactions): + """Check transactions (payments/refunds) in the latest history record. """ + transactions = self._latest_history()['transactions'] + self.assertItemsEqual(transactions, expected_transactions) + + def _latest_history(self): + """Retrieve the snapshot from the latest history record. """ + latest = InvoiceHistory.objects.latest() + return json.loads(latest.snapshot)