From ac10ea4ee895cdc087ab72148455c203fb04e142 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 10:19:18 +1000 Subject: [PATCH 1/8] s/cart_controller_helper/controller_helpers/ --- ...art_controller_helper.py => controller_helpers.py} | 0 registrasion/tests/test_cart.py | 2 +- registrasion/tests/test_ceilings.py | 2 +- registrasion/tests/test_enabling_condition.py | 2 +- registrasion/tests/test_invoice.py | 11 +++++++++-- registrasion/tests/test_refund.py | 2 +- registrasion/tests/test_voucher.py | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) rename registrasion/tests/{cart_controller_helper.py => controller_helpers.py} (100%) diff --git a/registrasion/tests/cart_controller_helper.py b/registrasion/tests/controller_helpers.py similarity index 100% rename from registrasion/tests/cart_controller_helper.py rename to registrasion/tests/controller_helpers.py diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d47d659e..10ba10d2 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -10,7 +10,7 @@ from django.test import TestCase from registrasion import models as rego from registrasion.controllers.product import ProductController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from patch_datetime import SetTimeMixin UTC = pytz.timezone('UTC') diff --git a/registrasion/tests/test_ceilings.py b/registrasion/tests/test_ceilings.py index f8481fea..c5664481 100644 --- a/registrasion/tests/test_ceilings.py +++ b/registrasion/tests/test_ceilings.py @@ -3,7 +3,7 @@ import pytz from django.core.exceptions import ValidationError -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from test_cart import RegistrationCartTestCase from registrasion import models as rego diff --git a/registrasion/tests/test_enabling_condition.py b/registrasion/tests/test_enabling_condition.py index 5d0e410c..d977cc5c 100644 --- a/registrasion/tests/test_enabling_condition.py +++ b/registrasion/tests/test_enabling_condition.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from registrasion.controllers.category import CategoryController -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.product import ProductController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 19332392..080ca008 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -5,7 +5,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError from registrasion import models as rego -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -197,4 +197,11 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - InvoiceController.for_cart(current_cart.cart) + invoice_1 = InvoiceController.for_cart(current_cart.cart) + + # TODO: test partially paid invoice cannot be void until payments + # are refunded + + # TODO: test overpaid invoice results in credit note + + # TODO: test credit note generation more generally diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index bde25929..e0b6c681 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,6 +1,6 @@ import pytz -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index a8bc6b00..443ce1d7 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from registrasion import models as rego -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase From 563355485435071e8d254425eeca495af0b0e7a2 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 10:23:38 +1000 Subject: [PATCH 2/8] Tests now use TestingInvoiceController --- registrasion/tests/controller_helpers.py | 5 ++++ registrasion/tests/test_discount.py | 3 ++- registrasion/tests/test_invoice.py | 32 ++++++++++++------------ registrasion/tests/test_refund.py | 4 +-- registrasion/tests/test_voucher.py | 4 +-- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 9e4191f0..32af58c8 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -1,4 +1,5 @@ from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist @@ -28,3 +29,7 @@ class TestingCartController(CartController): def next_cart(self): self.cart.active = False self.cart.save() + + +class TestingInvoiceController(InvoiceController): + pass diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index fb35330e..e9105378 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -4,7 +4,8 @@ from decimal import Decimal from registrasion import models as rego from registrasion.controllers import discount -from cart_controller_helper import TestingCartController +from controller_helpers import TestingCartController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 080ca008..0a7404d6 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from registrasion import models as rego from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -20,7 +20,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have a single line item line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) self.assertEqual(1, len(line_items)) @@ -29,7 +29,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) - invoice_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) # The old invoice should automatically be voided @@ -58,13 +58,13 @@ class InvoiceTestCase(RegistrationCartTestCase): # Now try to invoice the first user with self.assertRaises(ValidationError): - InvoiceController.for_cart(current_cart.cart) + TestingInvoiceController.for_cart(current_cart.cart) def test_paying_invoice_makes_new_cart(self): current_cart = TestingCartController.for_user(self.USER_1) current_cart.add_to_cart(self.PROD_1, 1) - invoice = InvoiceController.for_cart(current_cart.cart) + invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. @@ -99,7 +99,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # That invoice should have two line items line_items = rego.LineItem.objects.filter(invoice=invoice_1.invoice) @@ -131,7 +131,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertTrue(invoice_1.invoice.paid) @@ -140,21 +140,21 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertFalse(invoice_1.invoice.void) # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) - invoice_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) # Viewing invoice_1's invoice should show it as void - invoice_1_new = InvoiceController(invoice_1.invoice) + invoice_1_new = TestingInvoiceController(invoice_1.invoice) self.assertTrue(invoice_1_new.invoice.void) # Viewing invoice_2's invoice should *not* show it as void - invoice_2_new = InvoiceController(invoice_2.invoice) + invoice_2_new = TestingInvoiceController(invoice_2.invoice) self.assertFalse(invoice_2_new.invoice.void) def test_voiding_invoice_creates_new_invoice(self): @@ -162,12 +162,12 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) self.assertFalse(invoice_1.invoice.void) invoice_1.void() - invoice_2 = InvoiceController.for_cart(current_cart.cart) + invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) self.assertNotEqual(invoice_1.invoice, invoice_2.invoice) def test_cannot_pay_void_invoice(self): @@ -175,7 +175,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) invoice_1.void() @@ -187,7 +187,7 @@ class InvoiceTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) invoice_1.pay("Reference", invoice_1.invoice.value) @@ -197,7 +197,7 @@ class InvoiceTestCase(RegistrationCartTestCase): def test_cannot_generate_blank_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) with self.assertRaises(ValidationError): - invoice_1 = InvoiceController.for_cart(current_cart.cart) + invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) # TODO: test partially paid invoice cannot be void until payments # are refunded diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index e0b6c681..4510cbda 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -1,7 +1,7 @@ import pytz from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -15,7 +15,7 @@ class RefundTestCase(RegistrationCartTestCase): # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) - invoice = InvoiceController.for_cart(current_cart.cart) + invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A Payment!", invoice.invoice.value) self.assertFalse(invoice.invoice.void) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 443ce1d7..7fa709dc 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -7,7 +7,7 @@ from django.db import IntegrityError from registrasion import models as rego from controller_helpers import TestingCartController -from registrasion.controllers.invoice import InvoiceController +from controller_helpers import TestingInvoiceController from test_cart import RegistrationCartTestCase @@ -140,7 +140,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) - inv = InvoiceController.for_cart(current_cart.cart) + inv = TestingInvoiceController.for_cart(current_cart.cart) if not inv.invoice.paid: inv.pay("Hello!", inv.invoice.value) From 38cdb8aa63300ff6a3dede12c115e8ab34f746ac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 08:28:43 +1000 Subject: [PATCH 3/8] Makes invoice model, controller, and test changes to match issue #15 design doc --- registrasion/controllers/invoice.py | 187 +++++++++++------- ...6_2228_squashed_0015_auto_20160406_1942.py | 88 +++++++++ registrasion/models.py | 83 ++++++-- registrasion/tests/controller_helpers.py | 26 ++- registrasion/tests/test_invoice.py | 16 +- registrasion/tests/test_refund.py | 10 +- registrasion/tests/test_voucher.py | 2 +- 7 files changed, 313 insertions(+), 99 deletions(-) create mode 100644 registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 264049c8..a25221f1 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Sum +from django.utils import timezone from registrasion import models as rego @@ -13,6 +14,7 @@ class InvoiceController(object): def __init__(self, invoice): self.invoice = invoice + self.update_status() self.update_validity() # Make sure this invoice is up-to-date @classmethod @@ -22,21 +24,26 @@ class InvoiceController(object): an invoice is generated.''' try: - invoice = rego.Invoice.objects.get( + invoice = rego.Invoice.objects.exclude( + status=rego.Invoice.STATUS_VOID, + ).get( cart=cart, cart_revision=cart.revision, - void=False, ) except ObjectDoesNotExist: cart_controller = CartController(cart) cart_controller.validate_cart() # Raises ValidationError on fail. - # Void past invoices for this cart - rego.Invoice.objects.filter(cart=cart).update(void=True) - + cls.void_all_invoices(cart) invoice = cls._generate(cart) - return InvoiceController(invoice) + return cls(invoice) + + @classmethod + def void_all_invoices(cls, cart): + invoices = rego.Invoice.objects.filter(cart=cart).all() + for invoice in invoices: + cls(invoice).void() @classmethod def resolve_discount_value(cls, item): @@ -60,11 +67,21 @@ class InvoiceController(object): @transaction.atomic def _generate(cls, cart): ''' Generates an invoice for the given cart. ''' + + issued = timezone.now() + reservation_limit = cart.reservation_duration + cart.time_last_updated + # Never generate a due time that is before the issue time + due = max(issued, reservation_limit) + invoice = rego.Invoice.objects.create( user=cart.user, cart=cart, cart_revision=cart.revision, - value=Decimal() + status=rego.Invoice.STATUS_UNPAID, + value=Decimal(), + issue_time=issued, + due_time=due, + recipient="BOB_THOMAS", # TODO: add recipient generating code ) product_items = rego.ProductItem.objects.filter(cart=cart) @@ -84,6 +101,7 @@ class InvoiceController(object): description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, price=product.price, + product=product, ) invoice_value += line_item.quantity * line_item.price @@ -93,94 +111,121 @@ class InvoiceController(object): description=item.discount.description, quantity=item.quantity, price=cls.resolve_discount_value(item) * -1, + product=item.product, ) invoice_value += line_item.quantity * line_item.price invoice.value = invoice_value - if invoice.value == 0: - invoice.paid = True - invoice.save() return invoice + def total_payments(self): + ''' Returns the total amount paid towards this invoice. ''' + + payments = rego.PaymentBase.objects.filter(invoice=self.invoice) + total_paid = payments.aggregate(Sum("amount"))["amount__sum"] or 0 + return total_paid + + def update_status(self): + ''' Updates the status of this invoice based upon the total + payments.''' + + old_status = self.invoice.status + total_paid = self.total_payments() + num_payments = rego.PaymentBase.objects.filter( + invoice=self.invoice, + ).count() + remainder = self.invoice.value - total_paid + + if old_status == rego.Invoice.STATUS_UNPAID: + # Invoice had an amount owing + if remainder <= 0: + # Invoice no longer has amount owing + self._mark_paid() + elif total_paid == 0 and num_payments > 0: + # Invoice has multiple payments totalling zero + self._mark_void() + elif old_status == rego.Invoice.STATUS_PAID: + if remainder > 0: + # Invoice went from having a remainder of zero or less + # to having a positive remainder -- must be a refund + self._mark_refunded() + elif old_status == rego.Invoice.STATUS_REFUNDED: + # Should not ever change from here + pass + elif old_status == rego.Invoice.STATUS_VOID: + # Should not ever change from here + pass + + def _mark_paid(self): + ''' Marks the invoice as paid, and updates the attached cart if + necessary. ''' + cart = self.invoice.cart + if cart: + cart.active = False + cart.save() + self.invoice.status = rego.Invoice.STATUS_PAID + self.invoice.save() + + def _mark_refunded(self): + ''' Marks the invoice as refunded, and updates the attached cart if + necessary. ''' + cart = self.invoice.cart + if cart: + cart.active = False + cart.released = True + cart.save() + self.invoice.status = rego.Invoice.STATUS_REFUNDED + self.invoice.save() + + def _mark_void(self): + ''' Marks the invoice as refunded, and updates the attached cart if + necessary. ''' + self.invoice.status = rego.Invoice.STATUS_VOID + self.invoice.save() + + def _invoice_matches_cart(self): + ''' Returns true if there is no cart, or if the revision of this + invoice matches the current revision of the cart. ''' + cart = self.invoice.cart + if not cart: + return True + + return cart.revision == self.invoice.cart_revision + def update_validity(self): - ''' Updates the validity of this invoice if the cart it is attached to - has updated. ''' - if self.invoice.cart is not None: - if self.invoice.cart.revision != self.invoice.cart_revision: - self.void() + ''' Voids this invoice if the cart it is attached to has updated. ''' + if not self._invoice_matches_cart(): + self.void() def void(self): ''' Voids the invoice if it is valid to do so. ''' - if self.invoice.paid: + if self.invoice.status == rego.Invoice.STATUS_PAID: raise ValidationError("Paid invoices cannot be voided, " "only refunded.") - self.invoice.void = True - self.invoice.save() - - @transaction.atomic - def pay(self, reference, amount): - ''' Pays the invoice by the given amount. If the payment - equals the total on the invoice, finalise the invoice. - (NB should be transactional.) - ''' - if self.invoice.cart: - cart = CartController(self.invoice.cart) - cart.validate_cart() # Raises ValidationError if invalid - - if self.invoice.void: - raise ValidationError("Void invoices cannot be paid") - - if self.invoice.paid: - raise ValidationError("Paid invoices cannot be paid again") - - ''' Adds a payment ''' - payment = rego.Payment.objects.create( - invoice=self.invoice, - reference=reference, - amount=amount, - ) - payment.save() - - payments = rego.Payment.objects.filter(invoice=self.invoice) - agg = payments.aggregate(Sum("amount")) - total = agg["amount__sum"] - - if total == self.invoice.value: - self.invoice.paid = True - - if self.invoice.cart: - cart = self.invoice.cart - cart.active = False - cart.save() - - self.invoice.save() + self._mark_void() @transaction.atomic def refund(self, reference, amount): - ''' Refunds the invoice by the given amount. The invoice is - marked as unpaid, and the underlying cart is marked as released. + ''' Refunds the invoice by the given amount. + + The invoice is marked as refunded, and the underlying cart is marked + as released. + + TODO: replace with credit notes work instead. ''' - if self.invoice.void: + if self.invoice.is_void: raise ValidationError("Void invoices cannot be refunded") - ''' Adds a payment ''' - payment = rego.Payment.objects.create( + # Adds a payment + # TODO: replace by creating a credit note instead + rego.ManualPayment.objects.create( invoice=self.invoice, reference=reference, amount=0 - amount, ) - payment.save() - self.invoice.paid = False - self.invoice.void = True - - if self.invoice.cart: - cart = self.invoice.cart - cart.released = True - cart.save() - - self.invoice.save() + self.update_status() diff --git a/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py new file mode 100644 index 00000000..e4d88313 --- /dev/null +++ b/registrasion/migrations/0013_auto_20160406_2228_squashed_0015_auto_20160406_1942.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-07 03:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + replaces = [('registrasion', '0013_auto_20160406_2228'), ('registrasion', '0014_auto_20160406_1847'), ('registrasion', '0015_auto_20160406_1942')] + + dependencies = [ + ('registrasion', '0012_auto_20160406_1212'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentBase', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(default=django.utils.timezone.now)), + ('reference', models.CharField(max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=8)), + ], + ), + migrations.RemoveField( + model_name='payment', + name='invoice', + ), + migrations.RemoveField( + model_name='invoice', + name='paid', + ), + migrations.RemoveField( + model_name='invoice', + name='void', + ), + migrations.AddField( + model_name='invoice', + name='due_time', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='issue_time', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='recipient', + field=models.CharField(default='Lol', max_length=1024), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='status', + field=models.IntegerField(choices=[(1, 'Unpaid'), (2, 'Paid'), (3, 'Refunded'), (4, 'VOID')], db_index=True), + ), + migrations.AddField( + model_name='lineitem', + name='product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='registrasion.Product'), + ), + migrations.CreateModel( + name='ManualPayment', + fields=[ + ('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')), + ], + bases=('registrasion.paymentbase',), + ), + migrations.DeleteModel( + name='Payment', + ), + migrations.AddField( + model_name='paymentbase', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.Invoice'), + ), + migrations.AlterField( + model_name='invoice', + name='cart_revision', + field=models.IntegerField(db_index=True, null=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 941ebef5..fedaa68b 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import datetime import itertools +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.db import models @@ -26,13 +27,10 @@ class Attendee(models.Model): def get_instance(user): ''' Returns the instance of attendee for the given user, or creates a new one. ''' - attendees = Attendee.objects.filter(user=user) - if len(attendees) > 0: - return attendees[0] - else: - attendee = Attendee(user=user) - attendee.save() - return attendee + try: + return Attendee.objects.get(user=user) + except ObjectDoesNotExist: + return Attendee.objects.create(user=user) user = models.OneToOneField(User, on_delete=models.CASCADE) # Badge/profile is linked @@ -54,6 +52,19 @@ class AttendeeProfileBase(models.Model): speaker profile. If it's None, that functionality is disabled. ''' return None + def invoice_recipient(self): + ''' Returns a representation of this attendee profile for the purpose + of rendering to an invoice. Override in subclasses. ''' + + # Manual dispatch to subclass. Fleh. + slf = AttendeeProfileBase.objects.get_subclass(id=self.id) + # Actually compare the functions. + if type(slf).invoice_recipient != type(self).invoice_recipient: + return type(slf).invoice_recipient(slf) + + # Return a default + return slf.attendee.user.username + attendee = models.OneToOneField(Attendee, on_delete=models.CASCADE) @@ -533,6 +544,18 @@ class Invoice(models.Model): ''' An invoice. Invoices can be automatically generated when checking out a Cart, in which case, it is attached to a given revision of a Cart. ''' + STATUS_UNPAID = 1 + STATUS_PAID = 2 + STATUS_REFUNDED = 3 + STATUS_VOID = 4 + + STATUS_TYPES = [ + (STATUS_UNPAID, _("Unpaid")), + (STATUS_PAID, _("Paid")), + (STATUS_REFUNDED, _("Refunded")), + (STATUS_VOID, _("VOID")), + ] + def __str__(self): return "Invoice #%d" % self.id @@ -541,13 +564,37 @@ class Invoice(models.Model): raise ValidationError( "If this is a cart invoice, it must have a revision") + @property + def is_unpaid(self): + return self.status == self.STATUS_UNPAID + + @property + def is_void(self): + return self.status == self.STATUS_VOID + + @property + def is_paid(self): + return self.status == self.STATUS_PAID + + @property + def is_refunded(self): + return self.status == self.STATUS_REFUNDED + # Invoice Number user = models.ForeignKey(User) cart = models.ForeignKey(Cart, null=True) - cart_revision = models.IntegerField(null=True) + cart_revision = models.IntegerField( + null=True, + db_index=True, + ) # Line Items (foreign key) - void = models.BooleanField(default=False) - paid = models.BooleanField(default=False) + status = models.IntegerField( + choices=STATUS_TYPES, + db_index=True, + ) + recipient = models.CharField(max_length=1024) + issue_time = models.DateTimeField() + due_time = models.DateTimeField() value = models.DecimalField(max_digits=8, decimal_places=2) @@ -565,17 +612,25 @@ class LineItem(models.Model): description = models.CharField(max_length=255) quantity = models.PositiveIntegerField() price = models.DecimalField(max_digits=8, decimal_places=2) + product = models.ForeignKey(Product, null=True, blank=True) @python_2_unicode_compatible -class Payment(models.Model): - ''' A payment for an invoice. Each invoice can have multiple payments - attached to it.''' +class PaymentBase(models.Model): + ''' The base payment type for invoices. Payment apps should subclass this + class to handle implementation-specific issues. ''' + + objects = InheritanceManager() def __str__(self): return "Payment: ref=%s amount=%s" % (self.reference, self.amount) invoice = models.ForeignKey(Invoice) time = models.DateTimeField(default=timezone.now) - reference = models.CharField(max_length=64) + reference = models.CharField(max_length=255) amount = models.DecimalField(max_digits=8, decimal_places=2) + + +class ManualPayment(PaymentBase): + ''' Payments that are manually entered by staff. ''' + pass diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 32af58c8..60cf2346 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -3,6 +3,7 @@ from registrasion.controllers.invoice import InvoiceController from registrasion import models as rego from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError class TestingCartController(CartController): @@ -32,4 +33,27 @@ class TestingCartController(CartController): class TestingInvoiceController(InvoiceController): - pass + + def pay(self, reference, amount): + ''' Testing method for simulating an invoice paymenht by the given + amount. ''' + if self.invoice.cart: + cart = CartController(self.invoice.cart) + cart.validate_cart() # Raises ValidationError if invalid + + status = self.invoice.status + if status == rego.Invoice.STATUS_VOID: + raise ValidationError("Void invoices cannot be paid") + elif status == rego.Invoice.STATUS_PAID: + raise ValidationError("Paid invoices cannot be paid again") + elif status == rego.Invoice.STATUS_REFUNDED: + raise ValidationError("Refunded invoices cannot be paid") + + ''' Adds a payment ''' + payment = rego.ManualPayment.objects.create( + invoice=self.invoice, + reference=reference, + amount=amount, + ) + + self.update_status() diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 0a7404d6..645dfb80 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -35,8 +35,8 @@ class InvoiceTestCase(RegistrationCartTestCase): # The old invoice should automatically be voided invoice_1_new = rego.Invoice.objects.get(pk=invoice_1.invoice.id) invoice_2_new = rego.Invoice.objects.get(pk=invoice_2.invoice.id) - self.assertTrue(invoice_1_new.void) - self.assertFalse(invoice_2_new.void) + self.assertTrue(invoice_1_new.is_void) + self.assertFalse(invoice_2_new.is_void) # Invoice should have two line items line_items = rego.LineItem.objects.filter(invoice=invoice_2.invoice) @@ -68,7 +68,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.pay("A payment!", invoice.invoice.value) # This payment is for the correct amount invoice should be paid. - self.assertTrue(invoice.invoice.paid) + self.assertTrue(invoice.invoice.is_paid) # Cart should not be active self.assertFalse(invoice.invoice.cart.active) @@ -133,7 +133,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertTrue(invoice_1.invoice.paid) + self.assertTrue(invoice_1.invoice.is_paid) def test_invoice_voids_self_if_cart_is_invalid(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -142,7 +142,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertFalse(invoice_1.invoice.void) + self.assertFalse(invoice_1.invoice.is_void) # Adding item to cart should produce a new invoice current_cart.add_to_cart(self.PROD_2, 1) @@ -151,11 +151,11 @@ class InvoiceTestCase(RegistrationCartTestCase): # Viewing invoice_1's invoice should show it as void invoice_1_new = TestingInvoiceController(invoice_1.invoice) - self.assertTrue(invoice_1_new.invoice.void) + self.assertTrue(invoice_1_new.invoice.is_void) # Viewing invoice_2's invoice should *not* show it as void invoice_2_new = TestingInvoiceController(invoice_2.invoice) - self.assertFalse(invoice_2_new.invoice.void) + self.assertFalse(invoice_2_new.invoice.is_void) def test_voiding_invoice_creates_new_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -164,7 +164,7 @@ class InvoiceTestCase(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) - self.assertFalse(invoice_1.invoice.void) + self.assertFalse(invoice_1.invoice.is_void) invoice_1.void() invoice_2 = TestingInvoiceController.for_cart(current_cart.cart) diff --git a/registrasion/tests/test_refund.py b/registrasion/tests/test_refund.py index 4510cbda..35457749 100644 --- a/registrasion/tests/test_refund.py +++ b/registrasion/tests/test_refund.py @@ -18,11 +18,13 @@ class RefundTestCase(RegistrationCartTestCase): invoice = TestingInvoiceController.for_cart(current_cart.cart) invoice.pay("A Payment!", invoice.invoice.value) - self.assertFalse(invoice.invoice.void) - self.assertTrue(invoice.invoice.paid) + self.assertFalse(invoice.invoice.is_void) + self.assertTrue(invoice.invoice.is_paid) + self.assertFalse(invoice.invoice.is_refunded) self.assertFalse(invoice.invoice.cart.released) invoice.refund("A Refund!", invoice.invoice.value) - self.assertTrue(invoice.invoice.void) - self.assertFalse(invoice.invoice.paid) + self.assertFalse(invoice.invoice.is_void) + self.assertFalse(invoice.invoice.is_paid) + self.assertTrue(invoice.invoice.is_refunded) self.assertTrue(invoice.invoice.cart.released) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 7fa709dc..be598206 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -141,7 +141,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) inv = TestingInvoiceController.for_cart(current_cart.cart) - if not inv.invoice.paid: + if not inv.invoice.is_paid: inv.pay("Hello!", inv.invoice.value) current_cart = TestingCartController.for_user(self.USER_1) From 0e80e0336c2021c7cb585f29f396dddc31676292 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 16:59:03 +1000 Subject: [PATCH 4/8] adds invoice_recipient to AttendeeProfileBase --- registrasion/controllers/invoice.py | 7 ++++++- registrasion/tests/test_cart.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index a25221f1..f7ae13e5 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -73,6 +73,11 @@ class InvoiceController(object): # Never generate a due time that is before the issue time due = max(issued, reservation_limit) + # Get the invoice recipient + profile = rego.AttendeeProfileBase.objects.get_subclass( + id=cart.user.attendee.attendeeprofilebase.id, + ) + recipient = profile.invoice_recipient() invoice = rego.Invoice.objects.create( user=cart.user, cart=cart, @@ -81,7 +86,7 @@ class InvoiceController(object): value=Decimal(), issue_time=issued, due_time=due, - recipient="BOB_THOMAS", # TODO: add recipient generating code + recipient=recipient, ) product_items = rego.ProductItem.objects.filter(cart=cart) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index 10ba10d2..e87ef37e 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -23,6 +23,9 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): @classmethod def setUpTestData(cls): + + super(RegistrationCartTestCase, cls).setUpTestData() + cls.USER_1 = User.objects.create_user( username='testuser', email='test@example.com', @@ -33,6 +36,15 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') + attendee1 = rego.Attendee.get_instance(cls.USER_1) + attendee1.save() + profile1 = rego.AttendeeProfileBase.objects.create(attendee=attendee1) + profile1.save() + attendee2 = rego.Attendee.get_instance(cls.USER_2) + attendee2.save() + profile2 = rego.AttendeeProfileBase.objects.create(attendee=attendee2) + profile2.save() + cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] From 2fbe789090861dabf5c7c0481995c378fbaf4e8b Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 18:26:31 +1000 Subject: [PATCH 5/8] =?UTF-8?q?Adds=20validate=5Fallowed=5Fto=5Fpay(),=20w?= =?UTF-8?q?hich=20validates=20whether=20you=E2=80=99re=20allowed=20to=20pa?= =?UTF-8?q?y=20for=20an=20invoice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/controllers/invoice.py | 24 ++++++++++++++++++++++++ registrasion/tests/controller_helpers.py | 11 +---------- registrasion/tests/test_cart.py | 4 ++++ registrasion/tests/test_invoice.py | 17 +++++++++++++++-- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index f7ae13e5..c5a527ca 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -126,6 +126,30 @@ class InvoiceController(object): return invoice + def _refresh(self): + ''' Refreshes the underlying invoice and cart objects. ''' + self.invoice.refresh_from_db() + if self.invoice.cart: + self.invoice.cart.refresh_from_db() + + def validate_allowed_to_pay(self): + ''' Passes cleanly if we're allowed to pay, otherwise raise + a ValidationError. ''' + + self._refresh() + + if not self.invoice.is_unpaid: + raise ValidationError("You can only pay for unpaid invoices.") + + if not self.invoice.cart: + return + + if not self._invoice_matches_cart(): + raise ValidationError("The registration has been amended since " + "generating this invoice.") + + CartController(self.invoice.cart).validate_cart() + def total_payments(self): ''' Returns the total amount paid towards this invoice. ''' diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 60cf2346..476351dd 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -37,17 +37,8 @@ class TestingInvoiceController(InvoiceController): def pay(self, reference, amount): ''' Testing method for simulating an invoice paymenht by the given amount. ''' - if self.invoice.cart: - cart = CartController(self.invoice.cart) - cart.validate_cart() # Raises ValidationError if invalid - status = self.invoice.status - if status == rego.Invoice.STATUS_VOID: - raise ValidationError("Void invoices cannot be paid") - elif status == rego.Invoice.STATUS_PAID: - raise ValidationError("Paid invoices cannot be paid again") - elif status == rego.Invoice.STATUS_REFUNDED: - raise ValidationError("Refunded invoices cannot be paid") + self.validate_allowed_to_pay() ''' Adds a payment ''' payment = rego.ManualPayment.objects.create( diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index e87ef37e..f8a82c21 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -148,6 +148,10 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): voucher.save() return voucher + @classmethod + def reget(cls, object): + return type(object).objects.get(id=object.id) + class BasicCartTests(RegistrationCartTestCase): diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 645dfb80..b121bfa1 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -180,7 +180,7 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice_1.void() with self.assertRaises(ValidationError): - invoice_1.pay("Reference", invoice_1.invoice.value) + invoice_1.validate_allowed_to_pay() def test_cannot_void_paid_invoice(self): current_cart = TestingCartController.for_user(self.USER_1) @@ -199,9 +199,22 @@ class InvoiceTestCase(RegistrationCartTestCase): with self.assertRaises(ValidationError): invoice_1 = TestingInvoiceController.for_cart(current_cart.cart) + def test_cannot_pay_implicitly_void_invoice(self): + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + invoice = TestingInvoiceController.for_cart(self.reget(cart.cart)) + + # Implicitly void the invoice + cart.add_to_cart(self.PROD_1, 1) + + with self.assertRaises(ValidationError): + invoice.validate_allowed_to_pay() + + + # TODO: test partially paid invoice cannot be void until payments # are refunded # TODO: test overpaid invoice results in credit note - # TODO: test credit note generation more generally + # TODO: test credit note generation more generally From 94a42c100bbdc18180421d080fac1ef2b2961db4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 7 Apr 2016 19:19:19 +1000 Subject: [PATCH 6/8] Adds manual payment functionality --- registrasion/forms.py | 7 +++++++ registrasion/urls.py | 5 ++++- registrasion/views.py | 34 ++++++++++++++++++++++++++-------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 47dacd3d..68c69d36 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,6 +3,13 @@ import models as rego from django import forms +class ManualPaymentForm(forms.ModelForm): + + class Meta: + model = rego.ManualPayment + fields = ["reference", "amount"] + + # Products forms -- none of these have any fields: they are to be subclassed # and the fields added as needs be. diff --git a/registrasion/urls.py b/registrasion/urls.py index 0620dafb..eb0606df 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -1,3 +1,5 @@ +import views + from django.conf.urls import url, patterns urlpatterns = patterns( @@ -5,7 +7,8 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), - url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), + url(r"^invoice/([0-9]+)/manual_payment$", + views.manual_payment, name="manual_payment"), url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", diff --git a/registrasion/views.py b/registrasion/views.py index b3f9212d..da8687cc 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,7 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -443,15 +444,32 @@ def invoice(request, invoice_id): @login_required -def pay_invoice(request, invoice_id): - ''' Marks the invoice with the given invoice id as paid. - WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. +def manual_payment(request, invoice_id): + ''' Allows staff to make manual payments or refunds on an invoice.''' + + FORM_PREFIX = "manual_payment" + + if not request.user.is_staff: + raise Http404() - ''' invoice_id = int(invoice_id) - inv = rego.Invoice.objects.get(pk=invoice_id) + inv = get_object_or_404(rego.Invoice, pk=invoice_id) current_invoice = InvoiceController(inv) - if not current_invoice.invoice.paid and not current_invoice.invoice.void: - current_invoice.pay("Demo invoice payment", inv.value) - return redirect("invoice", current_invoice.invoice.id) + form = forms.ManualPaymentForm( + request.POST or None, + prefix=FORM_PREFIX, + ) + + if request.POST and form.is_valid(): + form.instance.invoice = inv + form.save() + current_invoice.update_status() + form = forms.ManualPaymentForm(prefix=FORM_PREFIX) + + data = { + "invoice": inv, + "form": form, + } + + return render(request, "registrasion/manual_payment.html", data) From 3dab78ab25b2d55c4482bb92cacf3c37d3ecc7a9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 12:21:39 +1000 Subject: [PATCH 7/8] Adds the access_code field to Attendee model --- .../migrations/0014_attendee_access_code.py | 21 +++++++++++++++++++ .../migrations/0015_auto_20160408_0220.py | 20 ++++++++++++++++++ .../migrations/0016_auto_20160408_0234.py | 20 ++++++++++++++++++ registrasion/models.py | 14 +++++++++++++ registrasion/util.py | 15 +++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 registrasion/migrations/0014_attendee_access_code.py create mode 100644 registrasion/migrations/0015_auto_20160408_0220.py create mode 100644 registrasion/migrations/0016_auto_20160408_0234.py create mode 100644 registrasion/util.py diff --git a/registrasion/migrations/0014_attendee_access_code.py b/registrasion/migrations/0014_attendee_access_code.py new file mode 100644 index 00000000..a579f47a --- /dev/null +++ b/registrasion/migrations/0014_attendee_access_code.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import registrasion.util + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0013_auto_20160406_2228_squashed_0015_auto_20160406_1942'), + ] + + operations = [ + migrations.AddField( + model_name='attendee', + name='access_code', + field=models.CharField(default=registrasion.util.generate_access_code, max_length=6, unique=True), + ), + ] diff --git a/registrasion/migrations/0015_auto_20160408_0220.py b/registrasion/migrations/0015_auto_20160408_0220.py new file mode 100644 index 00000000..acdde45f --- /dev/null +++ b/registrasion/migrations/0015_auto_20160408_0220.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0014_attendee_access_code'), + ] + + operations = [ + migrations.AlterField( + model_name='attendee', + name='access_code', + field=models.CharField(max_length=6, unique=True), + ), + ] diff --git a/registrasion/migrations/0016_auto_20160408_0234.py b/registrasion/migrations/0016_auto_20160408_0234.py new file mode 100644 index 00000000..94beb184 --- /dev/null +++ b/registrasion/migrations/0016_auto_20160408_0234.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-08 02:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0015_auto_20160408_0220'), + ] + + operations = [ + migrations.AlterField( + model_name='attendee', + name='access_code', + field=models.CharField(db_index=True, max_length=6, unique=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index fedaa68b..2719dd0e 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import util + import datetime import itertools @@ -32,8 +34,20 @@ class Attendee(models.Model): except ObjectDoesNotExist: return Attendee.objects.create(user=user) + def save(self, *a, **k): + while not self.access_code: + access_code = util.generate_access_code() + if Attendee.objects.filter(access_code=access_code).count() == 0: + self.access_code = access_code + return super(Attendee, self).save(*a, **k) + user = models.OneToOneField(User, on_delete=models.CASCADE) # Badge/profile is linked + access_code = models.CharField( + max_length=6, + unique=True, + db_index=True, + ) completed_registration = models.BooleanField(default=False) highest_complete_category = models.IntegerField(default=0) diff --git a/registrasion/util.py b/registrasion/util.py new file mode 100644 index 00000000..fb97d1d5 --- /dev/null +++ b/registrasion/util.py @@ -0,0 +1,15 @@ +import string + +from django.utils.crypto import get_random_string + +def generate_access_code(): + ''' Generates an access code for users' payments as well as their + fulfilment code for check-in. + The access code will 4 characters long, which allows for 1,500,625 + unique codes, which really should be enough for anyone. ''' + + length = 4 + # all upper-case letters + digits 1-9 (no 0 vs O confusion) + chars = string.uppercase + string.digits[1:] + # 4 chars => 35 ** 4 = 1500625 (should be enough for anyone) + return get_random_string(length=length, allowed_chars=chars) From ea1d6f52e693827cf861d6994717c7610d8ce812 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 8 Apr 2016 13:15:24 +1000 Subject: [PATCH 8/8] Adds payment access codes. --- registrasion/controllers/invoice.py | 17 ++++++++++++++ registrasion/urls.py | 3 +++ registrasion/views.py | 35 ++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index c5a527ca..de401d31 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -126,6 +126,23 @@ class InvoiceController(object): return invoice + def can_view(self, user=None, access_code=None): + ''' Returns true if the accessing user is allowed to view this invoice, + or if the given access code matches this invoice's user's access code. + ''' + + if user == self.invoice.user: + return True + + if user.is_staff: + return True + + if self.invoice.user.attendee.access_code == access_code: + return True + + return False + + def _refresh(self): ''' Refreshes the underlying invoice and cart objects. ''' self.invoice.refresh_from_db() diff --git a/registrasion/urls.py b/registrasion/urls.py index eb0606df..0949e4b4 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -7,8 +7,11 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), + url(r"^invoice/([0-9]+)/([A-Z0-9]+)$", views.invoice, name="invoice"), url(r"^invoice/([0-9]+)/manual_payment$", views.manual_payment, name="manual_payment"), + url(r"^invoice_access/([A-Z0-9]+)$", views.invoice_access, + name="invoice_access"), url(r"^profile$", "edit_profile", name="attendee_edit"), url(r"^register$", "guided_registration", name="guided_registration"), url(r"^register/([0-9]+)$", "guided_registration", diff --git a/registrasion/views.py b/registrasion/views.py index da8687cc..251f0fba 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -424,18 +424,41 @@ def checkout_errors(request, errors): return render(request, "registrasion/checkout_errors.html", data) -@login_required -def invoice(request, invoice_id): - ''' Displays an invoice for a given invoice id. ''' +def invoice_access(request, access_code): + ''' Redirects to the first unpaid invoice for the attendee that matches + the given access code, if any. ''' + + invoices = rego.Invoice.objects.filter( + user__attendee__access_code=access_code, + status=rego.Invoice.STATUS_UNPAID, + ).order_by("issue_time") + + if not invoices: + raise Http404() + + invoice = invoices[0] + + return redirect("invoice", invoice.id, access_code) + + +def invoice(request, invoice_id, access_code=None): + ''' Displays an invoice for a given invoice id. + This view is not authenticated, but it will only allow access to either: + the user the invoice belongs to; staff; or a request made with the correct + access code. + ''' invoice_id = int(invoice_id) inv = rego.Invoice.objects.get(pk=invoice_id) - if request.user != inv.cart.user and not request.user.is_staff: - raise Http404() - current_invoice = InvoiceController(inv) + if not current_invoice.can_view( + user=request.user, + access_code=access_code, + ): + raise Http404() + data = { "invoice": current_invoice.invoice, }