From 731976b2ab54e9c0df1ebdffdb0ffcf190f3f8ff Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Fri, 16 Feb 2018 08:29:02 +0000 Subject: [PATCH 01/66] Django 2 compat --- cfp/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cfp/models.py b/cfp/models.py index 8724b52..d7251dd 100644 --- a/cfp/models.py +++ b/cfp/models.py @@ -125,8 +125,8 @@ def is_interested_for_form(self, user): class Vote(models.Model): - proposal = models.ForeignKey('Proposal') - user = models.ForeignKey(settings.AUTH_USER_MODEL) + proposal = models.ForeignKey('Proposal', on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) is_interested = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) From dc75667cd78212f6f0bb242ff2b2ac6575a2b5f0 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Tue, 20 Feb 2018 10:44:05 +0000 Subject: [PATCH 02/66] Django 2.0 compatability --- ironcage/views.py | 2 +- tickets/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ironcage/views.py b/ironcage/views.py index 20ba799..5cfa9bd 100644 --- a/ironcage/views.py +++ b/ironcage/views.py @@ -13,7 +13,7 @@ def index(request): user = request.user - if user.is_authenticated(): + if user.is_authenticated: if user.get_ticket() is not None and not user.profile_complete(): messages.warning(request, 'Your profile is incomplete') context = { diff --git a/tickets/views.py b/tickets/views.py index 6f435b3..68bdb3c 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -83,7 +83,7 @@ def new_order(request): 'self_form': self_form, 'others_formset': others_formset, 'company_details_form': company_details_form, - 'user_can_buy_for_self': request.user.is_authenticated() and not request.user.get_ticket(), + 'user_can_buy_for_self': request.user.is_authenticated and not request.user.get_ticket(), 'rates_table_data': _rates_table_data(), 'rates_data': _rates_data(), 'js_paths': ['tickets/order_form.js'], From 98472e6e9c65d72cab7ac57caaaebf0fe75c5265 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Wed, 21 Feb 2018 15:19:15 +0000 Subject: [PATCH 03/66] Initial version of payments models --- payments/__init__.py | 0 payments/apps.py | 5 +++ payments/migrations/__init__.py | 0 payments/models.py | 76 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 payments/__init__.py create mode 100644 payments/apps.py create mode 100644 payments/migrations/__init__.py create mode 100644 payments/models.py diff --git a/payments/__init__.py b/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/apps.py b/payments/apps.py new file mode 100644 index 0000000..58d36ac --- /dev/null +++ b/payments/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + name = 'payments' diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/models.py b/payments/models.py new file mode 100644 index 0000000..2255bfe --- /dev/null +++ b/payments/models.py @@ -0,0 +1,76 @@ +from django.conf import settings +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +from ironcage.utils import Scrambler + + +class Invoice(models.Model): + + id_scrambler = Scrambler(1000) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + invoicee = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='invoices', + on_delete=models.PROTECT) + + invoice_to = models.TextField() + + is_credit = models.BooleanField() + + total = models.DecimalField(max_digits=7, decimal_places=2) + + +class InvoiceRow(models.Model): + + VAT_RATE_CHOICES = ( + (20.0, 'Standard 20%'), + (0.0, 'Zero Rated'), + ) + + invoice = models.ForeignKey(Invoice, related_name='invoice', + on_delete=models.PROTECT) + + object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + invoice_item = GenericForeignKey('object_type', 'object_id') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + total = models.DecimalField(max_digits=7, decimal_places=2) + + vat_rate = models.DecimalField(max_digits=4, decimal_places=2, + choices=VAT_RATE_CHOICES) + + +class Payment(models.Model): + + METHOD_CHOICES = ( + ('S', 'Stripe'), + ('C', 'Cash'), + ) + + STATUS_CHOICES = ( + ('SUC', 'Successful'), + ('FLD', 'Failed'), + ('ERR', 'Errored'), + ('RFD', 'Refunded'), + ('CBK', 'Chargeback'), + ) + + id_scrambler = Scrambler(1000) + + invoice = models.ForeignKey(Invoice, related_name='invoice', + on_delete=models.PROTECT) + + method = models.CharField(max_length=1, choices=METHOD_CHOICES) + status = models.CharField(max_length=3, choices=STATUS_CHOICES) + charge_id = models.CharField(max_length=80) + charge_created = models.DateTimeField(null=True) + charge_failure_reason = models.CharField(max_length=400, blank=True) + + amount = models.DecimalField(max_digits=7, decimal_places=2) From 83d0acb4bd80aaf0679156ec4e26a0da04f0b2ed Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 4 Mar 2018 08:37:58 +0000 Subject: [PATCH 04/66] Change variable names following feedback --- payments/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/payments/models.py b/payments/models.py index 2255bfe..954e083 100644 --- a/payments/models.py +++ b/payments/models.py @@ -13,9 +13,9 @@ class Invoice(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - invoicee = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='invoices', - on_delete=models.PROTECT) + purchaser = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='invoices', + on_delete=models.PROTECT) invoice_to = models.TextField() @@ -41,7 +41,7 @@ class InvoiceRow(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - total = models.DecimalField(max_digits=7, decimal_places=2) + total_ex_vat = models.DecimalField(max_digits=7, decimal_places=2) vat_rate = models.DecimalField(max_digits=4, decimal_places=2, choices=VAT_RATE_CHOICES) From a8f8e6988a381ab5445b99fcc0db3f4d5d9a22f3 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 4 Mar 2018 10:45:11 +0000 Subject: [PATCH 05/66] Change scrambler offsets and choices representations --- payments/models.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/payments/models.py b/payments/models.py index 954e083..e63ca56 100644 --- a/payments/models.py +++ b/payments/models.py @@ -8,7 +8,7 @@ class Invoice(models.Model): - id_scrambler = Scrambler(1000) + id_scrambler = Scrambler(10000) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -49,20 +49,27 @@ class InvoiceRow(models.Model): class Payment(models.Model): + STRIPE = 'S' + METHOD_CHOICES = ( - ('S', 'Stripe'), - ('C', 'Cash'), + (STRIPE, 'Stripe'), ) + SUCCESSFUL = 'SUC' + FAILED = 'FLD' + ERRORED = 'ERR' + REFUNDED = 'RFD' + CHARGEBACK = 'CBK' + STATUS_CHOICES = ( - ('SUC', 'Successful'), - ('FLD', 'Failed'), - ('ERR', 'Errored'), - ('RFD', 'Refunded'), - ('CBK', 'Chargeback'), + (SUCCESSFUL, 'Successful'), + (FAILED, 'Failed'), + (ERRORED, 'Errored'), + (REFUNDED, 'Refunded'), + (CHARGEBACK, 'Chargeback'), ) - id_scrambler = Scrambler(1000) + id_scrambler = Scrambler(20000) invoice = models.ForeignKey(Invoice, related_name='invoice', on_delete=models.PROTECT) From 826b362a6d68728a2a619696fda413c5785215c6 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 15:22:20 +0000 Subject: [PATCH 06/66] Add payments application and migration --- ironcage/settings/base.py | 1 + payments/migrations/0001_initial.py | 56 +++++++++++++++++++++++++++++ payments/models.py | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 payments/migrations/0001_initial.py diff --git a/ironcage/settings/base.py b/ironcage/settings/base.py index 61585e0..e7ed146 100644 --- a/ironcage/settings/base.py +++ b/ironcage/settings/base.py @@ -53,6 +53,7 @@ 'reports', 'tickets', 'ukpa', + 'payments', 'bootstrap3', 'debug_toolbar', diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..4040bb7 --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 2.0 on 2018-03-04 10:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('invoice_to', models.TextField()), + ('is_credit', models.BooleanField()), + ('total', models.DecimalField(decimal_places=2, max_digits=7)), + ('purchaser', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='InvoiceRow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('total_ex_vat', models.DecimalField(decimal_places=2, max_digits=7)), + ('vat_rate', models.DecimalField(choices=[(20.0, 'Standard 20%'), (0.0, 'Zero Rated')], decimal_places=2, max_digits=4)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rows', to='payments.Invoice')), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method', models.CharField(choices=[('S', 'Stripe')], max_length=1)), + ('status', models.CharField(choices=[('SUC', 'Successful'), ('FLD', 'Failed'), ('ERR', 'Errored'), ('RFD', 'Refunded'), ('CBK', 'Chargeback')], max_length=3)), + ('charge_id', models.CharField(max_length=80)), + ('charge_created', models.DateTimeField(null=True)), + ('charge_failure_reason', models.CharField(blank=True, max_length=400)), + ('amount', models.DecimalField(decimal_places=2, max_digits=7)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoice', to='payments.Invoice')), + ], + ), + ] diff --git a/payments/models.py b/payments/models.py index e63ca56..c5cb8b6 100644 --- a/payments/models.py +++ b/payments/models.py @@ -31,7 +31,7 @@ class InvoiceRow(models.Model): (0.0, 'Zero Rated'), ) - invoice = models.ForeignKey(Invoice, related_name='invoice', + invoice = models.ForeignKey(Invoice, related_name='rows', on_delete=models.PROTECT) object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) From 60376cad010d809ff0febc56e88e423a6aaae2f6 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 15:43:47 +0000 Subject: [PATCH 07/66] Split out VAT rates --- payments/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/payments/models.py b/payments/models.py index c5cb8b6..15deef0 100644 --- a/payments/models.py +++ b/payments/models.py @@ -26,9 +26,12 @@ class Invoice(models.Model): class InvoiceRow(models.Model): + STANDARD_RATE = 20.0 + ZERO_RATE = 0.0 + VAT_RATE_CHOICES = ( - (20.0, 'Standard 20%'), - (0.0, 'Zero Rated'), + (STANDARD_RATE, 'Standard 20%'), + (ZERO_RATE, 'Zero Rated'), ) invoice = models.ForeignKey(Invoice, related_name='rows', From bcfdb6db2b988f53589fcf0e13e1ae94a67425e1 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 16:18:41 +0000 Subject: [PATCH 08/66] Add create invoice and credit note --- payments/actions.py | 29 +++++ payments/migrations/0002_default_on_total.py | 18 +++ payments/models.py | 13 +- payments/tests/__init__.py | 0 payments/tests/factories.py | 1 + payments/tests/test_actions.py | 128 +++++++++++++++++++ 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 payments/actions.py create mode 100644 payments/migrations/0002_default_on_total.py create mode 100644 payments/tests/__init__.py create mode 100644 payments/tests/factories.py create mode 100644 payments/tests/test_actions.py diff --git a/payments/actions.py b/payments/actions.py new file mode 100644 index 0000000..f0f89fd --- /dev/null +++ b/payments/actions.py @@ -0,0 +1,29 @@ +from django.db import transaction + +from payments.models import Invoice + +import structlog +logger = structlog.get_logger() + + +def _create_invoice(purchaser, invoice_to, is_credit): + logger.info('_create_invoice', purchaser=purchaser.id, + invoice_to=invoice_to) + with transaction.atomic(): + return Invoice.objects.create( + purchaser=purchaser, + invoice_to=invoice_to, + is_credit=is_credit + ) + + +def create_new_invoice(purchaser, invoice_to): + logger.info('create_new_invoice', purchaser=purchaser.id, + invoice_to=invoice_to) + return _create_invoice(purchaser, invoice_to, is_credit=False) + + +def create_new_credit_note(purchaser, invoice_to): + logger.info('create_new_credit_note', purchaser=purchaser.id, + invoice_to=invoice_to) + return _create_invoice(purchaser, invoice_to, is_credit=True) diff --git a/payments/migrations/0002_default_on_total.py b/payments/migrations/0002_default_on_total.py new file mode 100644 index 0000000..a47a35a --- /dev/null +++ b/payments/migrations/0002_default_on_total.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-03-18 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='total', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=7), + ), + ] diff --git a/payments/models.py b/payments/models.py index 15deef0..5ebf3af 100644 --- a/payments/models.py +++ b/payments/models.py @@ -5,6 +5,9 @@ from ironcage.utils import Scrambler +STANDARD_RATE_VAT = 20.0 +ZERO_RATE_VAT = 0.0 + class Invoice(models.Model): @@ -21,17 +24,15 @@ class Invoice(models.Model): is_credit = models.BooleanField() - total = models.DecimalField(max_digits=7, decimal_places=2) + total = models.DecimalField(max_digits=7, decimal_places=2, default=0.0) -class InvoiceRow(models.Model): - STANDARD_RATE = 20.0 - ZERO_RATE = 0.0 +class InvoiceRow(models.Model): VAT_RATE_CHOICES = ( - (STANDARD_RATE, 'Standard 20%'), - (ZERO_RATE, 'Zero Rated'), + (STANDARD_RATE_VAT, 'Standard 20%'), + (ZERO_RATE_VAT, 'Zero Rated'), ) invoice = models.ForeignKey(Invoice, related_name='rows', diff --git a/payments/tests/__init__.py b/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/tests/factories.py b/payments/tests/factories.py new file mode 100644 index 0000000..464d52e --- /dev/null +++ b/payments/tests/factories.py @@ -0,0 +1 @@ +from accounts.tests.factories import create_user diff --git a/payments/tests/test_actions.py b/payments/tests/test_actions.py new file mode 100644 index 0000000..41b8612 --- /dev/null +++ b/payments/tests/test_actions.py @@ -0,0 +1,128 @@ +from unittest.mock import patch + +from django.test import TestCase + +from payments import actions +from payments.tests import factories + + +class CreateInvoiceTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + + def test_create_invoice(self): + invoice = actions._create_invoice( + self.alice, + 'Alice', + False + ) + + self.assertEqual(self.alice.invoices.count(), 1) + + self.assertEqual(invoice.purchaser, self.alice) + self.assertEqual(invoice.invoice_to, 'Alice') + self.assertEqual(invoice.is_credit, False) + self.assertEqual(invoice.total, 0) + + def test_create_invoice_as_credit(self): + invoice = actions._create_invoice( + self.alice, + 'Alice', + True + ) + + self.assertEqual(self.alice.invoices.count(), 1) + + self.assertEqual(invoice.purchaser, self.alice) + self.assertEqual(invoice.invoice_to, 'Alice') + self.assertEqual(invoice.is_credit, True) + self.assertEqual(invoice.total, 0) + + def test_create_invoice_invoiced_to_company(self): + invoice = actions._create_invoice( + self.alice, + 'My Company Limited', + False + ) + + self.assertEqual(self.alice.invoices.count(), 1) + + self.assertEqual(invoice.purchaser, self.alice) + self.assertEqual(invoice.invoice_to, 'My Company Limited') + self.assertEqual(invoice.is_credit, False) + self.assertEqual(invoice.total, 0) + + def test_create_invoice_change_purchaser_name_does_not_change_invoice_to(self): + invoice = actions._create_invoice( + self.alice, + 'Alice', + False + ) + + self.alice.name = 'Bob' + self.alice.save() + + self.assertEqual(self.alice.invoices.count(), 1) + + self.assertEqual(invoice.purchaser, self.alice) + self.assertEqual(invoice.purchaser.name, 'Bob') + self.assertEqual(invoice.invoice_to, 'Alice') + self.assertEqual(invoice.is_credit, False) + self.assertEqual(invoice.total, 0) + + +class CreateNewInvoiceTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + + @patch('payments.actions._create_invoice') + def test_create_new_invoice(self, _create_invoice): + actions.create_new_invoice( + self.alice, + 'Alice' + ) + + _create_invoice.assert_called_once_with( + self.alice, 'Alice', is_credit=False + ) + + @patch('payments.actions._create_invoice') + def test_create_new_invoice_invoiced_to_company(self, _create_invoice): + actions.create_new_invoice( + self.alice, + 'My Company Limited' + ) + + _create_invoice.assert_called_once_with( + self.alice, 'My Company Limited', is_credit=False + ) + + +class CreateNewCreditNoteTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + + @patch('payments.actions._create_invoice') + def test_create_new_credit_note(self, _create_invoice): + actions.create_new_credit_note( + self.alice, + 'Alice' + ) + + _create_invoice.assert_called_once_with( + self.alice, 'Alice', is_credit=True + ) + + @patch('payments.actions._create_invoice') + def test_create_new_credit_note_invoiced_to_company(self, _create_invoice): + actions.create_new_credit_note( + self.alice, + 'My Company Limited' + ) + + _create_invoice.assert_called_once_with( + self.alice, 'My Company Limited', is_credit=True + ) From 5bb97c43c37c494e6494d8e2624030a2ce3e0fd1 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 16:48:18 +0000 Subject: [PATCH 09/66] Add invoice row --- payments/models.py | 12 ++++++++++++ payments/tests/factories.py | 12 ++++++++++++ payments/tests/test_models.py | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 payments/tests/test_models.py diff --git a/payments/models.py b/payments/models.py index 5ebf3af..d62c608 100644 --- a/payments/models.py +++ b/payments/models.py @@ -27,6 +27,18 @@ class Invoice(models.Model): total = models.DecimalField(max_digits=7, decimal_places=2, default=0.0) + def add_row(self, item, vat_rate=STANDARD_RATE_VAT): + logger.info('add invoice row', invoice=self.id, item=item.id, + item_type=type(item), vat_rate=vat_rate) + + with transaction.atomic(): + InvoiceRow.objects.create( + invoice=self, + invoice_item=item, + total_ex_vat=item.cost_excl_vat(), + vat_rate=vat_rate + ) + class InvoiceRow(models.Model): diff --git a/payments/tests/factories.py b/payments/tests/factories.py index 464d52e..d4b91a3 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -1 +1,13 @@ from accounts.tests.factories import create_user + +from payments import actions + + +def create_invoice(user=None, invoice_to=None): + user = user or create_user() + invoice_to = invoice_to or user.name + + return actions.create_new_invoice( + purchaser=user, + invoice_to=invoice_to + ) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py new file mode 100644 index 0000000..f8f11a4 --- /dev/null +++ b/payments/tests/test_models.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from payments.models import STANDARD_RATE_VAT +from payments.tests import factories +from tickets.tests import factories as ticket_factories + + +class AddInvoiceRowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + cls.ticket = ticket_factories.create_ticket(cls.alice) + cls.invoice = factories.create_invoice(cls.alice) + + def test_add_invoice_row_to_invoice(self): + self.invoice.add_row(self.ticket) + + self.assertEqual(self.invoice.rows.count(), 1) + + invoice_row = self.invoice.rows.all()[0] + + self.assertEqual(invoice_row.invoice_item, self.ticket) + self.assertEqual(invoice_row.total_ex_vat, self.ticket.cost_excl_vat()) + self.assertTrue(invoice_row.total_ex_vat != 0) + self.assertEqual(invoice_row.vat_rate, STANDARD_RATE_VAT) + From 8974f6be13969b0ca607daed36c2ced438ecd60a Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 16:48:49 +0000 Subject: [PATCH 10/66] Alteration to creating invoices --- payments/actions.py | 6 ++++-- payments/tests/test_actions.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/payments/actions.py b/payments/actions.py index f0f89fd..16fb6e3 100644 --- a/payments/actions.py +++ b/payments/actions.py @@ -17,13 +17,15 @@ def _create_invoice(purchaser, invoice_to, is_credit): ) -def create_new_invoice(purchaser, invoice_to): +def create_new_invoice(purchaser, invoice_to=None): logger.info('create_new_invoice', purchaser=purchaser.id, invoice_to=invoice_to) + invoice_to = invoice_to or purchaser.name return _create_invoice(purchaser, invoice_to, is_credit=False) -def create_new_credit_note(purchaser, invoice_to): +def create_new_credit_note(purchaser, invoice_to=None): logger.info('create_new_credit_note', purchaser=purchaser.id, invoice_to=invoice_to) + invoice_to = invoice_to or purchaser.name return _create_invoice(purchaser, invoice_to, is_credit=True) diff --git a/payments/tests/test_actions.py b/payments/tests/test_actions.py index 41b8612..f220326 100644 --- a/payments/tests/test_actions.py +++ b/payments/tests/test_actions.py @@ -80,8 +80,7 @@ def setUpTestData(cls): @patch('payments.actions._create_invoice') def test_create_new_invoice(self, _create_invoice): actions.create_new_invoice( - self.alice, - 'Alice' + self.alice ) _create_invoice.assert_called_once_with( @@ -108,8 +107,7 @@ def setUpTestData(cls): @patch('payments.actions._create_invoice') def test_create_new_credit_note(self, _create_invoice): actions.create_new_credit_note( - self.alice, - 'Alice' + self.alice ) _create_invoice.assert_called_once_with( From 7c092895f863f4dcd33107391c53e073737fc49d Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 17:06:57 +0000 Subject: [PATCH 11/66] Add invoice row tests and VAT improvements --- payments/models.py | 21 +++++++++++++++++---- payments/tests/factories.py | 16 ++++++++++++++++ payments/tests/test_models.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/payments/models.py b/payments/models.py index d62c608..4990328 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,12 +1,19 @@ +from decimal import Decimal + from django.conf import settings from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.db import transaction from ironcage.utils import Scrambler -STANDARD_RATE_VAT = 20.0 -ZERO_RATE_VAT = 0.0 +import structlog +logger = structlog.get_logger() + + +STANDARD_RATE_VAT = Decimal(20.0) +ZERO_RATE_VAT = Decimal(0.0) class Invoice(models.Model): @@ -24,7 +31,8 @@ class Invoice(models.Model): is_credit = models.BooleanField() - total = models.DecimalField(max_digits=7, decimal_places=2, default=0.0) + total = models.DecimalField(max_digits=7, decimal_places=2, + default=Decimal(0.0)) def add_row(self, item, vat_rate=STANDARD_RATE_VAT): @@ -32,7 +40,7 @@ def add_row(self, item, vat_rate=STANDARD_RATE_VAT): item_type=type(item), vat_rate=vat_rate) with transaction.atomic(): - InvoiceRow.objects.create( + return InvoiceRow.objects.create( invoice=self, invoice_item=item, total_ex_vat=item.cost_excl_vat(), @@ -62,6 +70,11 @@ class InvoiceRow(models.Model): vat_rate = models.DecimalField(max_digits=4, decimal_places=2, choices=VAT_RATE_CHOICES) + @property + def total_inc_vat(self): + vat_rate_as_percent = 1 + (self.vat_rate / Decimal(100)) + return self.total_ex_vat * vat_rate_as_percent + class Payment(models.Model): diff --git a/payments/tests/factories.py b/payments/tests/factories.py index d4b91a3..83bb4c9 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -1,6 +1,10 @@ from accounts.tests.factories import create_user from payments import actions +from payments.models import STANDARD_RATE_VAT +from tickets.tests.factories import ( + create_ticket +) def create_invoice(user=None, invoice_to=None): @@ -11,3 +15,15 @@ def create_invoice(user=None, invoice_to=None): purchaser=user, invoice_to=invoice_to ) + + +def add_invoice_row(item=None, user=None, invoice=None, vat_rate=None): + user = user or create_user() + invoice = invoice or create_invoice(user) + item = item or create_ticket(user) + vat_rate = vat_rate if vat_rate is not None else STANDARD_RATE_VAT + + return invoice.add_row( + item=item, + vat_rate=vat_rate + ) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index f8f11a4..f5c657f 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -1,6 +1,7 @@ from django.test import TestCase from payments.models import STANDARD_RATE_VAT +from payments.models import STANDARD_RATE_VAT, ZERO_RATE_VAT from payments.tests import factories from tickets.tests import factories as ticket_factories @@ -13,14 +14,38 @@ def setUpTestData(cls): cls.invoice = factories.create_invoice(cls.alice) def test_add_invoice_row_to_invoice(self): - self.invoice.add_row(self.ticket) + invoice_row = self.invoice.add_row(self.ticket) self.assertEqual(self.invoice.rows.count(), 1) - invoice_row = self.invoice.rows.all()[0] - self.assertEqual(invoice_row.invoice_item, self.ticket) self.assertEqual(invoice_row.total_ex_vat, self.ticket.cost_excl_vat()) self.assertTrue(invoice_row.total_ex_vat != 0) self.assertEqual(invoice_row.vat_rate, STANDARD_RATE_VAT) + +class InvoiceRowIncVatTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + cls.ticket = ticket_factories.create_ticket(cls.alice) + cls.invoice = factories.create_invoice(cls.alice) + + def test_inc_standard_vat(self): + invoice_row = factories.add_invoice_row( + item=self.ticket, + user=self.alice, + invoice=self.invoice + ) + + self.assertEqual(invoice_row.total_inc_vat, self.ticket.cost_incl_vat()) + + def test_inc_zero_vat(self): + invoice_row = factories.add_invoice_row( + item=self.ticket, + user=self.alice, + invoice=self.invoice, + vat_rate=ZERO_RATE_VAT + ) + + self.assertEqual(invoice_row.total_inc_vat, self.ticket.cost_excl_vat()) From 2c8f87391ec055466be43a6e365244b997bf181a Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 17:13:41 +0000 Subject: [PATCH 12/66] Invoice total updates --- payments/models.py | 16 ++++++++++++++-- payments/tests/test_models.py | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/payments/models.py b/payments/models.py index 4990328..ca31953 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,10 +1,11 @@ from decimal import Decimal from django.conf import settings -from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db import transaction +from django.db import models, transaction +from django.db.models.signals import post_save +from django.dispatch import receiver from ironcage.utils import Scrambler @@ -76,6 +77,17 @@ def total_inc_vat(self): return self.total_ex_vat * vat_rate_as_percent +@receiver(post_save, sender=InvoiceRow) +def update_invoice_total(sender, instance, **kwargss): + total = Decimal(0) + + for row in instance.invoice.rows.all(): + total += row.total_inc_vat + + instance.invoice.total = total + instance.invoice.save() + + class Payment(models.Model): STRIPE = 'S' diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index f5c657f..e666fa8 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -1,6 +1,5 @@ from django.test import TestCase -from payments.models import STANDARD_RATE_VAT from payments.models import STANDARD_RATE_VAT, ZERO_RATE_VAT from payments.tests import factories from tickets.tests import factories as ticket_factories @@ -20,9 +19,10 @@ def test_add_invoice_row_to_invoice(self): self.assertEqual(invoice_row.invoice_item, self.ticket) self.assertEqual(invoice_row.total_ex_vat, self.ticket.cost_excl_vat()) - self.assertTrue(invoice_row.total_ex_vat != 0) self.assertEqual(invoice_row.vat_rate, STANDARD_RATE_VAT) + self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) + class InvoiceRowIncVatTests(TestCase): @classmethod From 00739685445c2c5b454440f14fd392379ddf6785 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 17:27:55 +0000 Subject: [PATCH 13/66] Add items only once per invoice --- .../migrations/0003_items_once_per_invoice.py | 18 ++++++++++++++++++ payments/models.py | 3 +++ payments/tests/test_models.py | 8 ++++++++ 3 files changed, 29 insertions(+) create mode 100644 payments/migrations/0003_items_once_per_invoice.py diff --git a/payments/migrations/0003_items_once_per_invoice.py b/payments/migrations/0003_items_once_per_invoice.py new file mode 100644 index 0000000..b15f2ee --- /dev/null +++ b/payments/migrations/0003_items_once_per_invoice.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-03-18 17:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('payments', '0002_default_on_total'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='invoicerow', + unique_together={('invoice', 'object_type', 'object_id')}, + ), + ] diff --git a/payments/models.py b/payments/models.py index ca31953..635bea7 100644 --- a/payments/models.py +++ b/payments/models.py @@ -71,6 +71,9 @@ class InvoiceRow(models.Model): vat_rate = models.DecimalField(max_digits=4, decimal_places=2, choices=VAT_RATE_CHOICES) + class Meta: + unique_together = ("invoice", "object_type", "object_id") + @property def total_inc_vat(self): vat_rate_as_percent = 1 + (self.vat_rate / Decimal(100)) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index e666fa8..e973991 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -1,5 +1,7 @@ from django.test import TestCase +from django.db.utils import IntegrityError + from payments.models import STANDARD_RATE_VAT, ZERO_RATE_VAT from payments.tests import factories from tickets.tests import factories as ticket_factories @@ -24,6 +26,12 @@ def test_add_invoice_row_to_invoice(self): self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) + def test_add_same_item_multiple_times_to_invoice_fails(self): + self.invoice.add_row(self.ticket) + + with self.assertRaises(IntegrityError): + self.invoice.add_row(self.ticket) + class InvoiceRowIncVatTests(TestCase): @classmethod def setUpTestData(cls): From a3caff62eb016167282a4a53975a47fd79f0f725 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 17:28:09 +0000 Subject: [PATCH 14/66] Further add row tests --- payments/tests/test_models.py | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index e973991..0be1557 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -25,6 +25,16 @@ def test_add_invoice_row_to_invoice(self): self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) + def test_add_invoice_row_to_invoice_zero_vat(self): + invoice_row = self.invoice.add_row(self.ticket, ZERO_RATE_VAT) + + self.assertEqual(self.invoice.rows.count(), 1) + + self.assertEqual(invoice_row.invoice_item, self.ticket) + self.assertEqual(invoice_row.total_ex_vat, self.ticket.cost_excl_vat()) + self.assertEqual(invoice_row.vat_rate, ZERO_RATE_VAT) + + self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) def test_add_same_item_multiple_times_to_invoice_fails(self): self.invoice.add_row(self.ticket) @@ -32,6 +42,43 @@ def test_add_same_item_multiple_times_to_invoice_fails(self): with self.assertRaises(IntegrityError): self.invoice.add_row(self.ticket) + def test_add_two_items_to_invoice(self): + # arrange + bob = factories.create_user() + ticket_2 = ticket_factories.create_ticket(bob) + + ticket_1_price = self.ticket.cost_incl_vat() + ticket_2_price = ticket_2.cost_incl_vat() + + total_ticket_cost = ticket_1_price + ticket_2_price + + # act + self.invoice.add_row(self.ticket) + self.invoice.add_row(ticket_2) + + # assert + self.assertEqual(self.invoice.rows.count(), 2) + self.assertEqual(self.invoice.total, total_ticket_cost) + + def test_add_two_items_to_invoice_different_vat_rates(self): + # arrange + bob = factories.create_user() + ticket_2 = ticket_factories.create_ticket(bob) + + ticket_1_price = self.ticket.cost_incl_vat() + ticket_2_price = ticket_2.cost_excl_vat() + + total_ticket_cost = ticket_1_price + ticket_2_price + + # act + self.invoice.add_row(self.ticket) + self.invoice.add_row(ticket_2, ZERO_RATE_VAT) + + # assert + self.assertEqual(self.invoice.rows.count(), 2) + self.assertEqual(self.invoice.total, total_ticket_cost) + + class InvoiceRowIncVatTests(TestCase): @classmethod def setUpTestData(cls): From 787bab754a6d91a0bdf682c73c4073147b6aea32 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 18:10:01 +0000 Subject: [PATCH 15/66] Credit note totals and tests --- payments/models.py | 5 ++++- payments/tests/factories.py | 10 ++++++++++ payments/tests/test_models.py | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/payments/models.py b/payments/models.py index 635bea7..da70384 100644 --- a/payments/models.py +++ b/payments/models.py @@ -85,7 +85,10 @@ def update_invoice_total(sender, instance, **kwargss): total = Decimal(0) for row in instance.invoice.rows.all(): - total += row.total_inc_vat + if instance.invoice.is_credit: + total -= row.total_inc_vat + else: + total += row.total_inc_vat instance.invoice.total = total instance.invoice.save() diff --git a/payments/tests/factories.py b/payments/tests/factories.py index 83bb4c9..28e0663 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -17,6 +17,16 @@ def create_invoice(user=None, invoice_to=None): ) +def create_credit_note(user=None, invoice_to=None): + user = user or create_user() + invoice_to = invoice_to or user.name + + return actions.create_new_credit_note( + purchaser=user, + invoice_to=invoice_to + ) + + def add_invoice_row(item=None, user=None, invoice=None, vat_rate=None): user = user or create_user() invoice = invoice or create_invoice(user) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 0be1557..ddfd968 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -79,6 +79,43 @@ def test_add_two_items_to_invoice_different_vat_rates(self): self.assertEqual(self.invoice.total, total_ticket_cost) +class AddCreditNoteRowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + cls.ticket = ticket_factories.create_ticket(cls.alice) + cls.credit_note = factories.create_credit_note(cls.alice) + + def test_add_invoice_row_to_credit_note(self): + invoice_row = self.credit_note.add_row(self.ticket) + + self.assertEqual(self.credit_note.rows.count(), 1) + + self.assertEqual(invoice_row.invoice_item, self.ticket) + self.assertEqual(invoice_row.total_ex_vat, self.ticket.cost_excl_vat()) + self.assertEqual(invoice_row.vat_rate, STANDARD_RATE_VAT) + + self.assertEqual(self.credit_note.total, -invoice_row.total_inc_vat) + + def test_add_two_items_to_credit_note(self): + # arrange + bob = factories.create_user() + ticket_2 = ticket_factories.create_ticket(bob) + + ticket_1_price = self.ticket.cost_incl_vat() + ticket_2_price = ticket_2.cost_incl_vat() + + total_ticket_cost = ticket_1_price + ticket_2_price + + # act + self.credit_note.add_row(self.ticket) + self.credit_note.add_row(ticket_2) + + # assert + self.assertEqual(self.credit_note.rows.count(), 2) + self.assertEqual(self.credit_note.total, -total_ticket_cost) + + class InvoiceRowIncVatTests(TestCase): @classmethod def setUpTestData(cls): From 20d1ede988b305928f6c5d1f71f7103f5f214dcf Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 18:42:42 +0000 Subject: [PATCH 16/66] =?UTF-8?q?Don=E2=80=99t=20add=20rows=20to=20invoice?= =?UTF-8?q?s=20with=20payments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0004_create_update_fields_on_payment.py | 35 +++++++++++++++++++ payments/models.py | 19 ++++++++-- payments/tests/factories.py | 20 ++++++++++- payments/tests/test_models.py | 15 +++++++- 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 payments/migrations/0004_create_update_fields_on_payment.py diff --git a/payments/migrations/0004_create_update_fields_on_payment.py b/payments/migrations/0004_create_update_fields_on_payment.py new file mode 100644 index 0000000..e97e0cd --- /dev/null +++ b/payments/migrations/0004_create_update_fields_on_payment.py @@ -0,0 +1,35 @@ +# Generated by Django 2.0.2 on 2018-03-18 18:18 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0003_items_once_per_invoice'), + ] + + operations = [ + migrations.RemoveField( + model_name='payment', + name='charge_created', + ), + migrations.AddField( + model_name='payment', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='payment', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name='payment', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.Invoice'), + ), + ] diff --git a/payments/models.py b/payments/models.py index da70384..7c963f1 100644 --- a/payments/models.py +++ b/payments/models.py @@ -17,6 +17,10 @@ ZERO_RATE_VAT = Decimal(0.0) +class InvoiceHasPaymentsException(Exception): + pass + + class Invoice(models.Model): id_scrambler = Scrambler(10000) @@ -40,6 +44,15 @@ def add_row(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, item_type=type(item), vat_rate=vat_rate) + if self.payments.count() != 0: + raise InvoiceHasPaymentsException + + # TODO: Additional logic regarding: + # - Only add to credit note if already on paid invoice + # - Only add to invoice if not already on paid invoice with no + # credit notes + # i.e. ensure each item can be paid for zero or one times + with transaction.atomic(): return InvoiceRow.objects.create( invoice=self, @@ -118,13 +131,15 @@ class Payment(models.Model): id_scrambler = Scrambler(20000) - invoice = models.ForeignKey(Invoice, related_name='invoice', + invoice = models.ForeignKey(Invoice, related_name='payments', on_delete=models.PROTECT) method = models.CharField(max_length=1, choices=METHOD_CHOICES) status = models.CharField(max_length=3, choices=STATUS_CHOICES) charge_id = models.CharField(max_length=80) - charge_created = models.DateTimeField(null=True) charge_failure_reason = models.CharField(max_length=400, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + amount = models.DecimalField(max_digits=7, decimal_places=2) diff --git a/payments/tests/factories.py b/payments/tests/factories.py index 28e0663..e13a3dc 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -1,7 +1,11 @@ +import random +import string + + from accounts.tests.factories import create_user from payments import actions -from payments.models import STANDARD_RATE_VAT +from payments.models import STANDARD_RATE_VAT, Payment from tickets.tests.factories import ( create_ticket ) @@ -37,3 +41,17 @@ def add_invoice_row(item=None, user=None, invoice=None, vat_rate=None): item=item, vat_rate=vat_rate ) + + +def make_payment(invoice=None, status=None, amount=None): + invoice = invoice or create_invoice(create_user()) + status = status or Payment.SUCCESSFUL + amount = amount or invoice.total + + return Payment.objects.create( + invoice=invoice, + method=Payment.STRIPE, + status=status, + charge_id=''.join(random.sample(string.ascii_letters, 10)), + amount=amount + ) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index ddfd968..3a12815 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -2,7 +2,11 @@ from django.db.utils import IntegrityError -from payments.models import STANDARD_RATE_VAT, ZERO_RATE_VAT +from payments.models import ( + InvoiceHasPaymentsException, + STANDARD_RATE_VAT, + ZERO_RATE_VAT, +) from payments.tests import factories from tickets.tests import factories as ticket_factories @@ -78,6 +82,15 @@ def test_add_two_items_to_invoice_different_vat_rates(self): self.assertEqual(self.invoice.rows.count(), 2) self.assertEqual(self.invoice.total, total_ticket_cost) + def test_add_invoice_row_to_invoice_with_payment_fails(self): + self.invoice.add_row(self.ticket) + factories.make_payment(self.invoice) + + ticket_2 = ticket_factories.create_ticket() + + with self.assertRaises(InvoiceHasPaymentsException): + self.invoice.add_row(ticket_2) + class AddCreditNoteRowTests(TestCase): @classmethod From 0af62524264b0feb13d2f87d44ec5d3250c4e945 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 18:42:50 +0000 Subject: [PATCH 17/66] Kill bob --- payments/tests/test_models.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 3a12815..4d62e28 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -48,8 +48,7 @@ def test_add_same_item_multiple_times_to_invoice_fails(self): def test_add_two_items_to_invoice(self): # arrange - bob = factories.create_user() - ticket_2 = ticket_factories.create_ticket(bob) + ticket_2 = ticket_factories.create_ticket() ticket_1_price = self.ticket.cost_incl_vat() ticket_2_price = ticket_2.cost_incl_vat() @@ -66,8 +65,7 @@ def test_add_two_items_to_invoice(self): def test_add_two_items_to_invoice_different_vat_rates(self): # arrange - bob = factories.create_user() - ticket_2 = ticket_factories.create_ticket(bob) + ticket_2 = ticket_factories.create_ticket() ticket_1_price = self.ticket.cost_incl_vat() ticket_2_price = ticket_2.cost_excl_vat() From 0f883c3d35adf8cf92819cc8841c4fd63d5422d1 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 19:46:25 +0000 Subject: [PATCH 18/66] Make ticket_2 a different price to ensure tests are correct --- payments/tests/test_models.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 4d62e28..80db60b 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -48,7 +48,7 @@ def test_add_same_item_multiple_times_to_invoice_fails(self): def test_add_two_items_to_invoice(self): # arrange - ticket_2 = ticket_factories.create_ticket() + ticket_2 = ticket_factories.create_ticket(num_days=5) ticket_1_price = self.ticket.cost_incl_vat() ticket_2_price = ticket_2.cost_incl_vat() @@ -65,7 +65,7 @@ def test_add_two_items_to_invoice(self): def test_add_two_items_to_invoice_different_vat_rates(self): # arrange - ticket_2 = ticket_factories.create_ticket() + ticket_2 = ticket_factories.create_ticket(num_days=5) ticket_1_price = self.ticket.cost_incl_vat() ticket_2_price = ticket_2.cost_excl_vat() @@ -110,8 +110,7 @@ def test_add_invoice_row_to_credit_note(self): def test_add_two_items_to_credit_note(self): # arrange - bob = factories.create_user() - ticket_2 = ticket_factories.create_ticket(bob) + ticket_2 = ticket_factories.create_ticket(num_days=5) ticket_1_price = self.ticket.cost_incl_vat() ticket_2_price = ticket_2.cost_incl_vat() From e03d50ec5413199efee8b153688dde55b6e28de4 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 19:47:17 +0000 Subject: [PATCH 19/66] Deleting of invoice rows --- payments/models.py | 62 ++++++++++++++++++++++++++++------- payments/tests/factories.py | 10 ++++++ payments/tests/test_models.py | 55 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/payments/models.py b/payments/models.py index 7c963f1..52558d7 100644 --- a/payments/models.py +++ b/payments/models.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models, transaction -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from ironcage.utils import Scrambler @@ -21,6 +21,10 @@ class InvoiceHasPaymentsException(Exception): pass +class ItemNotOnInvoiceException(Exception): + pass + + class Invoice(models.Model): id_scrambler = Scrambler(10000) @@ -40,11 +44,24 @@ class Invoice(models.Model): default=Decimal(0.0)) + def _recalculate_total(self): + self.total = Decimal(0) + + for row in self.rows.all(): + if self.is_credit: + self.total -= row.total_inc_vat + else: + self.total += row.total_inc_vat + + self.save() + def add_row(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, item_type=type(item), vat_rate=vat_rate) + # Does the invoice have any payments against it? if self.payments.count() != 0: + logger.error('invoice has payments', invoice=self.id) raise InvoiceHasPaymentsException # TODO: Additional logic regarding: @@ -61,6 +78,35 @@ def add_row(self, item, vat_rate=STANDARD_RATE_VAT): vat_rate=vat_rate ) + def delete_row(self, item): + logger.info('delete invoice row', invoice=self.id, item=item.id, + item_type=type(item)) + + # Is the item on the invoice? + # TODO: Make this less crappy + content_type = ContentType.objects.get_for_model(item) + + rows_with_item = self.rows.filter( + object_type=content_type, + object_id=item.id + ).count() + + if rows_with_item == 0: + logger.error('item not on invoice', invoice=self.id) + raise ItemNotOnInvoiceException + + # Does the invoice have any payments against it? + if self.payments.count() != 0: + logger.error('invoice has payments', invoice=self.id) + raise InvoiceHasPaymentsException + + with transaction.atomic(): + return InvoiceRow.objects.filter( + invoice=self, + object_type=content_type, + object_id=item.id + ).delete() + class InvoiceRow(models.Model): @@ -94,17 +140,9 @@ def total_inc_vat(self): @receiver(post_save, sender=InvoiceRow) -def update_invoice_total(sender, instance, **kwargss): - total = Decimal(0) - - for row in instance.invoice.rows.all(): - if instance.invoice.is_credit: - total -= row.total_inc_vat - else: - total += row.total_inc_vat - - instance.invoice.total = total - instance.invoice.save() +@receiver(post_delete, sender=InvoiceRow) +def update_invoice_total(sender, instance, **kwargs): + instance.invoice._recalculate_total() class Payment(models.Model): diff --git a/payments/tests/factories.py b/payments/tests/factories.py index e13a3dc..b2a4b80 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -43,6 +43,16 @@ def add_invoice_row(item=None, user=None, invoice=None, vat_rate=None): ) +def delete_invoice_row(item=None, user=None, invoice=None): + user = user or create_user() + invoice = invoice or create_invoice(user) + item = item or create_ticket(user) + + return invoice.delete_row( + item=item + ) + + def make_payment(invoice=None, status=None, amount=None): invoice = invoice or create_invoice(create_user()) status = status or Payment.SUCCESSFUL diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 80db60b..682c148 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -4,6 +4,7 @@ from payments.models import ( InvoiceHasPaymentsException, + ItemNotOnInvoiceException, STANDARD_RATE_VAT, ZERO_RATE_VAT, ) @@ -151,3 +152,57 @@ def test_inc_zero_vat(self): ) self.assertEqual(invoice_row.total_inc_vat, self.ticket.cost_excl_vat()) + + +class DeleteInvoiceRowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.alice = factories.create_user() + cls.ticket = ticket_factories.create_ticket(cls.alice) + cls.invoice = factories.create_invoice(cls.alice) + cls.invoice_row = cls.invoice.add_row(cls.ticket) + + def test_delete_invoice_row_from_invoice(self): + self.invoice.delete_row(self.ticket) + + self.invoice.refresh_from_db() + + self.assertEqual(self.invoice.rows.count(), 0) + self.assertEqual(self.invoice.total, 0) + + def test_delete_one_of_two_rows_from_invoice(self): + # arrange + ticket_2 = ticket_factories.create_ticket(num_days=5) + self.invoice.add_row(ticket_2) + + # act + self.invoice.delete_row(self.ticket) + + self.invoice.refresh_from_db() + + # assert + self.assertEqual(self.invoice.rows.count(), 1) + self.assertEqual(self.invoice.total, ticket_2.cost_incl_vat()) + + def test_delete_two_rows_from_two_row_invoice(self): + # arrange + ticket_2 = ticket_factories.create_ticket(num_days=5) + self.invoice.add_row(ticket_2) + + # act + self.invoice.delete_row(self.ticket) + self.invoice.delete_row(ticket_2) + + self.invoice.refresh_from_db() + + # assert + self.assertEqual(self.invoice.rows.count(), 0) + self.assertEqual(self.invoice.total, 0) + + def test_delete_item_not_on_invoice_fails(self): + # arrange + ticket_2 = ticket_factories.create_ticket(num_days=5) + + with self.assertRaises(ItemNotOnInvoiceException): + self.invoice.delete_row(ticket_2) + From f2e0c6c05aa7f964e3fefbc2eff12d5fbe3f758f Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 18 Mar 2018 19:52:49 +0000 Subject: [PATCH 20/66] Rename functions We add and delete items from invoices because each item can only appear on an invoice once. When we add (or delete) the item, we get an invoice row. We are not adding or deleting a row per se. --- payments/models.py | 4 +-- payments/tests/factories.py | 8 +++--- payments/tests/test_models.py | 52 +++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/payments/models.py b/payments/models.py index 52558d7..0232050 100644 --- a/payments/models.py +++ b/payments/models.py @@ -55,7 +55,7 @@ def _recalculate_total(self): self.save() - def add_row(self, item, vat_rate=STANDARD_RATE_VAT): + def add_item(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, item_type=type(item), vat_rate=vat_rate) @@ -78,7 +78,7 @@ def add_row(self, item, vat_rate=STANDARD_RATE_VAT): vat_rate=vat_rate ) - def delete_row(self, item): + def delete_item(self, item): logger.info('delete invoice row', invoice=self.id, item=item.id, item_type=type(item)) diff --git a/payments/tests/factories.py b/payments/tests/factories.py index b2a4b80..e94dceb 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -31,24 +31,24 @@ def create_credit_note(user=None, invoice_to=None): ) -def add_invoice_row(item=None, user=None, invoice=None, vat_rate=None): +def add_invoice_item(item=None, user=None, invoice=None, vat_rate=None): user = user or create_user() invoice = invoice or create_invoice(user) item = item or create_ticket(user) vat_rate = vat_rate if vat_rate is not None else STANDARD_RATE_VAT - return invoice.add_row( + return invoice.add_item( item=item, vat_rate=vat_rate ) -def delete_invoice_row(item=None, user=None, invoice=None): +def delete_invoice_item(item=None, user=None, invoice=None): user = user or create_user() invoice = invoice or create_invoice(user) item = item or create_ticket(user) - return invoice.delete_row( + return invoice.delete_item( item=item ) diff --git a/payments/tests/test_models.py b/payments/tests/test_models.py index 682c148..3b73dd5 100644 --- a/payments/tests/test_models.py +++ b/payments/tests/test_models.py @@ -20,7 +20,7 @@ def setUpTestData(cls): cls.invoice = factories.create_invoice(cls.alice) def test_add_invoice_row_to_invoice(self): - invoice_row = self.invoice.add_row(self.ticket) + invoice_row = self.invoice.add_item(self.ticket) self.assertEqual(self.invoice.rows.count(), 1) @@ -31,7 +31,7 @@ def test_add_invoice_row_to_invoice(self): self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) def test_add_invoice_row_to_invoice_zero_vat(self): - invoice_row = self.invoice.add_row(self.ticket, ZERO_RATE_VAT) + invoice_row = self.invoice.add_item(self.ticket, ZERO_RATE_VAT) self.assertEqual(self.invoice.rows.count(), 1) @@ -42,10 +42,10 @@ def test_add_invoice_row_to_invoice_zero_vat(self): self.assertEqual(self.invoice.total, invoice_row.total_inc_vat) def test_add_same_item_multiple_times_to_invoice_fails(self): - self.invoice.add_row(self.ticket) + self.invoice.add_item(self.ticket) with self.assertRaises(IntegrityError): - self.invoice.add_row(self.ticket) + self.invoice.add_item(self.ticket) def test_add_two_items_to_invoice(self): # arrange @@ -57,8 +57,8 @@ def test_add_two_items_to_invoice(self): total_ticket_cost = ticket_1_price + ticket_2_price # act - self.invoice.add_row(self.ticket) - self.invoice.add_row(ticket_2) + self.invoice.add_item(self.ticket) + self.invoice.add_item(ticket_2) # assert self.assertEqual(self.invoice.rows.count(), 2) @@ -74,21 +74,21 @@ def test_add_two_items_to_invoice_different_vat_rates(self): total_ticket_cost = ticket_1_price + ticket_2_price # act - self.invoice.add_row(self.ticket) - self.invoice.add_row(ticket_2, ZERO_RATE_VAT) + self.invoice.add_item(self.ticket) + self.invoice.add_item(ticket_2, ZERO_RATE_VAT) # assert self.assertEqual(self.invoice.rows.count(), 2) self.assertEqual(self.invoice.total, total_ticket_cost) def test_add_invoice_row_to_invoice_with_payment_fails(self): - self.invoice.add_row(self.ticket) + self.invoice.add_item(self.ticket) factories.make_payment(self.invoice) ticket_2 = ticket_factories.create_ticket() with self.assertRaises(InvoiceHasPaymentsException): - self.invoice.add_row(ticket_2) + self.invoice.add_item(ticket_2) class AddCreditNoteRowTests(TestCase): @@ -99,7 +99,7 @@ def setUpTestData(cls): cls.credit_note = factories.create_credit_note(cls.alice) def test_add_invoice_row_to_credit_note(self): - invoice_row = self.credit_note.add_row(self.ticket) + invoice_row = self.credit_note.add_item(self.ticket) self.assertEqual(self.credit_note.rows.count(), 1) @@ -119,8 +119,8 @@ def test_add_two_items_to_credit_note(self): total_ticket_cost = ticket_1_price + ticket_2_price # act - self.credit_note.add_row(self.ticket) - self.credit_note.add_row(ticket_2) + self.credit_note.add_item(self.ticket) + self.credit_note.add_item(ticket_2) # assert self.assertEqual(self.credit_note.rows.count(), 2) @@ -135,7 +135,7 @@ def setUpTestData(cls): cls.invoice = factories.create_invoice(cls.alice) def test_inc_standard_vat(self): - invoice_row = factories.add_invoice_row( + invoice_row = factories.add_invoice_item( item=self.ticket, user=self.alice, invoice=self.invoice @@ -144,7 +144,7 @@ def test_inc_standard_vat(self): self.assertEqual(invoice_row.total_inc_vat, self.ticket.cost_incl_vat()) def test_inc_zero_vat(self): - invoice_row = factories.add_invoice_row( + invoice_row = factories.add_invoice_item( item=self.ticket, user=self.alice, invoice=self.invoice, @@ -160,10 +160,10 @@ def setUpTestData(cls): cls.alice = factories.create_user() cls.ticket = ticket_factories.create_ticket(cls.alice) cls.invoice = factories.create_invoice(cls.alice) - cls.invoice_row = cls.invoice.add_row(cls.ticket) + cls.invoice_row = cls.invoice.add_item(cls.ticket) def test_delete_invoice_row_from_invoice(self): - self.invoice.delete_row(self.ticket) + self.invoice.delete_item(self.ticket) self.invoice.refresh_from_db() @@ -173,10 +173,10 @@ def test_delete_invoice_row_from_invoice(self): def test_delete_one_of_two_rows_from_invoice(self): # arrange ticket_2 = ticket_factories.create_ticket(num_days=5) - self.invoice.add_row(ticket_2) + self.invoice.add_item(ticket_2) # act - self.invoice.delete_row(self.ticket) + self.invoice.delete_item(self.ticket) self.invoice.refresh_from_db() @@ -187,11 +187,11 @@ def test_delete_one_of_two_rows_from_invoice(self): def test_delete_two_rows_from_two_row_invoice(self): # arrange ticket_2 = ticket_factories.create_ticket(num_days=5) - self.invoice.add_row(ticket_2) + self.invoice.add_item(ticket_2) # act - self.invoice.delete_row(self.ticket) - self.invoice.delete_row(ticket_2) + self.invoice.delete_item(self.ticket) + self.invoice.delete_item(ticket_2) self.invoice.refresh_from_db() @@ -199,10 +199,16 @@ def test_delete_two_rows_from_two_row_invoice(self): self.assertEqual(self.invoice.rows.count(), 0) self.assertEqual(self.invoice.total, 0) + def test_delete_item_from_invoice_with_payment_fails(self): + factories.make_payment(self.invoice) + + with self.assertRaises(InvoiceHasPaymentsException): + self.invoice.delete_item(self.ticket) + def test_delete_item_not_on_invoice_fails(self): # arrange ticket_2 = ticket_factories.create_ticket(num_days=5) with self.assertRaises(ItemNotOnInvoiceException): - self.invoice.delete_row(ticket_2) + self.invoice.delete_item(ticket_2) From a2764a9474027e10800a6033b146e227593e4262 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Tue, 20 Mar 2018 19:46:37 +0000 Subject: [PATCH 21/66] Move stripe integration to payments --- payments/models.py | 9 ++++++ payments/stripe_integration.py | 34 ++++++++++++++++++++ payments/tests/test_stripe_integration.py | 38 +++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 payments/stripe_integration.py create mode 100644 payments/tests/test_stripe_integration.py diff --git a/payments/models.py b/payments/models.py index 0232050..13f4c52 100644 --- a/payments/models.py +++ b/payments/models.py @@ -43,6 +43,11 @@ class Invoice(models.Model): total = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal(0.0)) + @property + def invoice_id(self): + if self.id is None: + return None + return self.id_scrambler.forward(self.id) def _recalculate_total(self): self.total = Decimal(0) @@ -55,6 +60,10 @@ def _recalculate_total(self): self.save() + @property + def total_pence_inc_vat(self): + return 100 * self.total + def add_item(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, item_type=type(item), vat_rate=vat_rate) diff --git a/payments/stripe_integration.py b/payments/stripe_integration.py new file mode 100644 index 0000000..b323fa0 --- /dev/null +++ b/payments/stripe_integration.py @@ -0,0 +1,34 @@ +import stripe + +from django.conf import settings + + +def set_stripe_api_key(): + stripe.api_key = settings.STRIPE_API_KEY_SECRET + + +def create_charge(amount_pence, description, statement_descriptor, token): + assert len(statement_descriptor) <= 22 + set_stripe_api_key() + return stripe.Charge.create( + amount=amount_pence, + currency='gbp', + description=description, + statement_descriptor=statement_descriptor, + source=token, + ) + + +def create_charge_for_invoice(invoice, token): + # assert invoice.payment_required() + return create_charge( + invoice.total_pence_inc_vat, + f'PyCon UK invoice 2018-{invoice.invoice_id}', + f'PyCon UK 2018-{invoice.invoice_id}', + token, + ) + + +def refund_charge(charge_id): + set_stripe_api_key() + stripe.Refund.create(charge=charge_id) diff --git a/payments/tests/test_stripe_integration.py b/payments/tests/test_stripe_integration.py new file mode 100644 index 0000000..61ed729 --- /dev/null +++ b/payments/tests/test_stripe_integration.py @@ -0,0 +1,38 @@ +import stripe + +from django.test import TestCase + +from ironcage.tests import utils + +from payments import stripe_integration +from payments.tests import factories +from tickets.tests import factories as ticket_factories + + +class StripeIntegrationTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.order = ticket_factories.create_pending_order_for_self() + cls.alice = factories.create_user() + cls.ticket = ticket_factories.create_ticket(cls.alice) + cls.invoice = factories.create_invoice(cls.alice) + + def setUp(self): + self.order.refresh_from_db() + + def test_create_charge_for_invoice_with_successful_charge(self): + token = 'tok_ abcdefghijklmnopqurstuvwx' + with utils.patched_charge_creation_success(): + charge = stripe_integration.create_charge_for_invoice(self.invoice, token) + self.assertEqual(charge.id, 'ch_abcdefghijklmnopqurstuvw') + + def test_create_charge_for_invoice_with_unsuccessful_charge(self): + token = 'tok_ abcdefghijklmnopqurstuvwx' + with self.assertRaises(stripe.error.CardError): + with utils.patched_charge_creation_failure(): + stripe_integration.create_charge_for_invoice(self.invoice, token) + + def test_refund_charge(self): + with utils.patched_refund_creation_expected() as mock: + stripe_integration.refund_charge('ch_abcdefghijklmnopqurstuvw') + mock.assert_called_with(charge='ch_abcdefghijklmnopqurstuvw') From 3decea553fabe2f5873b3a5d9f4a86b3bb6e9813 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Wed, 21 Mar 2018 12:25:17 +0000 Subject: [PATCH 22/66] Remove scrambler from invoice --- payments/models.py | 8 -------- payments/stripe_integration.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/payments/models.py b/payments/models.py index 13f4c52..dcab76a 100644 --- a/payments/models.py +++ b/payments/models.py @@ -27,8 +27,6 @@ class ItemNotOnInvoiceException(Exception): class Invoice(models.Model): - id_scrambler = Scrambler(10000) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -43,12 +41,6 @@ class Invoice(models.Model): total = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal(0.0)) - @property - def invoice_id(self): - if self.id is None: - return None - return self.id_scrambler.forward(self.id) - def _recalculate_total(self): self.total = Decimal(0) diff --git a/payments/stripe_integration.py b/payments/stripe_integration.py index b323fa0..ce3062c 100644 --- a/payments/stripe_integration.py +++ b/payments/stripe_integration.py @@ -23,8 +23,8 @@ def create_charge_for_invoice(invoice, token): # assert invoice.payment_required() return create_charge( invoice.total_pence_inc_vat, - f'PyCon UK invoice 2018-{invoice.invoice_id}', - f'PyCon UK 2018-{invoice.invoice_id}', + f'PyCon UK invoice 2018-{invoice.id}', + f'PyCon UK 2018-{invoice.id}', token, ) From 3b98db7a9ae54bb23ec8bf2c385f0442e7d44bf1 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Wed, 21 Mar 2018 21:41:48 +0000 Subject: [PATCH 23/66] Move rate onto ticket --- reports/reports.py | 6 +++--- tickets/migrations/0011_ticket_rate.py | 21 ++++++++++++++++++++ tickets/models.py | 27 +++++++++++++------------- 3 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 tickets/migrations/0011_ticket_rate.py diff --git a/reports/reports.py b/reports/reports.py index 2187f39..9353aa9 100644 --- a/reports/reports.py +++ b/reports/reports.py @@ -81,7 +81,7 @@ def get_context_data(self): for ticket in tickets: if getattr(ticket, day): - num_tickets[ticket.rate()] += 1 + num_tickets[ticket.rate] += 1 rows.append([ DAYS[day], @@ -154,7 +154,7 @@ def get_context_data(self): for ticket in tickets: if sum(getattr(ticket, day) for day in DAYS) == num_days: - num_tickets[ticket.rate()] += 1 + num_tickets[ticket.rate] += 1 num_tickets_rows.append([ num_days, @@ -225,7 +225,7 @@ def presenter(self, ticket): } return [ link, - ticket.rate(), + ticket.rate, ticket.ticket_holder_name(), ', '.join(ticket.days()), f'£{ticket.cost_incl_vat()}', diff --git a/tickets/migrations/0011_ticket_rate.py b/tickets/migrations/0011_ticket_rate.py new file mode 100644 index 0000000..c62c73e --- /dev/null +++ b/tickets/migrations/0011_ticket_rate.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2018-03-21 12:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0010_auto_20170903_1701'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='rate', + field=models.CharField(default=None, max_length=40), + preserve_default=False, + ), + ] diff --git a/tickets/models.py b/tickets/models.py index 088ae94..e9a6ad2 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -104,12 +104,12 @@ def confirm(self, charge_id, charge_created): days_for_self = self.unconfirmed_details['days_for_self'] if days_for_self is not None: - self.tickets.create_for_user(self.purchaser, days_for_self) + self.tickets.create_for_user(self.purchaser, self.rate, days_for_self) email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] if email_addrs_and_days_for_others is not None: for email_addr, days in email_addrs_and_days_for_others: - self.tickets.create_with_invitation(email_addr, days) + self.tickets.create_with_invitation(email_addr, self.rate, days) self.stripe_charge_id = charge_id self.stripe_charge_created = datetime.fromtimestamp(charge_created, tz=timezone.utc) @@ -297,6 +297,7 @@ class Ticket(models.Model): order = models.ForeignKey(Order, related_name='tickets', null=True, on_delete=models.CASCADE) pot = models.CharField(max_length=100, null=True) owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) + rate = models.CharField(max_length=40) thu = models.BooleanField() fri = models.BooleanField() sat = models.BooleanField() @@ -313,13 +314,13 @@ def get_by_ticket_id_or_404(self, ticket_id): id = self.model.id_scrambler.backward(ticket_id) return get_object_or_404(self.model, pk=id) - def create_for_user(self, user, days): + def create_for_user(self, user, rate, days): day_fields = {day: (day in days) for day in DAYS} - return self.create(owner=user, **day_fields) + return self.create(owner=user, rate=rate, **day_fields) - def create_with_invitation(self, email_addr, days): + def create_with_invitation(self, email_addr, rate, days): day_fields = {day: (day in days) for day in DAYS} - ticket = self.create(**day_fields) + ticket = self.create(rate=rate, **day_fields) ticket.invitations.create(email_addr=email_addr) return ticket @@ -380,17 +381,17 @@ def ticket_holder_name(self): else: return self.invitation().email_addr - def rate(self): - if self.order is None: - return 'free' - else: - return self.order.rate + # def rate(self): + # if self.order is None: + # return 'free' + # else: + # return self.order.rate def cost_incl_vat(self): - return cost_incl_vat(self.rate(), self.num_days()) + return cost_incl_vat(self.rate, self.num_days()) def cost_excl_vat(self): - return cost_excl_vat(self.rate(), self.num_days()) + return cost_excl_vat(self.rate, self.num_days()) def invitation(self): # This will raise an exception if a ticket has multiple invitations From 9c6c2b7512f70037d0f9b8e6c2d506332b0f46a9 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 24 Mar 2018 14:29:06 +0000 Subject: [PATCH 24/66] URLs, new views for invoice info, create invoice when creating ticket --- ironcage/urls.py | 1 + payments/urls.py | 11 ++++++++ payments/views.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++ tickets/urls.py | 2 +- tickets/views.py | 32 +++++++++++++++++------ 5 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 payments/urls.py create mode 100644 payments/views.py diff --git a/ironcage/urls.py b/ironcage/urls.py index 92f67be..d2954e2 100644 --- a/ironcage/urls.py +++ b/ironcage/urls.py @@ -28,6 +28,7 @@ url(r'^500/$', ironcage.views.error, name='error'), url(r'^log/$', ironcage.views.log, name='log'), url(r'^avatar/', include('avatar.urls')), + url(r'^payments/', include('payments.urls')), ] if settings.DEBUG: diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 0000000..0a072b6 --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url + +from . import views +from payments import views as payment_views + +app_name = 'payments' + +urlpatterns = [ + url(r'^orders/(?P\w+)/$', payment_views.order, name='order'), + url(r'^invoice/(?P\w+)/$', views.invoice, name='invoice'), +] diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..01fc0a5 --- /dev/null +++ b/payments/views.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.http import require_POST + +from payments import actions as payment_actions +from payments.models import Invoice +from tickets import actions +from tickets.forms import CompanyDetailsForm, TicketForm, TicketForSelfForm, TicketForOthersFormSet +from tickets.models import Ticket, TicketInvitation +from tickets.prices import PRICES_INCL_VAT, cost_incl_vat + + +@login_required +def order(request, invoice_id): + invoice = get_object_or_404(Invoice, pk=invoice_id) + + if request.user != invoice.purchaser: + messages.warning(request, 'Only the purchaser of an invoice can view the invoice') + return redirect('index') + + # if invoice.payment_required(): + # if request.user.get_ticket() is not None and invoice.unconfirmed_details['days_for_self']: + # messages.warning(request, 'You already have a ticket. Please amend your invoice.') + # return redirect('tickets:order_edit', invoice.invoice_id) + + # if invoice.status == 'failed': + # messages.error(request, f'Payment for this invoice failed ({invoice.stripe_charge_failure_reason})') + # elif invoice.status == 'errored': + # messages.error(request, 'There was an error creating your invoice. You card may have been charged, but if so the charge will have been refunded. Please make a new invoice.') + + # ticket = request.user.get_ticket() + # if ticket is not None and ticket.invoice != invoice: + # ticket = None + + context = { + 'invoice': invoice, + # 'ticket': ticket, + 'stripe_api_key': settings.STRIPE_API_KEY_PUBLISHABLE, + } + return render(request, 'payments/order.html', context) + + +@login_required +def invoice(request, invoice_id): + pass + # order = get_object_or_404(Invoice, invoice_id) + + # # if request.user != order.purchaser: + # # messages.warning(request, 'Only the purchaser of an order can view the receipt') + # # return redirect('index') + + # # if order.payment_required(): + # # messages.error(request, 'This order has not been paid') + # # return redirect(order) + + # context = { + # 'order': order, + # 'title': f'PyCon UK 2018 invoice {order.order_id}', + # 'no_navbar': True, + # } + # return render(request, 'tickets/order_receipt.html', context) diff --git a/tickets/urls.py b/tickets/urls.py index c2a8e1e..6e4b85a 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -1,12 +1,12 @@ from django.conf.urls import url from . import views +from payments import views as payment_views app_name = 'tickets' urlpatterns = [ url(r'^orders/new/$', views.new_order, name='new_order'), - url(r'^orders/(?P\w+)/$', views.order, name='order'), url(r'^orders/(?P\w+)/edit/$', views.order_edit, name='order_edit'), url(r'^orders/(?P\w+)/payment/$', views.order_payment, name='order_payment'), url(r'^orders/(?P\w+)/receipt/$', views.order_receipt, name='order_receipt'), diff --git a/tickets/views.py b/tickets/views.py index 68bdb3c..eb1d5d4 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.views.decorators.http import require_POST +from payments import actions as payment_actions from . import actions from .forms import CompanyDetailsForm, TicketForm, TicketForSelfForm, TicketForOthersFormSet from .models import Order, Ticket, TicketInvitation @@ -57,15 +58,30 @@ def new_order(request): company_details = None if valid: - order = actions.create_pending_order( - purchaser=request.user, - rate=rate, - days_for_self=days_for_self, - email_addrs_and_days_for_others=email_addrs_and_days_for_others, - company_details=company_details, - ) + invoice_to = company_details.get('name') if company_details else None - return redirect(order) + invoice = payment_actions.create_new_invoice(request.user, invoice_to) + + if days_for_self: + ticket = Ticket.objects.create_for_user(request.user, rate, days_for_self) + invoice.add_item(ticket) + + if email_addrs_and_days_for_others is not None: + for email_addr, days in email_addrs_and_days_for_others: + ticket = Ticket.objects.create_with_invitation(email_addr, rate, days) + invoice.add_item(ticket) + + # self.save() + + # order = actions.create_pending_order( + # purchaser=request.user, + # rate=rate, + # days_for_self=days_for_self, + # email_addrs_and_days_for_others=email_addrs_and_days_for_others, + # company_details=company_details, + # ) + + return redirect(invoice) else: if datetime.now(timezone.utc) > settings.TICKET_SALES_CLOSE_AT: From 10231e8db7c6985c5a16934ea2a07addf3810bb8 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 25 Mar 2018 14:08:43 +0100 Subject: [PATCH 25/66] Working but untested full flow --- payments/actions.py | 50 +++++- payments/models.py | 49 +++++- payments/stripe_integration.py | 6 +- .../templates/payments/_invoice_details.html | 64 ++++++++ payments/templates/payments/order.html | 45 ++++++ .../templates/payments/payment_receipt.html | 64 ++++++++ payments/urls.py | 6 +- payments/views.py | 145 +++++++++++++++--- tickets/actions.py | 54 +++---- tickets/models.py | 10 +- tickets/templates/tickets/order.html | 32 ++-- tickets/templates/tickets/order_receipt.html | 81 ---------- tickets/urls.py | 3 +- tickets/views.py | 138 ----------------- 14 files changed, 456 insertions(+), 291 deletions(-) create mode 100644 payments/templates/payments/_invoice_details.html create mode 100644 payments/templates/payments/order.html create mode 100644 payments/templates/payments/payment_receipt.html delete mode 100644 tickets/templates/tickets/order_receipt.html diff --git a/payments/actions.py b/payments/actions.py index 16fb6e3..7ce0293 100644 --- a/payments/actions.py +++ b/payments/actions.py @@ -1,6 +1,12 @@ +import stripe + from django.db import transaction -from payments.models import Invoice +from payments.models import Invoice, Payment + +from django.db.utils import IntegrityError + +from payments.stripe_integration import create_charge_for_invoice import structlog logger = structlog.get_logger() @@ -29,3 +35,45 @@ def create_new_credit_note(purchaser, invoice_to=None): invoice_to=invoice_to) invoice_to = invoice_to or purchaser.name return _create_invoice(purchaser, invoice_to, is_credit=True) + + +def confirm_order(order, charge_id, charge_created): + logger.info('confirm_order', order=order.order_id, charge_id=charge_id) + with transaction.atomic(): + order.confirm(charge_id, charge_created) + send_receipt(order) + send_ticket_invitations(order) + slack_message('tickets/order_created.slack', {'order': order}) + + +def process_stripe_charge(invoice, token): + logger.info('process_stripe_charge', invoice=invoice.invoice_id, token=token) + assert invoice.payment_required + try: + charge = create_charge_for_invoice(invoice, token) + Payment.objects.create( + invoice=invoice, + method=Payment.STRIPE, + status=Payment.SUCCESSFUL, + charge_id=charge.id, + amount=charge.amount/100 + ) + + + confirm_order(invoice, charge.id, charge.created) + except stripe.error.CardError as e: + mark_order_as_failed(invoice, e._message) + except IntegrityError: + refund_charge(charge.id) + mark_order_as_errored_after_charge(invoice, charge.id) + + +def send_receipt(order): + logger.info('send_receipt', order=order.order_id) + send_order_confirmation_mail(order) + + +def send_ticket_invitations(order): + logger.info('send_ticket_invitations', order=order.order_id) + for ticket in order.unclaimed_tickets(): + send_invitation_mail(ticket) diff --git a/payments/models.py b/payments/models.py index dcab76a..b3b9773 100644 --- a/payments/models.py +++ b/payments/models.py @@ -6,6 +6,7 @@ from django.db import models, transaction from django.db.models.signals import post_save, post_delete from django.dispatch import receiver +from django.urls import reverse from ironcage.utils import Scrambler @@ -41,6 +42,10 @@ class Invoice(models.Model): total = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal(0.0)) + @property + def invoice_id(self): + return 'IC-2018-{}'.format(self.id) + def _recalculate_total(self): self.total = Decimal(0) @@ -52,9 +57,39 @@ def _recalculate_total(self): self.save() + @property + def total_ex_vat(self): + total_ex_vat = Decimal(0) + + for row in self.rows.all(): + if self.is_credit: + total_ex_vat -= row.total_ex_vat + else: + total_ex_vat += row.total_ex_vat + + return total_ex_vat + + @property + def total_vat(self): + total_vat = Decimal(0) + + for row in self.rows.all(): + if self.is_credit: + total_vat -= row.total_vat + else: + total_vat += row.total_vat + + return total_vat + + def get_absolute_url(self): + return reverse('tickets:order', args=[self.id]) + + def get_payment(self): + import ipdb; ipdb.set_trace() + @property def total_pence_inc_vat(self): - return 100 * self.total + return int(100 * self.total) def add_item(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, @@ -108,6 +143,11 @@ def delete_item(self, item): object_id=item.id ).delete() + @property + def payment_required(self): + return self.payments.count() == 0 + + class InvoiceRow(models.Model): @@ -139,6 +179,11 @@ def total_inc_vat(self): vat_rate_as_percent = 1 + (self.vat_rate / Decimal(100)) return self.total_ex_vat * vat_rate_as_percent + @property + def total_vat(self): + vat_rate_as_percent = self.vat_rate / Decimal(100) + return self.total_ex_vat * vat_rate_as_percent + @receiver(post_save, sender=InvoiceRow) @receiver(post_delete, sender=InvoiceRow) @@ -168,8 +213,6 @@ class Payment(models.Model): (CHARGEBACK, 'Chargeback'), ) - id_scrambler = Scrambler(20000) - invoice = models.ForeignKey(Invoice, related_name='payments', on_delete=models.PROTECT) diff --git a/payments/stripe_integration.py b/payments/stripe_integration.py index ce3062c..c2c8f8c 100644 --- a/payments/stripe_integration.py +++ b/payments/stripe_integration.py @@ -20,11 +20,11 @@ def create_charge(amount_pence, description, statement_descriptor, token): def create_charge_for_invoice(invoice, token): - # assert invoice.payment_required() + assert invoice.payment_required return create_charge( invoice.total_pence_inc_vat, - f'PyCon UK invoice 2018-{invoice.id}', - f'PyCon UK 2018-{invoice.id}', + f'PyCon UK invoice {invoice.invoice_id}', + f'PyCon UK {invoice.invoice_id}', token, ) diff --git a/payments/templates/payments/_invoice_details.html b/payments/templates/payments/_invoice_details.html new file mode 100644 index 0000000..b894b5b --- /dev/null +++ b/payments/templates/payments/_invoice_details.html @@ -0,0 +1,64 @@ +
+
+ + + + + + + + + + + + + + {% if invoice.rate == 'corporate' %} + + + + + + + + + {% endif %} + + + + + + + + + + + + +
Invoice Number{{ invoice.invoice_id }}
Date{{ invoice.created_at|date }}
Status{% if invoice.payment_required %}Unpaid{% else %}Paid{% endif %}
Company{{ invoice.company_name }}
Address{{ invoice.company_addr_formatted }}
Total (excl. VAT)£{{ invoice.total_ex_vat|floatformat:2 }}
VAT£{{ invoice.total_vat|floatformat:2 }}
Total (incl. VAT)£{{ invoice.total|floatformat:2 }}
+
+
+ +
+
+

Invoice details

+ + + + + + + + + {% for row in invoice.rows.all %} + + + + + + + {% endfor %} +
Item IDItemVAT RateTotal
{{ row.id }}{{ row.invoice_item.invoice_description }}{{ row.vat_rate|floatformat:0 }}%£{{ row.total_inc_vat|floatformat:2 }}
+
+
+ diff --git a/payments/templates/payments/order.html b/payments/templates/payments/order.html new file mode 100644 index 0000000..e2f9337 --- /dev/null +++ b/payments/templates/payments/order.html @@ -0,0 +1,45 @@ +{% extends 'ironcage/base.html' %} + +{% load static %} + +{% block content %} +

Details of your order

+ +{% include './_invoice_details.html' %} + +{% if invoice.payment_required %} +
+
+ {% csrf_token %} + + + + + + Make changes to your invoice +
+ +
+{% elif not invoice.status == 'errored' %} +
+
+ View receipt + {% if ticket %} + View your ticket + {% endif %} +
+
+{% endif %} + +{% endblock %} diff --git a/payments/templates/payments/payment_receipt.html b/payments/templates/payments/payment_receipt.html new file mode 100644 index 0000000..3a6ec02 --- /dev/null +++ b/payments/templates/payments/payment_receipt.html @@ -0,0 +1,64 @@ +{% extends 'ironcage/base.html' %} + +{% block content %} +

Receipt for PyCon UK 2018

+ +

Issued by the PyCon UK Society Ltd, c/o Acconomy, Arena Business Centre, Holyrood Close, Poole, BH17 7FJ, United Kingdom (VAT Number 249244982)

+ +{% if payment.rate == 'corporate' %} +

Issued to {{ payment.company_name }}, {{ payment.company_addr_formatted }}

+{% else %} +

Issued to {{ invoice.purchaser.name }}, {{ invoice.purchaser.email_addr }}

+{% endif %} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date{{ payment.created_at|date }}
Invoice number{{ invoice.invoice_id }}
Payment number{{ payment.id }}
Total received£{{ payment.amount }}
Paid by{{ payment.get_method_display }}
Payment status{{ payment.get_status_display }}
+
+
+ +

Invoice {{ invoice.invoice_id }} Detail

+ +{% include './_invoice_details.html' %} +{% endblock %} diff --git a/payments/urls.py b/payments/urls.py index 0a072b6..2aa9d06 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -1,11 +1,11 @@ from django.conf.urls import url from . import views -from payments import views as payment_views app_name = 'payments' urlpatterns = [ - url(r'^orders/(?P\w+)/$', payment_views.order, name='order'), - url(r'^invoice/(?P\w+)/$', views.invoice, name='invoice'), + url(r'^orders/(?P\w+)/$', views.order, name='order'), + url(r'^payment/(?P\w+)/$', views.payment, name='payment'), + url(r'^orders/(?P\w+)/payment/$', views.invoice_payment, name='invoice_payment'), ] diff --git a/payments/views.py b/payments/views.py index 01fc0a5..b6981a1 100644 --- a/payments/views.py +++ b/payments/views.py @@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST from payments import actions as payment_actions -from payments.models import Invoice +from payments.models import Invoice, Payment from tickets import actions from tickets.forms import CompanyDetailsForm, TicketForm, TicketForSelfForm, TicketForOthersFormSet from tickets.models import Ticket, TicketInvitation @@ -46,21 +46,128 @@ def order(request, invoice_id): @login_required -def invoice(request, invoice_id): - pass - # order = get_object_or_404(Invoice, invoice_id) - - # # if request.user != order.purchaser: - # # messages.warning(request, 'Only the purchaser of an order can view the receipt') - # # return redirect('index') - - # # if order.payment_required(): - # # messages.error(request, 'This order has not been paid') - # # return redirect(order) - - # context = { - # 'order': order, - # 'title': f'PyCon UK 2018 invoice {order.order_id}', - # 'no_navbar': True, - # } - # return render(request, 'tickets/order_receipt.html', context) +def order_edit(request, order_id): + order = get_object_or_404(Invoice, pk=order_id) + + if request.user != order.purchaser: + messages.warning(request, 'Only the purchaser of an order can update the order') + return redirect('index') + + if not order.payment_required: + messages.error(request, 'This order has already been paid') + return redirect(order) + + if request.method == 'POST': + form = TicketForm(request.POST) + self_form = TicketForSelfForm(request.POST) + others_formset = TicketForOthersFormSet(request.POST) + company_details_form = CompanyDetailsForm(request.POST) + + if form.is_valid(): + who = form.cleaned_data['who'] + rate = form.cleaned_data['rate'] + + if who == 'self': + valid = self_form.is_valid() + if valid: + days_for_self = self_form.cleaned_data['days'] + email_addrs_and_days_for_others = None + elif who == 'others': + valid = others_formset.is_valid() + if valid: + days_for_self = None + email_addrs_and_days_for_others = others_formset.email_addrs_and_days + elif who == 'self and others': + valid = self_form.is_valid() and others_formset.is_valid() + if valid: + days_for_self = self_form.cleaned_data['days'] + email_addrs_and_days_for_others = others_formset.email_addrs_and_days + else: + assert False + + if valid: + if rate == 'corporate': + valid = company_details_form.is_valid() + if valid: + company_details = { + 'name': company_details_form.cleaned_data['company_name'], + 'addr': company_details_form.cleaned_data['company_addr'], + } + else: + company_details = None + + if valid: + actions.update_pending_order( + order, + rate=rate, + days_for_self=days_for_self, + email_addrs_and_days_for_others=email_addrs_and_days_for_others, + company_details=company_details, + ) + + return redirect(order) + + else: + form = TicketForm(order.form_data()) + self_form = TicketForSelfForm(order.self_form_data()) + others_formset = TicketForOthersFormSet(order.others_formset_data()) + company_details_form = CompanyDetailsForm(order.company_details_form_data()) + + context = { + 'form': form, + 'self_form': self_form, + 'others_formset': others_formset, + 'company_details_form': company_details_form, + 'user_can_buy_for_self': not request.user.get_ticket(), + 'rates_table_data': _rates_table_data(), + 'rates_data': _rates_data(), + 'js_paths': ['tickets/order_form.js'], + } + + return render(request, 'tickets/order_edit.html', context) + + +@login_required +@require_POST +def invoice_payment(request, order_id): + order = get_object_or_404(Invoice, pk=order_id) + + if request.user != order.purchaser: + messages.warning(request, 'Only the purchaser of an order can pay for the order') + return redirect('index') + + if not order.payment_required: + messages.error(request, 'This order has already been paid') + return redirect(order) + + # if request.user.get_ticket() is not None and order.unconfirmed_details['days_for_self']: + # messages.warning(request, 'You already have a ticket. Please amend your order. Your card has not been charged.') + # return redirect('tickets:order_edit', order.order_id) + + token = request.POST['stripeToken'] + payment_actions.process_stripe_charge(order, token) + + if not order.payment_required: + messages.success(request, 'Payment for this order has been received.') + + return redirect(order) + + +@login_required +def payment(request, payment_id): + payment = get_object_or_404(Payment, pk=payment_id) + + if request.user != payment.invoice.purchaser: + messages.warning(request, 'Only the purchaser of an order can view the receipt') + return redirect('index') + + context = { + 'payment': payment, + 'invoice': payment.invoice, + 'title': f'PyCon UK 2018 Receipt {payment.id}', + 'no_navbar': True, + } + return render(request, 'payments/payment_receipt.html', context) + + + diff --git a/tickets/actions.py b/tickets/actions.py index 29463be..7823e2e 100644 --- a/tickets/actions.py +++ b/tickets/actions.py @@ -40,26 +40,26 @@ def update_pending_order(order, rate, days_for_self=None, email_addrs_and_days_f order.update(rate, days_for_self, email_addrs_and_days_for_others, company_details) -def process_stripe_charge(order, token): - logger.info('process_stripe_charge', order=order.order_id, token=token) - assert order.payment_required() - try: - charge = create_charge_for_order(order, token) - confirm_order(order, charge.id, charge.created) - except stripe.error.CardError as e: - mark_order_as_failed(order, e._message) - except IntegrityError: - refund_charge(charge.id) - mark_order_as_errored_after_charge(order, charge.id) - - -def confirm_order(order, charge_id, charge_created): - logger.info('confirm_order', order=order.order_id, charge_id=charge_id) - with transaction.atomic(): - order.confirm(charge_id, charge_created) - send_receipt(order) - send_ticket_invitations(order) - slack_message('tickets/order_created.slack', {'order': order}) +# def process_stripe_charge(order, token): +# logger.info('process_stripe_charge', order=order.order_id, token=token) +# assert order.payment_required() +# try: +# charge = create_charge_for_order(order, token) +# confirm_order(order, charge.id, charge.created) +# except stripe.error.CardError as e: +# mark_order_as_failed(order, e._message) +# except IntegrityError: +# refund_charge(charge.id) +# mark_order_as_errored_after_charge(order, charge.id) + + +# def confirm_order(order, charge_id, charge_created): +# logger.info('confirm_order', order=order.order_id, charge_id=charge_id) +# with transaction.atomic(): +# order.confirm(charge_id, charge_created) +# send_receipt(order) +# send_ticket_invitations(order) +# slack_message('tickets/order_created.slack', {'order': order}) def mark_order_as_failed(order, charge_failure_reason): @@ -91,15 +91,15 @@ def update_free_ticket(ticket, days): ticket.update_days(days) -def send_receipt(order): - logger.info('send_receipt', order=order.order_id) - send_order_confirmation_mail(order) +# def send_receipt(order): +# logger.info('send_receipt', order=order.order_id) +# send_order_confirmation_mail(order) -def send_ticket_invitations(order): - logger.info('send_ticket_invitations', order=order.order_id) - for ticket in order.unclaimed_tickets(): - send_invitation_mail(ticket) +# def send_ticket_invitations(order): +# logger.info('send_ticket_invitations', order=order.order_id) +# for ticket in order.unclaimed_tickets(): +# send_invitation_mail(ticket) def claim_ticket_invitation(owner, invitation): diff --git a/tickets/models.py b/tickets/models.py index e9a6ad2..c497375 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -294,7 +294,7 @@ def company_addr_formatted(self): class Ticket(models.Model): - order = models.ForeignKey(Order, related_name='tickets', null=True, on_delete=models.CASCADE) + # order = models.ForeignKey(Order, related_name='tickets', null=True, on_delete=models.CASCADE) pot = models.CharField(max_length=100, null=True) owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) rate = models.CharField(max_length=40) @@ -408,6 +408,14 @@ def update_days(self, days): setattr(self, day, (day in days)) self.save() + @property + def invoice_description(self): + return 'Ticket {} for {} ({})'.format( + self.ticket_id, + self.invitation().email_addr if self.invitation() else self.owner, + ', '.join(self.days()), + ) + class UnconfirmedTicket: def __init__(self, order, days, owner=None, email_addr=None): diff --git a/tickets/templates/tickets/order.html b/tickets/templates/tickets/order.html index 94190c8..98a0327 100644 --- a/tickets/templates/tickets/order.html +++ b/tickets/templates/tickets/order.html @@ -3,44 +3,50 @@ {% load static %} {% block content %} -

Details of your order ({{ order.order_id }})

+

Details of your invoice ({{ invoice.id }})

-{% if order.payment_required %} -

You are ordering {{ order.num_tickets }} ticket{{ order.num_tickets|pluralize }} at the {{ order.rate }} rate.

+{% if invoice.payment_required %} +

You are ordering {{ invoice.num_tickets }} ticket{{ invoice.num_tickets|pluralize }} at the {{ invoice.rate }} rate.

{% else %} -

You have ordered {{ order.num_tickets }} ticket{{ order.num_tickets|pluralize }} at the {{ order.rate }} rate.

+

You have ordered {{ invoice.num_tickets }} ticket{{ invoice.num_tickets|pluralize }} at the {{ invoice.rate }} rate.

{% endif %} -{% include './_order_details.html' %} -{% if order.payment_required %} +{% for row in invoice.rows.all %} +{{ row.invoice_item }} {{ row.total_inc_vat }} +{% endfor %} + + +{% include './_invoice_details.html' %} + +{% if invoice.payment_required %}
-
+ {% csrf_token %} - Make changes to your order + Make changes to your invoice
-{% elif not order.status == 'errored' %} +{% elif not invoice.status == 'errored' %}
- View receipt + View receipt {% if ticket %} View your ticket {% endif %} diff --git a/tickets/templates/tickets/order_receipt.html b/tickets/templates/tickets/order_receipt.html deleted file mode 100644 index 743f874..0000000 --- a/tickets/templates/tickets/order_receipt.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends 'ironcage/base.html' %} - -{% block content %} -

Receipt for PyCon UK 2017 order {{ order.order_id }}

- -

Issued by the PyCon UK Society Ltd, c/o Acconomy, Arena Business Centre, Holyrood Close, Poole, BH17 7FJ, United Kingdom (VAT Number 249244982)

- -{% if order.rate == 'corporate' %} -

Issued to {{ order.company_name }}, {{ order.company_addr_formatted }}

-{% else %} -

Issued to {{ order.purchaser.name }}, {{ order.purchaser.email_addr }}

-{% endif %} - -

{{ order.num_tickets }} ticket{{ order.num_tickets|pluralize }} for PyCon UK 2017

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Date{{ order.stripe_charge_created|date }}
Invoice number{{ order.id }}
Total (excl. VAT)£{{ order.cost_excl_vat }}
VAT at 20%£{{ order.vat }}
Total (incl. VAT)£{{ order.cost_incl_vat }}
-
-
- - - - - - - - - - - - - - - {% for record in order.ticket_summary %} - - - - - - - - - {% endfor %} - - - - - - - - - - -
ItemQuantityPrice per item (excl. VAT)Price per item (incl. VAT)Total (excl. VAT)Total (incl. VAT)
Ticket for {{ record.num_days }} day{{ record.num_days|pluralize }}{{ record.num_tickets }}£{{ record.per_item_cost_excl_vat }}£{{ record.per_item_cost_incl_vat }}£{{ record.total_cost_excl_vat }}£{{ record.total_cost_incl_vat }}
Total£{{ order.cost_excl_vat }}£{{ order.cost_incl_vat }}
-{% endblock %} diff --git a/tickets/urls.py b/tickets/urls.py index 6e4b85a..4fb5c07 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -7,8 +7,7 @@ urlpatterns = [ url(r'^orders/new/$', views.new_order, name='new_order'), - url(r'^orders/(?P\w+)/edit/$', views.order_edit, name='order_edit'), - url(r'^orders/(?P\w+)/payment/$', views.order_payment, name='order_payment'), + url(r'^orders/(?P\w+)/edit/$', payment_views.order_edit, name='order_edit'), url(r'^orders/(?P\w+)/receipt/$', views.order_receipt, name='order_receipt'), url(r'^tickets/(?P\w+)/$', views.ticket, name='ticket'), url(r'^tickets/(?P\w+)/edit/$', views.ticket_edit, name='ticket_edit'), diff --git a/tickets/views.py b/tickets/views.py index eb1d5d4..37341b6 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -108,144 +108,6 @@ def new_order(request): return render(request, 'tickets/new_order.html', context) -@login_required -def order_edit(request, order_id): - order = Order.objects.get_by_order_id_or_404(order_id) - - if request.user != order.purchaser: - messages.warning(request, 'Only the purchaser of an order can update the order') - return redirect('index') - - if not order.payment_required(): - messages.error(request, 'This order has already been paid') - return redirect(order) - - if request.method == 'POST': - form = TicketForm(request.POST) - self_form = TicketForSelfForm(request.POST) - others_formset = TicketForOthersFormSet(request.POST) - company_details_form = CompanyDetailsForm(request.POST) - - if form.is_valid(): - who = form.cleaned_data['who'] - rate = form.cleaned_data['rate'] - - if who == 'self': - valid = self_form.is_valid() - if valid: - days_for_self = self_form.cleaned_data['days'] - email_addrs_and_days_for_others = None - elif who == 'others': - valid = others_formset.is_valid() - if valid: - days_for_self = None - email_addrs_and_days_for_others = others_formset.email_addrs_and_days - elif who == 'self and others': - valid = self_form.is_valid() and others_formset.is_valid() - if valid: - days_for_self = self_form.cleaned_data['days'] - email_addrs_and_days_for_others = others_formset.email_addrs_and_days - else: - assert False - - if valid: - if rate == 'corporate': - valid = company_details_form.is_valid() - if valid: - company_details = { - 'name': company_details_form.cleaned_data['company_name'], - 'addr': company_details_form.cleaned_data['company_addr'], - } - else: - company_details = None - - if valid: - actions.update_pending_order( - order, - rate=rate, - days_for_self=days_for_self, - email_addrs_and_days_for_others=email_addrs_and_days_for_others, - company_details=company_details, - ) - - return redirect(order) - - else: - form = TicketForm(order.form_data()) - self_form = TicketForSelfForm(order.self_form_data()) - others_formset = TicketForOthersFormSet(order.others_formset_data()) - company_details_form = CompanyDetailsForm(order.company_details_form_data()) - - context = { - 'form': form, - 'self_form': self_form, - 'others_formset': others_formset, - 'company_details_form': company_details_form, - 'user_can_buy_for_self': not request.user.get_ticket(), - 'rates_table_data': _rates_table_data(), - 'rates_data': _rates_data(), - 'js_paths': ['tickets/order_form.js'], - } - - return render(request, 'tickets/order_edit.html', context) - - -@login_required -def order(request, order_id): - order = Order.objects.get_by_order_id_or_404(order_id) - - if request.user != order.purchaser: - messages.warning(request, 'Only the purchaser of an order can view the order') - return redirect('index') - - if order.payment_required(): - if request.user.get_ticket() is not None and order.unconfirmed_details['days_for_self']: - messages.warning(request, 'You already have a ticket. Please amend your order.') - return redirect('tickets:order_edit', order.order_id) - - if order.status == 'failed': - messages.error(request, f'Payment for this order failed ({order.stripe_charge_failure_reason})') - elif order.status == 'errored': - messages.error(request, 'There was an error creating your order. You card may have been charged, but if so the charge will have been refunded. Please make a new order.') - - ticket = request.user.get_ticket() - if ticket is not None and ticket.order != order: - ticket = None - - context = { - 'order': order, - 'ticket': ticket, - 'stripe_api_key': settings.STRIPE_API_KEY_PUBLISHABLE, - } - return render(request, 'tickets/order.html', context) - - -@login_required -@require_POST -def order_payment(request, order_id): - order = Order.objects.get_by_order_id_or_404(order_id) - - if request.user != order.purchaser: - messages.warning(request, 'Only the purchaser of an order can pay for the order') - return redirect('index') - - if not order.payment_required(): - messages.error(request, 'This order has already been paid') - return redirect(order) - - if request.user.get_ticket() is not None and order.unconfirmed_details['days_for_self']: - messages.warning(request, 'You already have a ticket. Please amend your order. Your card has not been charged.') - return redirect('tickets:order_edit', order.order_id) - - token = request.POST['stripeToken'] - actions.process_stripe_charge(order, token) - - if not order.payment_required(): - messages.success(request, 'Payment for this order has been received.') - - return redirect(order) - - @login_required def order_receipt(request, order_id): order = Order.objects.get_by_order_id_or_404(order_id) From a86d45f57008af750959497a4befc8a50f1a7e45 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Mon, 26 Mar 2018 12:42:45 +0100 Subject: [PATCH 26/66] Further changes to invoice --- ironcage/templates/ironcage/index.html | 4 ++-- ironcage/views.py | 2 +- payments/actions.py | 11 +++++------ payments/models.py | 5 +++-- payments/templates/payments/_invoice_details.html | 2 +- payments/templates/payments/order.html | 2 +- tickets/models.py | 7 +++++-- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/ironcage/templates/ironcage/index.html b/ironcage/templates/ironcage/index.html index 1135b33..51362db 100644 --- a/ironcage/templates/ironcage/index.html +++ b/ironcage/templates/ironcage/index.html @@ -25,7 +25,7 @@
  • Order conference tickets
  • {% endif %} {% elif orders|length == 1 %} -
  • View your order
  • +
  • View your order
  • {% if ticket_sales_open %}
  • Order more conference tickets
  • {% endif %} @@ -33,7 +33,7 @@
  • View your orders:
  • {% if ticket_sales_open %} diff --git a/ironcage/views.py b/ironcage/views.py index 5cfa9bd..9b2c73b 100644 --- a/ironcage/views.py +++ b/ironcage/views.py @@ -18,7 +18,7 @@ def index(request): messages.warning(request, 'Your profile is incomplete') context = { 'ticket': user.get_ticket(), - 'orders': user.orders.all(), + 'orders': user.invoices.all(), 'grant_application': user.get_grant_application(), 'proposals': user.proposals.all(), 'nomination': user.get_nomination(), diff --git a/payments/actions.py b/payments/actions.py index 7ce0293..2f942e9 100644 --- a/payments/actions.py +++ b/payments/actions.py @@ -38,12 +38,12 @@ def create_new_credit_note(purchaser, invoice_to=None): def confirm_order(order, charge_id, charge_created): - logger.info('confirm_order', order=order.order_id, charge_id=charge_id) + logger.info('confirm_order', invoice=invoice.id, charge_id=charge_id) with transaction.atomic(): - order.confirm(charge_id, charge_created) - send_receipt(order) - send_ticket_invitations(order) - slack_message('tickets/order_created.slack', {'order': order}) + invoice.confirm(charge_id, charge_created) + send_receipt(invoice) + send_ticket_invitations(invoice) + slack_message('tickets/order_created.slack', {'invoice': invoice}) def process_stripe_charge(invoice, token): @@ -59,7 +59,6 @@ def process_stripe_charge(invoice, token): amount=charge.amount/100 ) - confirm_order(invoice, charge.id, charge.created) except stripe.error.CardError as e: mark_order_as_failed(invoice, e._message) diff --git a/payments/models.py b/payments/models.py index b3b9773..8ea7c54 100644 --- a/payments/models.py +++ b/payments/models.py @@ -82,10 +82,11 @@ def total_vat(self): return total_vat def get_absolute_url(self): - return reverse('tickets:order', args=[self.id]) + return reverse('payments:order', args=[self.id]) def get_payment(self): - import ipdb; ipdb.set_trace() + # TODO: BETTER + return self.payments.first() @property def total_pence_inc_vat(self): diff --git a/payments/templates/payments/_invoice_details.html b/payments/templates/payments/_invoice_details.html index b894b5b..5ed85b3 100644 --- a/payments/templates/payments/_invoice_details.html +++ b/payments/templates/payments/_invoice_details.html @@ -52,7 +52,7 @@

    Invoice details

    {% for row in invoice.rows.all %} - {{ row.id }} + {{ row.invoice_item.item_id }} {{ row.invoice_item.invoice_description }} {{ row.vat_rate|floatformat:0 }}% £{{ row.total_inc_vat|floatformat:2 }} diff --git a/payments/templates/payments/order.html b/payments/templates/payments/order.html index e2f9337..2ee86f2 100644 --- a/payments/templates/payments/order.html +++ b/payments/templates/payments/order.html @@ -34,7 +34,7 @@

    Details of your order

    {% elif not invoice.status == 'errored' %}
    - View receipt + View receipt {% if ticket %} View your ticket {% endif %} diff --git a/tickets/models.py b/tickets/models.py index c497375..6eee026 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -408,10 +408,13 @@ def update_days(self, days): setattr(self, day, (day in days)) self.save() + @property + def item_id(self): + return self.ticket_id + @property def invoice_description(self): - return 'Ticket {} for {} ({})'.format( - self.ticket_id, + return 'PyCon UK 2018 Ticket for {} ({})'.format( self.invitation().email_addr if self.invitation() else self.owner, ', '.join(self.days()), ) From b3ec2529bac1896094c1f663cc62d81104144123 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Mon, 26 Mar 2018 12:55:46 +0100 Subject: [PATCH 27/66] Change instances of 2017 to 2018 --- accommodation/mailer.py | 4 +-- .../accommodation/booking_payment.html | 2 +- .../accommodation/booking_receipt.html | 2 +- accommodation/tests/test_views.py | 4 +-- accommodation/views.py | 2 +- .../registration/password_reset_email.html | 2 +- children/mailer.py | 2 +- .../children/emails/order-confirmation.txt | 4 +-- children/templates/children/order.html | 2 +- children/tests/test_mailer.py | 6 ++-- dinners/mailer.py | 2 +- .../dinners/conference_dinner_payment.html | 2 +- .../dinners/conference_dinner_receipt.html | 6 ++-- .../dinners/emails/order-confirmation.txt | 2 +- dinners/tests/test_views.py | 2 +- dinners/views.py | 2 +- .../emails/cfp-proposals-notification.txt | 2 +- emails/templates/emails/schedule-days.txt | 2 +- .../templates/emails/ukpa-ticket-holders.txt | 2 +- emails/tests.py | 2 +- ironcage/management/commands/sendtestemail.py | 2 +- ironcage/settings/base.py | 8 ++--- ironcage/settings/local.py | 2 +- ironcage/settings/prod.py | 6 ++-- ironcage/settings/staging.py | 6 ++-- ironcage/templates/ironcage/base.html | 2 +- ironcage/templates/ironcage/index.html | 2 +- tickets/mailer.py | 18 +++++------ .../tickets/emails/order-confirmation.txt | 4 +-- tickets/templates/tickets/order.html | 2 +- tickets/tests/data/stripe_charge_success.json | 2 +- tickets/tests/test_mailer.py | 30 +++++++++---------- tickets/tests/test_views.py | 4 +-- tickets/views.py | 4 +-- 34 files changed, 73 insertions(+), 73 deletions(-) diff --git a/accommodation/mailer.py b/accommodation/mailer.py index 644be9f..72ceb3a 100644 --- a/accommodation/mailer.py +++ b/accommodation/mailer.py @@ -13,7 +13,7 @@ We look forward to seeing you in Cardiff! -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team '''.strip() @@ -21,7 +21,7 @@ def send_booking_confirmation_mail(booking): body = INVITATION_TEMPLATE.format(room_description=booking.room_description()) send_mail( - f'PyCon UK 2017 accommodation confirmation', + f'PyCon UK 2018 accommodation confirmation', body, booking.guest.email_addr, ) diff --git a/accommodation/templates/accommodation/booking_payment.html b/accommodation/templates/accommodation/booking_payment.html index 671804c..e2e9a3f 100644 --- a/accommodation/templates/accommodation/booking_payment.html +++ b/accommodation/templates/accommodation/booking_payment.html @@ -28,7 +28,7 @@

    Accommodation booking payment

    data-currency="gbp" data-name="PyCon UK Society Ltd" data-image="{% static 'ironcage/img/yellow.png' %}" - data-description="PyCon UK 2017 accommodation" + data-description="PyCon UK 2018 accommodation" data-locale="auto" data-email="{{ user.email_addr }}" > diff --git a/accommodation/templates/accommodation/booking_receipt.html b/accommodation/templates/accommodation/booking_receipt.html index bf29f35..64d210f 100644 --- a/accommodation/templates/accommodation/booking_receipt.html +++ b/accommodation/templates/accommodation/booking_receipt.html @@ -1,7 +1,7 @@ {% extends 'ironcage/base.html' %} {% block content %} -

    Receipt for PyCon UK 2017 accommodation

    +

    Receipt for PyCon UK 2018 accommodation

    Issued by the PyCon UK Society Ltd, c/o Acconomy, Arena Business Centre, Holyrood Close, Poole, BH17 7FJ, United Kingdom (VAT Number 249244982)

    diff --git a/accommodation/tests/test_views.py b/accommodation/tests/test_views.py index ba052e8..a57994d 100644 --- a/accommodation/tests/test_views.py +++ b/accommodation/tests/test_views.py @@ -129,8 +129,8 @@ def test_post_stripe_success(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, [self.alice.email_addr]) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, 'PyCon UK 2017 accommodation confirmation') + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, 'PyCon UK 2018 accommodation confirmation') self.assertIn(ROOMS[1].description, email.body) backend = get_slack_backend() diff --git a/accommodation/views.py b/accommodation/views.py index 109cb3e..987cef0 100644 --- a/accommodation/views.py +++ b/accommodation/views.py @@ -49,7 +49,7 @@ def booking_payment(request): token = request.POST['stripeToken'] charge = create_charge( room.cost_incl_vat * 100, - 'PyCon UK 2017 accommodation', + 'PyCon UK 2018 accommodation', 'PyCon UK accommodation', token ) diff --git a/accounts/templates/registration/password_reset_email.html b/accounts/templates/registration/password_reset_email.html index 576ecaa..501c75e 100644 --- a/accounts/templates/registration/password_reset_email.html +++ b/accounts/templates/registration/password_reset_email.html @@ -6,4 +6,4 @@ Your username, in case you've forgotten: {{ user.get_username }} -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team diff --git a/children/mailer.py b/children/mailer.py index 156b1b9..7a61926 100644 --- a/children/mailer.py +++ b/children/mailer.py @@ -20,7 +20,7 @@ def send_order_confirmation_mail(order): body = re.sub('\n\n\n+', '\n\n', body_raw) send_mail( - f"PyCon UK 2017 children's day order confirmation ({order.order_id})", + f"PyCon UK 2018 children's day order confirmation ({order.order_id})", body, order.purchaser.email_addr, ) diff --git a/children/templates/children/emails/order-confirmation.txt b/children/templates/children/emails/order-confirmation.txt index b6bc55c..b602d8c 100644 --- a/children/templates/children/emails/order-confirmation.txt +++ b/children/templates/children/emails/order-confirmation.txt @@ -1,6 +1,6 @@ Hi {{ purchaser_name }}, -You have purchased {{ num_tickets }} ticket{{ num_tickets|pluralize }} for the children's day at PyCon UK 2017. +You have purchased {{ num_tickets }} ticket{{ num_tickets|pluralize }} for the children's day at PyCon UK 2018. You can view your order at: @@ -8,4 +8,4 @@ You can view your order at: Best wishes, -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team diff --git a/children/templates/children/order.html b/children/templates/children/order.html index 477b3d7..09e8c1d 100644 --- a/children/templates/children/order.html +++ b/children/templates/children/order.html @@ -24,7 +24,7 @@

    Details of your children's day order ({{ order.order_id }})

    data-currency="gbp" data-name="PyCon UK Society Ltd" data-image="{% static 'ironcage/img/yellow.png' %}" - data-description="PyCon UK 2017 order {{ order.order_id }}" + data-description="PyCon UK 2018 order {{ order.order_id }}" data-locale="auto" data-email="{{ order.purchaser.email_addr }}" > diff --git a/children/tests/test_mailer.py b/children/tests/test_mailer.py index c7fe4cf..3f9561f 100644 --- a/children/tests/test_mailer.py +++ b/children/tests/test_mailer.py @@ -23,7 +23,7 @@ def test_send_order_confirmation_mail(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f"PyCon UK 2017 children's day order confirmation ({order.order_id})") - self.assertTrue(re.search(r"You have purchased 1 ticket for the children's day at PyCon UK 2017", email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f"PyCon UK 2018 children's day order confirmation ({order.order_id})") + self.assertTrue(re.search(r"You have purchased 1 ticket for the children's day at PyCon UK 2018", email.body)) self.assertTrue(re.search(fr'http://testserver/children/orders/{order.order_id}/', email.body)) diff --git a/dinners/mailer.py b/dinners/mailer.py index 1fb0593..14dbc59 100644 --- a/dinners/mailer.py +++ b/dinners/mailer.py @@ -9,7 +9,7 @@ def send_booking_confirmation_mail(booking): user = booking.guest - subject = f'PyCon UK 2017 dinner confirmation | {user.user_id}' + subject = f'PyCon UK 2018 dinner confirmation | {user.user_id}' template = get_template('dinners/emails/order-confirmation.txt') context = { diff --git a/dinners/templates/dinners/conference_dinner_payment.html b/dinners/templates/dinners/conference_dinner_payment.html index 3dd9121..b1c4efe 100644 --- a/dinners/templates/dinners/conference_dinner_payment.html +++ b/dinners/templates/dinners/conference_dinner_payment.html @@ -36,7 +36,7 @@

    Conference dinner payment

    data-currency="gbp" data-name="PyCon UK Society Ltd" data-image="{% static 'ironcage/img/yellow.png' %}" - data-description="PyCon UK 2017 conference dinner" + data-description="PyCon UK 2018 conference dinner" data-locale="auto" data-email="{{ user.email_addr }}" > diff --git a/dinners/templates/dinners/conference_dinner_receipt.html b/dinners/templates/dinners/conference_dinner_receipt.html index 2dc6a3b..e688bb4 100644 --- a/dinners/templates/dinners/conference_dinner_receipt.html +++ b/dinners/templates/dinners/conference_dinner_receipt.html @@ -1,13 +1,13 @@ {% extends 'ironcage/base.html' %} {% block content %} -

    Receipt for PyCon UK 2017 conference dinner

    +

    Receipt for PyCon UK 2018 conference dinner

    Issued by the PyCon UK Society Ltd, c/o Acconomy, Arena Business Centre, Holyrood Close, Poole, BH17 7FJ, United Kingdom (VAT Number 249244982)

    Issued to {{ booking.guest.name }}

    -

    Ticket for PyCon UK 2017 conference dinner

    +

    Ticket for PyCon UK 2018 conference dinner

    @@ -49,7 +49,7 @@

    Receipt for PyCon UK 2017 conference dinner

    - Ticket for PyCon UK 2017 conference dinner + Ticket for PyCon UK 2018 conference dinner 1 £25 £30 diff --git a/dinners/templates/dinners/emails/order-confirmation.txt b/dinners/templates/dinners/emails/order-confirmation.txt index e905cc0..8f66666 100644 --- a/dinners/templates/dinners/emails/order-confirmation.txt +++ b/dinners/templates/dinners/emails/order-confirmation.txt @@ -18,5 +18,5 @@ You can download a receipt at : Best wishes, -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team {% endautoescape %} diff --git a/dinners/tests/test_views.py b/dinners/tests/test_views.py index ad90088..c4cd074 100644 --- a/dinners/tests/test_views.py +++ b/dinners/tests/test_views.py @@ -274,4 +274,4 @@ def test_get_when_booked_as_contributor(self): def test_get_when_booked(self): factories.create_paid_booking(self.alice) rsp = self.client.get(self.url, follow=True) - self.assertContains(rsp, 'Receipt for PyCon UK 2017 conference dinner') + self.assertContains(rsp, 'Receipt for PyCon UK 2018 conference dinner') diff --git a/dinners/views.py b/dinners/views.py index 70e5a4f..9f8434c 100644 --- a/dinners/views.py +++ b/dinners/views.py @@ -131,7 +131,7 @@ def conference_dinner_payment(request): token = request.POST['stripeToken'] charge = create_charge( CONFERENCE_DINNER_PRICE_PENCE, - 'PyCon UK 2017 dinner', + 'PyCon UK 2018 dinner', 'PyCon UK dinner', token ) diff --git a/emails/templates/emails/cfp-proposals-notification.txt b/emails/templates/emails/cfp-proposals-notification.txt index 1b945e3..a6043e9 100644 --- a/emails/templates/emails/cfp-proposals-notification.txt +++ b/emails/templates/emails/cfp-proposals-notification.txt @@ -90,7 +90,7 @@ Hi {{ recipient.name }}, assistance. {% elif application.requested_ticket_only %} - You have applied for a complementary ticket for PyCon UK 2017, and we're + You have applied for a complementary ticket for PyCon UK 2018, and we're also pleased to tell you that we can offer you a ticket for the conference. We will send instructions for claiming your ticket in the next few days. diff --git a/emails/templates/emails/schedule-days.txt b/emails/templates/emails/schedule-days.txt index 813a7d5..96c8578 100644 --- a/emails/templates/emails/schedule-days.txt +++ b/emails/templates/emails/schedule-days.txt @@ -1,7 +1,7 @@ Hi {{ recipient.name }}, We mentioned in an email last week that we have published a provisional -day-by-day schedule for PyCon UK 2017, which you can find our website at +day-by-day schedule for PyCon UK 2018, which you can find our website at http://2017.pyconuk.org/schedule/. We'd like to minimise further changes to the schedule; so if you can't speak on diff --git a/emails/templates/emails/ukpa-ticket-holders.txt b/emails/templates/emails/ukpa-ticket-holders.txt index 2ae6d45..abc4ac4 100644 --- a/emails/templates/emails/ukpa-ticket-holders.txt +++ b/emails/templates/emails/ukpa-ticket-holders.txt @@ -5,7 +5,7 @@ Python Association, a newly-formed charity whose purpose is support the Python community in the UK. You can read more about the UKPA on the conference website[1]. -The UKPA is a membership organisation, and anybody who attends PyCon UK 2017 is +The UKPA is a membership organisation, and anybody who attends PyCon UK 2018 is entitled to membership. For now, membership entitles you to vote to elect the organisation's trustees. You can indicate that you would like to join the UKPA when you fill out your profile[2]. diff --git a/emails/tests.py b/emails/tests.py index b4808fc..4d63772 100644 --- a/emails/tests.py +++ b/emails/tests.py @@ -50,6 +50,6 @@ def test_wet_run(self): self.assertEqual(len(mail.outbox), 2) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') + self.assertEqual(email.from_email, 'PyCon UK 2018 ') self.assertEqual(email.subject, f'This is a test | {self.alice.user_id}') self.assertIn('Hi Alice', email.body) diff --git a/ironcage/management/commands/sendtestemail.py b/ironcage/management/commands/sendtestemail.py index cb791c1..c436d9e 100644 --- a/ironcage/management/commands/sendtestemail.py +++ b/ironcage/management/commands/sendtestemail.py @@ -11,7 +11,7 @@ def add_arguments(self, parser): def handle(self, *args, to_addr, **kwargs): send_mail( - 'PyCon UK 2017 test email', + 'PyCon UK 2018 test email', f'This is a test, generated at {datetime.now()}', to_addr, ) diff --git a/ironcage/settings/base.py b/ironcage/settings/base.py index e7ed146..ac0f6a4 100644 --- a/ironcage/settings/base.py +++ b/ironcage/settings/base.py @@ -253,10 +253,10 @@ # Email address to send mail from -DEFAULT_FROM_EMAIL = 'PyCon UK 2017 ' -SERVER_EMAIL = 'PyCon UK 2017 ' -EMAIL_FROM_ADDR = f'PyCon UK 2017 ' -EMAIL_REPLY_TO_ADDR = 'PyCon UK 2017 ' +DEFAULT_FROM_EMAIL = 'PyCon UK 2018 ' +SERVER_EMAIL = 'PyCon UK 2018 ' +EMAIL_FROM_ADDR = f'PyCon UK 2018 ' +EMAIL_REPLY_TO_ADDR = 'PyCon UK 2018 ' # Maintenance mode diff --git a/ironcage/settings/local.py b/ironcage/settings/local.py index 6d2ecc0..7d59177 100644 --- a/ironcage/settings/local.py +++ b/ironcage/settings/local.py @@ -19,4 +19,4 @@ LOGGING['loggers']['django']['handlers'].remove('slack_admins') # Email address to send mail from -SERVER_EMAIL = 'PyCon UK 2017 ' +SERVER_EMAIL = 'PyCon UK 2018 ' diff --git a/ironcage/settings/prod.py b/ironcage/settings/prod.py index 3bee789..3724895 100644 --- a/ironcage/settings/prod.py +++ b/ironcage/settings/prod.py @@ -17,9 +17,9 @@ X_FRAME_OPTIONS = 'DENY' # Email address to send mail from -SERVER_EMAIL = f'PyCon UK 2017 ' -EMAIL_FROM_ADDR = f'PyCon UK 2017 ' -EMAIL_REPLY_TO_ADDR = 'PyCon UK 2017 ' +SERVER_EMAIL = f'PyCon UK 2018 ' +EMAIL_FROM_ADDR = f'PyCon UK 2018 ' +EMAIL_REPLY_TO_ADDR = 'PyCon UK 2018 ' # Last orders... bst = timezone(timedelta(hours=1)) diff --git a/ironcage/settings/staging.py b/ironcage/settings/staging.py index e70b513..32e60a4 100644 --- a/ironcage/settings/staging.py +++ b/ironcage/settings/staging.py @@ -20,6 +20,6 @@ SLACK_USERNAME = 'ironcage-log-bot-staging' # Email address to send error mails from -SERVER_EMAIL = f'PyCon UK 2017 ' -EMAIL_FROM_ADDR = f'PyCon UK 2017 ' -EMAIL_REPLY_TO_ADDR = 'PyCon UK 2017 ' +SERVER_EMAIL = f'PyCon UK 2018 ' +EMAIL_FROM_ADDR = f'PyCon UK 2018 ' +EMAIL_REPLY_TO_ADDR = 'PyCon UK 2018 ' diff --git a/ironcage/templates/ironcage/base.html b/ironcage/templates/ironcage/base.html index 031b934..ddbf590 100644 --- a/ironcage/templates/ironcage/base.html +++ b/ironcage/templates/ironcage/base.html @@ -10,7 +10,7 @@ - {{ title|default:"PyCon UK 2017 HQ" }} + {{ title|default:"PyCon UK 2018 HQ" }}
    diff --git a/ironcage/templates/ironcage/index.html b/ironcage/templates/ironcage/index.html index 51362db..a0cb63e 100644 --- a/ironcage/templates/ironcage/index.html +++ b/ironcage/templates/ironcage/index.html @@ -5,7 +5,7 @@

    Hello, {{ request.user.name }}

    {% endif %} -

    Welcome to the PyCon UK 2017 conference HQ.

    +

    Welcome to the PyCon UK 2018 conference HQ.

    The conference takes place at Cardiff City Hall from Thursday 26th to Monday 30th October 2017. You can find out more about the conference on our website.

    diff --git a/tickets/mailer.py b/tickets/mailer.py index 96c0576..147ad4d 100644 --- a/tickets/mailer.py +++ b/tickets/mailer.py @@ -10,7 +10,7 @@ INVITATION_TEMPLATE = ''' Hello! -{purchaser_name} has purchased you a ticket for PyCon UK 2017. +{purchaser_name} has purchased you a ticket for PyCon UK 2018. Please click here to claim your ticket: @@ -18,14 +18,14 @@ We look forward to seeing you in Cardiff! -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team '''.strip() FREE_TICKET_INVITATION_TEMPLATE = ''' Hello! -You have been assigned a ticket for PyCon UK 2017. +You have been assigned a ticket for PyCon UK 2018. Please click here to claim your ticket: @@ -33,7 +33,7 @@ We look forward to seeing you in Cardiff! -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team '''.strip() @@ -47,7 +47,7 @@ def send_invitation_mail(ticket): body = INVITATION_TEMPLATE.format(purchaser_name=purchaser_name, url=url) send_mail( - f'PyCon UK 2017 ticket invitation ({ticket.ticket_id})', + f'PyCon UK 2018 ticket invitation ({ticket.ticket_id})', body, invitation.email_addr, ) @@ -68,7 +68,7 @@ def send_order_confirmation_mail(order): body = re.sub('\n\n\n+', '\n\n', body_raw) send_mail( - f'PyCon UK 2017 order confirmation ({order.order_id})', + f'PyCon UK 2018 order confirmation ({order.order_id})', body, order.purchaser.email_addr, ) @@ -77,9 +77,9 @@ def send_order_confirmation_mail(order): ORDER_REFUND_TEMPLATE = ''' Hi {purchaser_name}, -Your order for PyCon UK 2017 has been refunded. +Your order for PyCon UK 2018 has been refunded. -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team '''.strip() @@ -87,7 +87,7 @@ def send_order_refund_mail(order): body = ORDER_REFUND_TEMPLATE.format(purchaser_name=order.purchaser.name) send_mail( - f'PyCon UK 2017 order refund ({order.order_id})', + f'PyCon UK 2018 order refund ({order.order_id})', body, order.purchaser.email_addr, ) diff --git a/tickets/templates/tickets/emails/order-confirmation.txt b/tickets/templates/tickets/emails/order-confirmation.txt index 467b81f..33a5184 100644 --- a/tickets/templates/tickets/emails/order-confirmation.txt +++ b/tickets/templates/tickets/emails/order-confirmation.txt @@ -1,6 +1,6 @@ Hi {{ purchaser_name }}, -You have purchased {{ num_tickets }} ticket{{ num_tickets|pluralize }} for PyCon UK 2017. +You have purchased {{ num_tickets }} ticket{{ num_tickets|pluralize }} for PyCon UK 2018. {% if tickets_for_others %} Ticket invitations have been sent to the following: @@ -18,4 +18,4 @@ We look forward to seeing you in Cardiff! Best wishes, {% endif %} -~ The PyCon UK 2017 team +~ The PyCon UK 2018 team diff --git a/tickets/templates/tickets/order.html b/tickets/templates/tickets/order.html index 98a0327..167a549 100644 --- a/tickets/templates/tickets/order.html +++ b/tickets/templates/tickets/order.html @@ -30,7 +30,7 @@

    Details of your invoice ({{ invoice.id }})

    data-currency="gbp" data-name="PyCon UK Society Ltd" data-image="{% static 'ironcage/img/yellow.png' %}" - data-description="PyCon UK 2017 invoice {{ invoice.order_id }}" + data-description="PyCon UK 2018 invoice {{ invoice.order_id }}" data-locale="auto" data-email="{{ invoice.purchaser.email_addr }}" > diff --git a/tickets/tests/data/stripe_charge_success.json b/tickets/tests/data/stripe_charge_success.json index 8a2fd4b..c03a16d 100644 --- a/tickets/tests/data/stripe_charge_success.json +++ b/tickets/tests/data/stripe_charge_success.json @@ -8,7 +8,7 @@ "created": 1495355163, "currency": "gbp", "customer": null, - "description": "PyCon UK 2017 order DF55", + "description": "PyCon UK 2018 order DF55", "destination": null, "dispute": null, "failure_code": null, diff --git a/tickets/tests/test_mailer.py b/tickets/tests/test_mailer.py index c24dcc3..b4d58bc 100644 --- a/tickets/tests/test_mailer.py +++ b/tickets/tests/test_mailer.py @@ -24,9 +24,9 @@ def test_send_invitation_mail(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['bob@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f'PyCon UK 2017 ticket invitation ({invitation.ticket.ticket_id})') - self.assertTrue(re.search(r'Alice has purchased you a ticket for PyCon UK 2017', email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f'PyCon UK 2018 ticket invitation ({invitation.ticket.ticket_id})') + self.assertTrue(re.search(r'Alice has purchased you a ticket for PyCon UK 2018', email.body)) self.assertTrue(re.search(r'http://testserver/tickets/invitations/\w{12}/', email.body)) def test_send_invitation_mail_for_free_ticket(self): @@ -39,9 +39,9 @@ def test_send_invitation_mail_for_free_ticket(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f'PyCon UK 2017 ticket invitation ({invitation.ticket.ticket_id})') - self.assertTrue(re.search(r'You have been assigned a ticket for PyCon UK 2017', email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f'PyCon UK 2018 ticket invitation ({invitation.ticket.ticket_id})') + self.assertTrue(re.search(r'You have been assigned a ticket for PyCon UK 2018', email.body)) self.assertTrue(re.search(r'http://testserver/tickets/invitations/\w{12}/', email.body)) def test_send_order_confirmation_mail_for_order_for_self(self): @@ -54,9 +54,9 @@ def test_send_order_confirmation_mail_for_order_for_self(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f'PyCon UK 2017 order confirmation ({order.order_id})') - self.assertTrue(re.search(r'You have purchased 1 ticket for PyCon UK 2017', email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertTrue(re.search(r'You have purchased 1 ticket for PyCon UK 2018', email.body)) self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) self.assertFalse(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('We look forward to seeing you in Cardiff', email.body)) @@ -71,9 +71,9 @@ def test_send_order_confirmation_mail_for_order_for_others(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f'PyCon UK 2017 order confirmation ({order.order_id})') - self.assertTrue(re.search(r'You have purchased 2 tickets for PyCon UK 2017', email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertTrue(re.search(r'You have purchased 2 tickets for PyCon UK 2018', email.body)) self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) self.assertTrue(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('bob@example.com', email.body)) @@ -90,9 +90,9 @@ def test_send_order_confirmation_mail_for_order_for_self_and_others(self): self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) - self.assertEqual(email.from_email, 'PyCon UK 2017 ') - self.assertEqual(email.subject, f'PyCon UK 2017 order confirmation ({order.order_id})') - self.assertTrue(re.search(r'You have purchased 3 tickets for PyCon UK 2017', email.body)) + self.assertEqual(email.from_email, 'PyCon UK 2018 ') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertTrue(re.search(r'You have purchased 3 tickets for PyCon UK 2018', email.body)) self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) self.assertTrue(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('bob@example.com', email.body)) diff --git a/tickets/tests/test_views.py b/tickets/tests/test_views.py index 025e3e7..e4feeab 100644 --- a/tickets/tests/test_views.py +++ b/tickets/tests/test_views.py @@ -414,8 +414,8 @@ def setUpTestData(cls): def test_order_receipt(self): self.client.force_login(self.order.purchaser) rsp = self.client.get(f'/tickets/orders/{self.order.order_id}/receipt/', follow=True) - self.assertContains(rsp, f'Receipt for PyCon UK 2017 order {self.order.order_id}') - self.assertContains(rsp, '3 tickets for PyCon UK 2017') + self.assertContains(rsp, f'Receipt for PyCon UK 2018 order {self.order.order_id}') + self.assertContains(rsp, '3 tickets for PyCon UK 2018') self.assertContains(rsp, 'DateMay 21, 2017', html=True) self.assertContains(rsp, 'Total (excl. VAT)£255', html=True) self.assertContains(rsp, 'VAT at 20%£51', html=True) diff --git a/tickets/views.py b/tickets/views.py index 37341b6..7e58307 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -10,7 +10,7 @@ from payments import actions as payment_actions from . import actions from .forms import CompanyDetailsForm, TicketForm, TicketForSelfForm, TicketForOthersFormSet -from .models import Order, Ticket, TicketInvitation +from .models import Ticket, TicketInvitation #Order, from .prices import PRICES_INCL_VAT, cost_incl_vat @@ -122,7 +122,7 @@ def order_receipt(request, order_id): context = { 'order': order, - 'title': f'PyCon UK 2017 receipt for order {order.order_id}', + 'title': f'PyCon UK 2018 receipt for order {order.order_id}', 'no_navbar': True, } return render(request, 'tickets/order_receipt.html', context) From c7483cfb5f1a5789c2e26c953fbecf0c2c70b00b Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Mon, 26 Mar 2018 12:59:15 +0100 Subject: [PATCH 28/66] Another 2018 change --- ironcage/templates/ironcage/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ironcage/templates/ironcage/index.html b/ironcage/templates/ironcage/index.html index a0cb63e..dad6dd1 100644 --- a/ironcage/templates/ironcage/index.html +++ b/ironcage/templates/ironcage/index.html @@ -7,7 +7,7 @@

    Welcome to the PyCon UK 2018 conference HQ.

    -

    The conference takes place at Cardiff City Hall from Thursday 26th to Monday 30th October 2017. You can find out more about the conference on our website.

    +

    The conference takes place at Cardiff City Hall from Saturday 15th to Wednesday 19th September 2018. You can find out more about the conference on our website.

    {% if ticket %}

    You have a ticket for {{ ticket.details.days }}.

    From 407d2dc0c59d6ae400305d493950791829da563f Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Tue, 27 Mar 2018 12:14:46 +0100 Subject: [PATCH 29/66] Orders is not used in the account profile --- accounts/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/accounts/views.py b/accounts/views.py index 1333a52..aafb683 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -12,7 +12,6 @@ def profile(request): context = { 'name': user.name, - 'orders': user.orders.all(), 'ticket': user.get_ticket(), } return render(request, 'accounts/profile.html', context) From 96373c3af175b7e3113dc149b1bc1805702b8100 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Tue, 27 Mar 2018 12:35:00 +0100 Subject: [PATCH 30/66] Improve formatting and readability --- ironcage/templates/ironcage/index.html | 138 ++++++++++++------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/ironcage/templates/ironcage/index.html b/ironcage/templates/ironcage/index.html index dad6dd1..3fcf4c1 100644 --- a/ironcage/templates/ironcage/index.html +++ b/ironcage/templates/ironcage/index.html @@ -16,102 +16,102 @@

    Here, you can:

    {% if ticket %} -

    We look forward to seeing you in Cardiff!

    +

    We look forward to seeing you in Cardiff!

    {% else %} -

    We hope to see you in Cardiff!

    +

    We hope to see you in Cardiff!

    {% endif %}

    ~ The PyCon UK Committee

    From d5b564178492f26592cee606e9ed12118d9587b3 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Tue, 27 Mar 2018 12:35:35 +0100 Subject: [PATCH 31/66] Add the profile link to the profile incomplete message --- tickets/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/views.py b/tickets/views.py index 7e58307..edbde17 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.http import require_POST +from django.utils.safestring import mark_safe from payments import actions as payment_actions from . import actions @@ -140,7 +141,7 @@ def ticket(request, ticket_id): return redirect('tickets:ticket_edit', ticket.ticket_id) if not request.user.profile_complete(): - messages.warning(request, 'Your profile is incomplete') + messages.warning(request, mark_safe('Your profile is incomplete. Update your profile'.format(reverse('accounts:profile')))) context = { 'ticket': ticket, From 87ce28133a53655f6cab2c5de6e741966f4aac92 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 08:30:28 +0100 Subject: [PATCH 32/66] Remove Order --- reports/reports.py | 2 +- reports/views.py | 2 +- tickets/actions.py | 2 +- tickets/migrations/0012_auto_20180328_1133.py | 24 +++++++++++++++++++ tickets/models.py | 3 ++- tickets/templates/tickets/order_created.slack | 4 ++-- 6 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 tickets/migrations/0012_auto_20180328_1133.py diff --git a/reports/reports.py b/reports/reports.py index 9353aa9..81b2908 100644 --- a/reports/reports.py +++ b/reports/reports.py @@ -17,7 +17,7 @@ from dinners.menus import MENUS from grants.models import Application from tickets.constants import DAYS -from tickets.models import Order, Ticket +from tickets.models import Ticket #, Order from tickets.prices import PRICES_EXCL_VAT, cost_incl_vat from ukpa.models import Nomination diff --git a/reports/views.py b/reports/views.py index f315542..7879137 100644 --- a/reports/views.py +++ b/reports/views.py @@ -6,7 +6,7 @@ from cfp.models import Proposal from grants.forms import ApplicationForm from grants.models import Application -from tickets.models import Order, Ticket +from tickets.models import Ticket #Order, from .reports import reports diff --git a/tickets/actions.py b/tickets/actions.py index 7823e2e..6d9073a 100644 --- a/tickets/actions.py +++ b/tickets/actions.py @@ -16,7 +16,7 @@ from ironcage.stripe_integration import create_charge_for_order, refund_charge from .mailer import send_invitation_mail, send_order_confirmation_mail, send_order_refund_mail -from .models import Order, Ticket +from .models import Ticket #Order, import structlog logger = structlog.get_logger() diff --git a/tickets/migrations/0012_auto_20180328_1133.py b/tickets/migrations/0012_auto_20180328_1133.py new file mode 100644 index 0000000..12c46cb --- /dev/null +++ b/tickets/migrations/0012_auto_20180328_1133.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.3 on 2018-03-28 11:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0011_ticket_rate'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='purchaser', + ), + migrations.RemoveField( + model_name='ticket', + name='order', + ), + migrations.DeleteModel( + name='Order', + ), + ] diff --git a/tickets/models.py b/tickets/models.py index 6eee026..0262a83 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -398,7 +398,8 @@ def invitation(self): return self.invitations.get() def is_free_ticket(self): - return not self.order + return self.rate == 'free' + # Previously checked for an order being attached def is_incomplete(self): return self.days() == [] diff --git a/tickets/templates/tickets/order_created.slack b/tickets/templates/tickets/order_created.slack index 5b8620f..a3454d1 100644 --- a/tickets/templates/tickets/order_created.slack +++ b/tickets/templates/tickets/order_created.slack @@ -1,7 +1,7 @@ {% extends django_slack %} {% block text %} -New order {{ order.order_id }} +New invoice {{ invoice.id }} -{{ order.purchaser.name }} has just placed an order for {{ order.num_tickets }} ticket{{ order.num_tickets.pluralize }} at the {{ order.rate }} rate. +{{ invoice.purchaser.name }} has just placed an invoice for invoice.num_tickets ticket invoice.num_tickets.pluralize at the invoice.rate rate. {% endblock %} From 88b94a8f2ceb6d9fd35809ef513017efffa3c243 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 09:21:22 +0100 Subject: [PATCH 33/66] Remove existing migrations for payments --- payments/migrations/0001_initial.py | 56 ------------------- payments/migrations/0002_default_on_total.py | 18 ------ .../migrations/0003_items_once_per_invoice.py | 18 ------ .../0004_create_update_fields_on_payment.py | 35 ------------ 4 files changed, 127 deletions(-) delete mode 100644 payments/migrations/0001_initial.py delete mode 100644 payments/migrations/0002_default_on_total.py delete mode 100644 payments/migrations/0003_items_once_per_invoice.py delete mode 100644 payments/migrations/0004_create_update_fields_on_payment.py diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py deleted file mode 100644 index 4040bb7..0000000 --- a/payments/migrations/0001_initial.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 2.0 on 2018-03-04 10:49 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Invoice', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('invoice_to', models.TextField()), - ('is_credit', models.BooleanField()), - ('total', models.DecimalField(decimal_places=2, max_digits=7)), - ('purchaser', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='InvoiceRow', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.PositiveIntegerField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('total_ex_vat', models.DecimalField(decimal_places=2, max_digits=7)), - ('vat_rate', models.DecimalField(choices=[(20.0, 'Standard 20%'), (0.0, 'Zero Rated')], decimal_places=2, max_digits=4)), - ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rows', to='payments.Invoice')), - ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), - ], - ), - migrations.CreateModel( - name='Payment', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('method', models.CharField(choices=[('S', 'Stripe')], max_length=1)), - ('status', models.CharField(choices=[('SUC', 'Successful'), ('FLD', 'Failed'), ('ERR', 'Errored'), ('RFD', 'Refunded'), ('CBK', 'Chargeback')], max_length=3)), - ('charge_id', models.CharField(max_length=80)), - ('charge_created', models.DateTimeField(null=True)), - ('charge_failure_reason', models.CharField(blank=True, max_length=400)), - ('amount', models.DecimalField(decimal_places=2, max_digits=7)), - ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoice', to='payments.Invoice')), - ], - ), - ] diff --git a/payments/migrations/0002_default_on_total.py b/payments/migrations/0002_default_on_total.py deleted file mode 100644 index a47a35a..0000000 --- a/payments/migrations/0002_default_on_total.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.0.2 on 2018-03-18 16:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='invoice', - name='total', - field=models.DecimalField(decimal_places=2, default=0.0, max_digits=7), - ), - ] diff --git a/payments/migrations/0003_items_once_per_invoice.py b/payments/migrations/0003_items_once_per_invoice.py deleted file mode 100644 index b15f2ee..0000000 --- a/payments/migrations/0003_items_once_per_invoice.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.0.2 on 2018-03-18 17:18 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('payments', '0002_default_on_total'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='invoicerow', - unique_together={('invoice', 'object_type', 'object_id')}, - ), - ] diff --git a/payments/migrations/0004_create_update_fields_on_payment.py b/payments/migrations/0004_create_update_fields_on_payment.py deleted file mode 100644 index e97e0cd..0000000 --- a/payments/migrations/0004_create_update_fields_on_payment.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 2.0.2 on 2018-03-18 18:18 - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0003_items_once_per_invoice'), - ] - - operations = [ - migrations.RemoveField( - model_name='payment', - name='charge_created', - ), - migrations.AddField( - model_name='payment', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='payment', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.AlterField( - model_name='payment', - name='invoice', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.Invoice'), - ), - ] From 3bf58278a12d6cfa3bf4d0c970abe6026978ffd8 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 13:14:23 +0100 Subject: [PATCH 34/66] Rename migration to remove order --- .../{0012_auto_20180328_1133.py => 0012_remove_order.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tickets/migrations/{0012_auto_20180328_1133.py => 0012_remove_order.py} (100%) diff --git a/tickets/migrations/0012_auto_20180328_1133.py b/tickets/migrations/0012_remove_order.py similarity index 100% rename from tickets/migrations/0012_auto_20180328_1133.py rename to tickets/migrations/0012_remove_order.py From 5c2f5b2d3751b2983bf37d071106c357a93f1e11 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 13:24:53 +0100 Subject: [PATCH 35/66] Change conference days --- grants/forms.py | 4 +- grants/migrations/0003_change_days.py | 23 +++++++++ grants/models.py | 4 +- grants/tests/factories.py | 4 +- grants/tests/test_views.py | 8 ++-- ironcage/tests/test_views.py | 2 +- reports/tests.py | 20 ++++---- tickets/constants.py | 4 +- tickets/forms.py | 4 +- tickets/migrations/0013_change_days.py | 23 +++++++++ tickets/models.py | 4 +- tickets/tests/factories.py | 14 +++--- tickets/tests/test_actions.py | 64 +++++++++++++------------- tickets/tests/test_forms.py | 16 +++---- tickets/tests/test_views.py | 18 ++++---- 15 files changed, 129 insertions(+), 83 deletions(-) create mode 100644 grants/migrations/0003_change_days.py create mode 100644 tickets/migrations/0013_change_days.py diff --git a/grants/forms.py b/grants/forms.py index d294914..c5d30bd 100644 --- a/grants/forms.py +++ b/grants/forms.py @@ -6,11 +6,11 @@ DAY_CHOICES = [ - ('thu', 'Thursday'), - ('fri', 'Friday'), ('sat', 'Saturday'), ('sun', 'Sunday'), ('mon', 'Monday'), + ('tue', 'Tuesday'), + ('wed', 'Wednesday'), ] diff --git a/grants/migrations/0003_change_days.py b/grants/migrations/0003_change_days.py new file mode 100644 index 0000000..ee16d76 --- /dev/null +++ b/grants/migrations/0003_change_days.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.2 on 2018-03-31 12:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('grants', '0002_auto_20170827_1856'), + ] + + operations = [ + migrations.RenameField( + model_name='application', + old_name='fri', + new_name='tue', + ), + migrations.RenameField( + model_name='application', + old_name='thu', + new_name='wed', + ), + ] diff --git a/grants/models.py b/grants/models.py index 7c4b35f..ec93ba0 100644 --- a/grants/models.py +++ b/grants/models.py @@ -12,11 +12,11 @@ class Application(models.Model): applicant = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='grant_application', on_delete=models.CASCADE) amount_requested = models.IntegerField() would_like_ticket_set_aside = models.BooleanField() - thu = models.BooleanField() - fri = models.BooleanField() sat = models.BooleanField() sun = models.BooleanField() mon = models.BooleanField() + tue = models.BooleanField() + wed = models.BooleanField() about_you = models.TextField() amount_offered = models.IntegerField(default=0) requested_ticket_only = models.BooleanField(default=False) diff --git a/grants/tests/factories.py b/grants/tests/factories.py index 307ee76..08203a6 100644 --- a/grants/tests/factories.py +++ b/grants/tests/factories.py @@ -11,10 +11,10 @@ def create_application(user=None): applicant=user, amount_requested=1000, would_like_ticket_set_aside=False, - thu=False, - fri=False, sat=True, sun=True, mon=True, + tue=False, + wed=False, about_you='I have two thumbs', ) diff --git a/grants/tests/test_views.py b/grants/tests/test_views.py index 05fe9c8..11da5d3 100644 --- a/grants/tests/test_views.py +++ b/grants/tests/test_views.py @@ -87,7 +87,7 @@ def test_post(self): self.client.force_login(self.alice) form_data = { 'amount_requested': '2000', - 'days': ['fri', 'sat', 'sun', 'mon'], + 'days': ['sat', 'sun', 'mon', 'tue'], 'about_you': 'I have two thumbs', } rsp = self.client.post(f'/grants/applications/{self.application.application_id}/edit/', form_data, follow=True) @@ -96,7 +96,7 @@ def test_post(self): application = self.alice.get_grant_application() application.refresh_from_db() self.assertEqual(application.amount_requested, 2000) - self.assertEqual(application.days(), ['Friday', 'Saturday', 'Sunday', 'Monday']) + self.assertEqual(application.days(), ['Saturday', 'Sunday', 'Monday', 'Tuesday']) def test_get_when_not_authenticated(self): rsp = self.client.get(f'/grants/applications/{self.application.application_id}/edit/') @@ -242,7 +242,7 @@ def test_get_application_edit_with_token(self): def test_post_application_edit(self): form_data = { 'amount_requested': '2000', - 'days': ['fri', 'sat', 'sun', 'mon'], + 'days': ['sat', 'sun', 'mon', 'tue'], 'about_you': 'I have two thumbs', } rsp = self.client.post(f'/grants/applications/{self.application.application_id}/edit/', form_data, follow=True) @@ -252,7 +252,7 @@ def test_post_application_edit(self): def test_post_application_edit_with_token(self): form_data = { 'amount_requested': '2000', - 'days': ['fri', 'sat', 'sun', 'mon'], + 'days': ['sat', 'sun', 'mon', 'tue'], 'about_you': 'I have two thumbs', } rsp = self.client.post(f'/grants/applications/{self.application.application_id}/edit/?deadline-bypass-token=abc123', form_data, follow=True) diff --git a/ironcage/tests/test_views.py b/ironcage/tests/test_views.py index 86b1fac..30dda62 100644 --- a/ironcage/tests/test_views.py +++ b/ironcage/tests/test_views.py @@ -28,7 +28,7 @@ def test_when_has_ticket(self): ticket = ticket_factories.create_ticket(self.alice) rsp = self.client.get('/') - self.assertContains(rsp, 'You have a ticket for Thursday, Friday, Saturday') + self.assertContains(rsp, 'You have a ticket for Saturday, Sunday, Monday') self.assertContains(rsp, f'View your conference ticket', html=True) self.assertContains(rsp, 'Update your profile', html=True) self.assertContains(rsp, 'Your profile is incomplete') diff --git a/reports/tests.py b/reports/tests.py index a8e1bdb..9f51703 100644 --- a/reports/tests.py +++ b/reports/tests.py @@ -56,11 +56,11 @@ def test_get_context_data(self): 'title': 'Attendance by day', 'headings': ['Day', 'Individual rate', 'Corporate rate', 'Education rate', 'Free', 'Total'], 'rows': [ - ['Thursday', 3, 2, 0, 0, 5], - ['Friday', 2, 2, 0, 0, 4], - ['Saturday', 2, 1, 0, 0, 3], - ['Sunday', 1, 1, 0, 0, 2], - ['Monday', 1, 0, 0, 0, 1], + ['Saturday', 3, 2, 0, 0, 5], + ['Sunday', 2, 2, 0, 0, 4], + ['Monday', 2, 1, 0, 0, 3], + ['Tuesday', 1, 1, 0, 0, 2], + ['Wednesday', 1, 0, 0, 0, 1], ], } self.assertEqual(report.get_context_data(), expected) @@ -191,9 +191,9 @@ def test_get_context_data(self): 'title': 'All tickets', 'headings': ['ID', 'Rate', 'Ticket holder', 'Days', 'Cost (incl. VAT)', 'Status'], 'rows': [ - [links[0], 'individual', 'Alice', 'Thursday, Friday, Saturday', '£126', 'Assigned'], - [links[1], 'individual', 'bob@example.com', 'Friday, Saturday', '£90', 'Unclaimed'], - [links[2], 'individual', 'carol@example.com', 'Saturday, Sunday', '£90', 'Unclaimed'], + [links[0], 'individual', 'Alice', 'Saturday, Sunday, Monday', '£126', 'Assigned'], + [links[1], 'individual', 'bob@example.com', 'Saturday, Sunday', '£90', 'Unclaimed'], + [links[2], 'individual', 'carol@example.com', 'Sunday, Monday', '£90', 'Unclaimed'], ], } self.assertEqual(report.get_context_data(), expected) @@ -221,8 +221,8 @@ def test_get_context_data(self): 'title': 'Unclaimed tickets', 'headings': ['ID', 'Rate', 'Ticket holder', 'Days', 'Cost (incl. VAT)', 'Status'], 'rows': [ - [links[0], 'individual', 'bob@example.com', 'Friday, Saturday', '£90', 'Unclaimed'], - [links[1], 'individual', 'carol@example.com', 'Saturday, Sunday', '£90', 'Unclaimed'], + [links[0], 'individual', 'bob@example.com', 'Saturday, Sunday', '£90', 'Unclaimed'], + [links[1], 'individual', 'carol@example.com', 'Sunday, Monday', '£90', 'Unclaimed'], ], } self.assertEqual(report.get_context_data(), expected) diff --git a/tickets/constants.py b/tickets/constants.py index cfdfda0..542cd97 100644 --- a/tickets/constants.py +++ b/tickets/constants.py @@ -1,7 +1,7 @@ DAYS = { - 'thu': 'Thursday', - 'fri': 'Friday', 'sat': 'Saturday', 'sun': 'Sunday', 'mon': 'Monday', + 'tue': 'Tuesday', + 'wed': 'Wednesday', } diff --git a/tickets/forms.py b/tickets/forms.py index 350be2a..bec0c7d 100644 --- a/tickets/forms.py +++ b/tickets/forms.py @@ -19,11 +19,11 @@ DAY_CHOICES = [ - ('thu', 'Thursday'), - ('fri', 'Friday'), ('sat', 'Saturday'), ('sun', 'Sunday'), ('mon', 'Monday'), + ('tue', 'Tuesday'), + ('wed', 'Wednesday'), ] diff --git a/tickets/migrations/0013_change_days.py b/tickets/migrations/0013_change_days.py new file mode 100644 index 0000000..b494648 --- /dev/null +++ b/tickets/migrations/0013_change_days.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.2 on 2018-03-31 12:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0012_remove_order'), + ] + + operations = [ + migrations.RenameField( + model_name='ticket', + old_name='fri', + new_name='tue', + ), + migrations.RenameField( + model_name='ticket', + old_name='thu', + new_name='wed', + ), + ] diff --git a/tickets/models.py b/tickets/models.py index 0262a83..c49be3f 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -298,11 +298,11 @@ class Ticket(models.Model): pot = models.CharField(max_length=100, null=True) owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) rate = models.CharField(max_length=40) - thu = models.BooleanField() - fri = models.BooleanField() sat = models.BooleanField() sun = models.BooleanField() mon = models.BooleanField() + tue = models.BooleanField() + wed = models.BooleanField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index 50a6985..77b20b3 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -19,7 +19,7 @@ def create_pending_order_for_self(user=None, rate=None, num_days=None): return actions.create_pending_order( purchaser=user, rate=rate, - days_for_self=['thu', 'fri', 'sat', 'sun', 'mon'][:num_days], + days_for_self=['sat', 'sun', 'mon', 'tue', 'wed'][:num_days], company_details=company_details, ) @@ -31,8 +31,8 @@ def create_pending_order_for_others(user=None, rate=None): purchaser=user, rate=rate, email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -43,10 +43,10 @@ def create_pending_order_for_self_and_others(user=None, rate=None): return actions.create_pending_order( purchaser=user, rate=rate, - days_for_self=['thu', 'fri', 'sat'], + days_for_self=['sat', 'sun', 'mon'], email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -125,5 +125,5 @@ def create_claimed_free_ticket(user, pot='Financial assistance'): def create_completed_free_ticket(user, pot='Financial assistance'): ticket = create_claimed_free_ticket(user, pot) - actions.update_free_ticket(ticket, ['thu', 'fri', 'sat']) + actions.update_free_ticket(ticket, ['sat', 'sun', 'mon']) return ticket diff --git a/tickets/tests/test_actions.py b/tickets/tests/test_actions.py index ebb2ade..50cd126 100644 --- a/tickets/tests/test_actions.py +++ b/tickets/tests/test_actions.py @@ -19,7 +19,7 @@ def test_order_for_self_individual(self): order = actions.create_pending_order( self.alice, 'individual', - days_for_self=['thu', 'fri', 'sat'] + days_for_self=['sat', 'sun', 'mon'] ) self.assertEqual(self.alice.orders.count(), 1) @@ -33,7 +33,7 @@ def test_order_for_self_corporate(self): order = actions.create_pending_order( self.alice, 'corporate', - days_for_self=['thu', 'fri', 'sat'], + days_for_self=['sat', 'sun', 'mon'], company_details={ 'name': 'Sirius Cybernetics Corp.', 'addr': 'Eadrax, Sirius Tau', @@ -54,8 +54,8 @@ def test_order_for_others(self): self.alice, 'individual', email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -70,10 +70,10 @@ def test_order_for_self_and_others(self): order = actions.create_pending_order( self.alice, 'individual', - days_for_self=['thu', 'fri', 'sat'], + days_for_self=['sat', 'sun', 'mon'], email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -91,14 +91,14 @@ def test_order_for_self_to_order_for_self(self): actions.update_pending_order( order, 'individual', - days_for_self=['fri'], + days_for_self=['sat'], ) self.assertEqual(order.status, 'pending') self.assertEqual(order.rate, 'individual') self.assertEqual( order.ticket_details(), - [{'name': 'Alice', 'days': 'Friday', 'cost_incl_vat': 54, 'cost_excl_vat': 45}] + [{'name': 'Alice', 'days': 'Saturday', 'cost_incl_vat': 54, 'cost_excl_vat': 45}] ) def test_order_for_self_to_order_for_others(self): @@ -107,8 +107,8 @@ def test_order_for_self_to_order_for_others(self): order, 'individual', email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -117,8 +117,8 @@ def test_order_for_self_to_order_for_others(self): self.assertEqual( order.ticket_details(), [ - {'name': 'bob@example.com', 'days': 'Friday, Saturday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, - {'name': 'carol@example.com', 'days': 'Saturday, Sunday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, + {'name': 'bob@example.com', 'days': 'Saturday, Sunday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, + {'name': 'carol@example.com', 'days': 'Sunday, Monday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, ] ) @@ -127,10 +127,10 @@ def test_order_for_self_to_order_for_self_and_others(self): actions.update_pending_order( order, 'individual', - days_for_self=['fri', 'sat', 'sun'], + days_for_self=['sat', 'sun', 'mon'], email_addrs_and_days_for_others=[ - ('bob@example.com', ['fri', 'sat']), - ('carol@example.com', ['sat', 'sun']), + ('bob@example.com', ['sat', 'sun']), + ('carol@example.com', ['sun', 'mon']), ] ) @@ -139,9 +139,9 @@ def test_order_for_self_to_order_for_self_and_others(self): self.assertEqual( order.ticket_details(), [ - {'name': 'Alice', 'days': 'Friday, Saturday, Sunday', 'cost_incl_vat': 126, 'cost_excl_vat': 105}, - {'name': 'bob@example.com', 'days': 'Friday, Saturday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, - {'name': 'carol@example.com', 'days': 'Saturday, Sunday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, + {'name': 'Alice', 'days': 'Saturday, Sunday, Monday', 'cost_incl_vat': 126, 'cost_excl_vat': 105}, + {'name': 'bob@example.com', 'days': 'Saturday, Sunday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, + {'name': 'carol@example.com', 'days': 'Sunday, Monday', 'cost_incl_vat': 90, 'cost_excl_vat': 75}, ] ) @@ -150,7 +150,7 @@ def test_individual_order_to_corporate_order(self): actions.update_pending_order( order, 'corporate', - days_for_self=['fri', 'sat', 'sun'], + days_for_self=['sat', 'sun', 'mon'], company_details={ 'name': 'Sirius Cybernetics Corp.', 'addr': 'Eadrax, Sirius Tau', @@ -164,7 +164,7 @@ def test_individual_order_to_corporate_order(self): self.assertEqual( order.ticket_details(), [ - {'name': 'Alice', 'days': 'Friday, Saturday, Sunday', 'cost_incl_vat': 252, 'cost_excl_vat': 210}, + {'name': 'Alice', 'days': 'Saturday, Sunday, Monday', 'cost_incl_vat': 252, 'cost_excl_vat': 210}, ] ) @@ -173,7 +173,7 @@ def test_corporate_order_to_individual_order(self): actions.update_pending_order( order, 'individual', - days_for_self=['fri', 'sat', 'sun'], + days_for_self=['sat', 'sun', 'mon'], ) self.assertEqual(order.status, 'pending') @@ -183,7 +183,7 @@ def test_corporate_order_to_individual_order(self): self.assertEqual( order.ticket_details(), [ - {'name': 'Alice', 'days': 'Friday, Saturday, Sunday', 'cost_incl_vat': 126, 'cost_excl_vat': 105}, + {'name': 'Alice', 'days': 'Saturday, Sunday, Monday', 'cost_incl_vat': 126, 'cost_excl_vat': 105}, ] ) @@ -202,7 +202,7 @@ def test_order_for_self(self): self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Thursday', 'Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) self.assertEqual(len(mail.outbox), 1) @@ -219,10 +219,10 @@ def test_order_for_others(self): self.assertIsNone(order.purchaser.get_ticket()) ticket = TicketInvitation.objects.get(email_addr='bob@example.com').ticket - self.assertEqual(ticket.days(), ['Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) ticket = TicketInvitation.objects.get(email_addr='carol@example.com').ticket - self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) + self.assertEqual(ticket.days(), ['Sunday', 'Monday']) self.assertEqual(len(mail.outbox), 3) @@ -239,13 +239,13 @@ def test_order_for_self_and_others(self): self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Thursday', 'Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) ticket = TicketInvitation.objects.get(email_addr='bob@example.com').ticket - self.assertEqual(ticket.days(), ['Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) ticket = TicketInvitation.objects.get(email_addr='carol@example.com').ticket - self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) + self.assertEqual(ticket.days(), ['Sunday', 'Monday']) self.assertEqual(len(mail.outbox), 3) @@ -264,7 +264,7 @@ def test_after_order_marked_as_failed(self): self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Thursday', 'Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) def test_sends_slack_message(self): backend = get_slack_backend() @@ -313,10 +313,10 @@ class UpdateFreeTicketTests(TestCase): def test_update_free_ticket(self): ticket = factories.create_free_ticket() - actions.update_free_ticket(ticket, ['thu', 'fri', 'sat']) + actions.update_free_ticket(ticket, ['sat', 'sun', 'mon']) ticket.refresh_from_db() - self.assertEqual(ticket.days(), ['Thursday', 'Friday', 'Saturday']) + self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) class ProcessStripeChargeTests(TestCase): diff --git a/tickets/tests/test_forms.py b/tickets/tests/test_forms.py index 0d1dad6..fb7563d 100644 --- a/tickets/tests/test_forms.py +++ b/tickets/tests/test_forms.py @@ -13,7 +13,7 @@ def test_is_valid_with_valid_data(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'] }) @@ -28,7 +28,7 @@ def test_is_valid_with_valid_data_and_empty_form(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': '', }) @@ -42,7 +42,7 @@ def test_is_not_valid_with_no_email_addr(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': '', 'form-1-days': ['sat', 'sun', 'mon'] }) @@ -60,7 +60,7 @@ def test_is_not_valid_with_no_days(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', }) @@ -108,7 +108,7 @@ def test_email_addrs_and_days(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'] }) @@ -117,7 +117,7 @@ def test_email_addrs_and_days(self): formset.errors # Trigger full clean self.assertEqual( formset.email_addrs_and_days, - [('test1@example.com', ['thu', 'fri']), ('test2@example.com', ['sat', 'sun', 'mon'])] + [('test1@example.com', ['mon', 'tue']), ('test2@example.com', ['sat', 'sun', 'mon'])] ) def test_email_addrs_and_days_with_valid_data_and_deleted_form(self): @@ -127,7 +127,7 @@ def test_email_addrs_and_days_with_valid_data_and_deleted_form(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': '', 'form-1-DELETE': 'on', }) @@ -136,5 +136,5 @@ def test_email_addrs_and_days_with_valid_data_and_deleted_form(self): formset.errors # Trigger full clean self.assertEqual( formset.email_addrs_and_days, - [('test1@example.com', ['thu', 'fri'])] + [('test1@example.com', ['mon', 'tue'])] ) diff --git a/tickets/tests/test_views.py b/tickets/tests/test_views.py index e4feeab..24870ce 100644 --- a/tickets/tests/test_views.py +++ b/tickets/tests/test_views.py @@ -68,7 +68,7 @@ def test_post_for_self_individual(self): form_data = { 'who': 'self', 'rate': 'individual', - 'days': ['thu', 'fri', 'sat'], + 'days': ['sat', 'sun', 'mon'], # The formset gets POSTed even when order is only for self 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', @@ -85,7 +85,7 @@ def test_post_for_self_corporate(self): form_data = { 'who': 'self', 'rate': 'corporate', - 'days': ['thu', 'fri', 'sat'], + 'days': ['sat', 'sun', 'mon'], 'company_name': 'Sirius Cybernetics Corp.', 'company_addr': 'Eadrax, Sirius Tau', # The formset gets POSTed even when order is only for self @@ -109,7 +109,7 @@ def test_post_for_others(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'], } @@ -121,13 +121,13 @@ def test_post_for_self_and_others(self): form_data = { 'who': 'self and others', 'rate': 'individual', - 'days': ['thu', 'fri', 'sat'], + 'days': ['sat', 'sun', 'mon'], 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'], } @@ -202,7 +202,7 @@ def test_post_for_others(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'], } @@ -222,7 +222,7 @@ def test_post_for_self_and_others(self): 'form-MIN_NUM_FORMS': '1', 'form-MAX_NUM_FORMS': '1000', 'form-0-email_addr': 'test1@example.com', - 'form-0-days': ['thu', 'fri'], + 'form-0-days': ['mon', 'tue'], 'form-1-email_addr': 'test2@example.com', 'form-1-days': ['sat', 'sun', 'mon'], } @@ -530,8 +530,8 @@ def test_post(self): alice = factories.create_user('Alice') ticket = factories.create_completed_free_ticket(alice) self.client.force_login(alice) - rsp = self.client.post(f'/tickets/tickets/{ticket.ticket_id}/edit/', {'days': ['thu', 'fri', 'sat', 'sun']}, follow=True) - self.assertContains(rsp, 'Thursday, Friday, Saturday, Sunday') + rsp = self.client.post(f'/tickets/tickets/{ticket.ticket_id}/edit/', {'days': ['sat', 'sun', 'mon', 'tue']}, follow=True) + self.assertContains(rsp, 'Saturday, Sunday, Monday, Tuesday') self.assertContains(rsp, 'Update your ticket') def test_get_when_not_authenticated(self): From 31be595226cc6e77c23a33c35529eee9168b7c39 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 16:19:41 +0100 Subject: [PATCH 36/66] Improving integrity of data --- tickets/forms.py | 11 +-- tickets/mailer.py | 5 +- .../commands/dumpticketsforbadges.py | 2 +- ...0014_improve_integrity_of_ticket_models.py | 45 ++++++++++++ tickets/models.py | 70 +++++++++++++++---- tickets/tests/factories.py | 8 +-- tickets/tests/test_actions.py | 8 +-- tickets/tests/test_views.py | 22 +++--- 8 files changed, 129 insertions(+), 42 deletions(-) create mode 100644 tickets/migrations/0014_improve_integrity_of_ticket_models.py diff --git a/tickets/forms.py b/tickets/forms.py index bec0c7d..35c5b0a 100644 --- a/tickets/forms.py +++ b/tickets/forms.py @@ -3,6 +3,8 @@ from ironcage.widgets import ButtonsCheckbox, ButtonsRadio, EmailInput +from tickets.models import Ticket + WHO_CHOICES = [ ('self', 'Myself'), @@ -11,13 +13,6 @@ ] -RATE_CHOICES = [ - ('individual', 'Individual'), - ('corporate', 'Corporate'), - ('education', 'Education'), -] - - DAY_CHOICES = [ ('sat', 'Saturday'), ('sun', 'Sunday'), @@ -33,7 +28,7 @@ class TicketForm(forms.Form): widget=ButtonsRadio ) rate = forms.ChoiceField( - choices=RATE_CHOICES, + choices=Ticket.RATE_CHOICES, widget=ButtonsRadio ) diff --git a/tickets/mailer.py b/tickets/mailer.py index 147ad4d..2baa45f 100644 --- a/tickets/mailer.py +++ b/tickets/mailer.py @@ -5,6 +5,7 @@ from django.urls import reverse from ironcage.emails import send_mail +from tickets.models import Ticket INVITATION_TEMPLATE = ''' @@ -38,9 +39,9 @@ def send_invitation_mail(ticket): - invitation = ticket.invitation() + invitation = ticket.invitation url = settings.DOMAIN + invitation.get_absolute_url() - if ticket.order is None: + if ticket.rate == Ticket.FREE: body = FREE_TICKET_INVITATION_TEMPLATE.format(url=url) else: purchaser_name = ticket.order.purchaser.name diff --git a/tickets/management/commands/dumpticketsforbadges.py b/tickets/management/commands/dumpticketsforbadges.py index f158b02..1e96285 100644 --- a/tickets/management/commands/dumpticketsforbadges.py +++ b/tickets/management/commands/dumpticketsforbadges.py @@ -14,7 +14,7 @@ def handle(self, *args, **kwargs): user = ticket.owner if user is None: name = None - email_addr = ticket.invitation().email_addr + email_addr = ticket.invitation.email_addr is_staff = False is_contributor = False else: diff --git a/tickets/migrations/0014_improve_integrity_of_ticket_models.py b/tickets/migrations/0014_improve_integrity_of_ticket_models.py new file mode 100644 index 0000000..0f05ecb --- /dev/null +++ b/tickets/migrations/0014_improve_integrity_of_ticket_models.py @@ -0,0 +1,45 @@ +# Generated by Django 2.0.2 on 2018-03-31 13:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0013_change_days'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='owner', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='ticket', + name='pot', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='ticket', + name='rate', + field=models.CharField(choices=[('INDI', 'Individual'), ('CORP', 'Corporate'), ('EDUC', 'Educational'), ('FREE', 'Free')], max_length=4), + ), + migrations.AlterField( + model_name='ticketinvitation', + name='email_addr', + field=models.EmailField(max_length=254, unique=True), + ), + migrations.AlterField( + model_name='ticketinvitation', + name='status', + field=models.CharField(choices=[('U', 'Unclaimed'), ('C', 'Claimed')], default='U', max_length=1), + ), + migrations.AlterField( + model_name='ticketinvitation', + name='ticket', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to='tickets.Ticket'), + ), + ] diff --git a/tickets/models.py b/tickets/models.py index c49be3f..164b163 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -291,13 +291,27 @@ def company_addr_formatted(self): return ', '.join(lines) else: return None - + INDIVIDUAL = 'INDI' + CORPORATE = 'CORP' + EDUCATION = 'EDUC' + FREE = 'FREE' + + RATE_CHOICES = ( + (INDIVIDUAL, 'Individual'), + (CORPORATE, 'Corporate'), + (EDUCATION, 'Educational'), + (FREE, 'Free'), + ) class Ticket(models.Model): # order = models.ForeignKey(Order, related_name='tickets', null=True, on_delete=models.CASCADE) pot = models.CharField(max_length=100, null=True) owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) rate = models.CharField(max_length=40) + pot = models.CharField(max_length=100, null=True, blank=True) + owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, + on_delete=models.DO_NOTHING, related_name='ticket') + rate = models.CharField(max_length=4, choices=RATE_CHOICES, blank=False, null=False) sat = models.BooleanField() sun = models.BooleanField() mon = models.BooleanField() @@ -310,24 +324,40 @@ class Ticket(models.Model): id_scrambler = Scrambler(2000) class Manager(models.Manager): + @staticmethod + def _check_days(days): + return isinstance(days, list) \ + and len(days) > 0 \ + and all(day in DAYS for day in days) + def get_by_ticket_id_or_404(self, ticket_id): id = self.model.id_scrambler.backward(ticket_id) return get_object_or_404(self.model, pk=id) def create_for_user(self, user, rate, days): + if not self._check_days(days): + raise ValidationError('Please provide at least one day') + day_fields = {day: (day in days) for day in DAYS} return self.create(owner=user, rate=rate, **day_fields) def create_with_invitation(self, email_addr, rate, days): + if not self._check_days(days): + raise ValidationError('Please provide at least one day') + day_fields = {day: (day in days) for day in DAYS} - ticket = self.create(rate=rate, **day_fields) - ticket.invitations.create(email_addr=email_addr) + ticket = self.create(rate=rate, owner=None, **day_fields) + TicketInvitation.objects.create( + ticket=ticket, email_addr=email_addr + ) return ticket def create_free_with_invitation(self, email_addr, pot): days = {day: False for day in DAYS} - ticket = self.create(pot=pot, **days) - ticket.invitations.create(email_addr=email_addr) + ticket = self.create(pot=pot, rate=Ticket.FREE, **days) + TicketInvitation.objects.create( + ticket=ticket, email_addr=email_addr + ) return ticket objects = Manager() @@ -395,10 +425,10 @@ def cost_excl_vat(self): def invitation(self): # This will raise an exception if a ticket has multiple invitations - return self.invitations.get() + return self.invitations.get() if self.invitations.count() else None def is_free_ticket(self): - return self.rate == 'free' + return self.rate == self.FREE # Previously checked for an order being attached def is_incomplete(self): @@ -409,6 +439,13 @@ def update_days(self, days): setattr(self, day, (day in days)) self.save() + @property + def valid(self): + if self.rate == self.FREE: + return True + else: + return False + @property def item_id(self): return self.ticket_id @@ -454,10 +491,19 @@ def cost_excl_vat(self): class TicketInvitation(models.Model): - ticket = models.ForeignKey(Ticket, related_name='invitations', on_delete=models.CASCADE) # This should be a OneToOneField - email_addr = models.EmailField() # This should be unique=True + + UNCLAIMED = 'U' + CLAIMED = 'C' + + STATUS_CHOICES = ( + (UNCLAIMED, 'Unclaimed'), + (CLAIMED, 'Claimed'), + ) + + ticket = models.OneToOneField(Ticket, related_name='invitation', on_delete=models.CASCADE) # This should be a OneToOneField + email_addr = models.EmailField(unique=True) token = models.CharField(max_length=12, unique=True) # An index is automatically created since unique=True - status = models.CharField(max_length=10, default='unclaimed') + status = models.CharField(max_length=1, default=UNCLAIMED, choices=STATUS_CHOICES) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -475,9 +521,9 @@ def get_absolute_url(self): def claim_for_owner(self, owner): # This would fail if owner already has a ticket, as Ticket.owner is a # OneToOneField. - assert self.status == 'unclaimed' + assert self.status == self.UNCLAIMED ticket = self.ticket ticket.owner = owner ticket.save() - self.status = 'claimed' + self.status = self.CLAIMED self.save() diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index 77b20b3..bec560f 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -5,10 +5,10 @@ def create_pending_order_for_self(user=None, rate=None, num_days=None): user = user or create_user() - rate = rate or 'individual' + rate = rate or Ticket.INDIVIDUAL num_days = num_days or 3 - if rate == 'corporate': + if rate == Ticket.CORPORATE: company_details = { 'name': 'Sirius Cybernetics Corp.', 'addr': 'Eadrax, Sirius Tau', @@ -26,7 +26,7 @@ def create_pending_order_for_self(user=None, rate=None, num_days=None): def create_pending_order_for_others(user=None, rate=None): user = user or create_user() - rate = rate or 'individual' + rate = rate or Ticket.INDIVIDUAL return actions.create_pending_order( purchaser=user, rate=rate, @@ -39,7 +39,7 @@ def create_pending_order_for_others(user=None, rate=None): def create_pending_order_for_self_and_others(user=None, rate=None): user = user or create_user() - rate = rate or 'individual' + rate = rate or Ticket.INDIVIDUAL return actions.create_pending_order( purchaser=user, rate=rate, diff --git a/tickets/tests/test_actions.py b/tickets/tests/test_actions.py index 50cd126..6455c3c 100644 --- a/tickets/tests/test_actions.py +++ b/tickets/tests/test_actions.py @@ -305,7 +305,7 @@ def test_create_free_ticket(self): self.assertEqual(ticket.days(), []) self.assertEqual(ticket.pot, 'Financial assistance') - self.assertEqual(ticket.invitation().email_addr, 'alice@example.com') + self.assertEqual(ticket.invitation.email_addr, 'alice@example.com') self.assertEqual(len(mail.outbox), 1) @@ -374,7 +374,7 @@ def test_reassign_assigned_ticket_with_existing_invitation(self): self.assertIsNone(self.alice.get_ticket()) ticket.refresh_from_db() - self.assertEqual(ticket.invitation().status, 'unclaimed') + self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) self.assertEqual(len(mail.outbox), 1) @@ -392,7 +392,7 @@ def test_reassign_assigned_ticket_with_no_existing_invitation(self): self.assertIsNone(alice.get_ticket()) ticket.refresh_from_db() - self.assertEqual(ticket.invitation().status, 'unclaimed') + self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) self.assertEqual(len(mail.outbox), 1) @@ -403,7 +403,7 @@ def test_reassign_unassigned_ticket(self): actions.reassign_ticket(ticket, 'zoe@example.com') ticket.refresh_from_db() - self.assertEqual(ticket.invitation().status, 'unclaimed') + self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) self.assertEqual(len(mail.outbox), 1) diff --git a/tickets/tests/test_views.py b/tickets/tests/test_views.py index 24870ce..01d965e 100644 --- a/tickets/tests/test_views.py +++ b/tickets/tests/test_views.py @@ -7,7 +7,7 @@ from . import factories from tickets import actions -from tickets.models import TicketInvitation +from tickets.models import TicketInvitation, Ticket class NewOrderTests(TestCase): @@ -67,7 +67,7 @@ def test_post_for_self_individual(self): self.client.force_login(self.alice) form_data = { 'who': 'self', - 'rate': 'individual', + 'rate': Ticket.INDIVIDUAL, 'days': ['sat', 'sun', 'mon'], # The formset gets POSTed even when order is only for self 'form-TOTAL_FORMS': '2', @@ -84,7 +84,7 @@ def test_post_for_self_corporate(self): self.client.force_login(self.alice) form_data = { 'who': 'self', - 'rate': 'corporate', + 'rate': Ticket.CORPORATE, 'days': ['sat', 'sun', 'mon'], 'company_name': 'Sirius Cybernetics Corp.', 'company_addr': 'Eadrax, Sirius Tau', @@ -103,7 +103,7 @@ def test_post_for_others(self): self.client.force_login(self.alice) form_data = { 'who': 'others', - 'rate': 'individual', + 'rate': Ticket.INDIVIDUAL, 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', 'form-MIN_NUM_FORMS': '1', @@ -120,7 +120,7 @@ def test_post_for_self_and_others(self): self.client.force_login(self.alice) form_data = { 'who': 'self and others', - 'rate': 'individual', + 'rate': Ticket.INDIVIDUAL, 'days': ['sat', 'sun', 'mon'], 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', @@ -175,7 +175,7 @@ def test_post_for_self(self): self.client.force_login(self.order.purchaser) form_data = { 'who': 'self', - 'rate': 'corporate', + 'rate': Ticket.CORPORATE, 'company_name': 'Sirius Cybernetics Corp.', 'company_addr': 'Eadrax, Sirius Tau', 'days': ['fri', 'sat', 'sun'], @@ -194,7 +194,7 @@ def test_post_for_others(self): self.client.force_login(self.order.purchaser) form_data = { 'who': 'others', - 'rate': 'corporate', + 'rate': Ticket.CORPORATE, 'company_name': 'Sirius Cybernetics Corp.', 'company_addr': 'Eadrax, Sirius Tau', 'form-TOTAL_FORMS': '2', @@ -213,7 +213,7 @@ def test_post_for_self_and_others(self): self.client.force_login(self.order.purchaser) form_data = { 'who': 'self and others', - 'rate': 'corporate', + 'rate': Ticket.CORPORATE, 'company_name': 'Sirius Cybernetics Corp.', 'company_addr': 'Eadrax, Sirius Tau', 'days': ['fri', 'sat', 'sun'], @@ -492,7 +492,7 @@ def test_complete_free_ticket(self): self.client.force_login(alice) rsp = self.client.get(f'/tickets/tickets/{ticket.ticket_id}/', follow=True) self.assertNotContains(rsp, 'Cost (incl. VAT)') - self.assertContains(rsp, 'Thursday, Friday, Saturday') + self.assertContains(rsp, 'Saturday, Sunday, Monday') self.assertContains(rsp, 'Update your ticket') def test_when_not_authenticated(self): @@ -516,7 +516,7 @@ def test_get_incomplete_free_ticket(self): self.client.force_login(alice) rsp = self.client.get(f'/tickets/tickets/{ticket.ticket_id}/edit/', follow=True) self.assertContains(rsp, 'Update my ticket') - self.assertContains(rsp, '', html=True) + self.assertContains(rsp, '', html=True) def test_get_completed_free_ticket(self): alice = factories.create_user('Alice') @@ -524,7 +524,7 @@ def test_get_completed_free_ticket(self): self.client.force_login(alice) rsp = self.client.get(f'/tickets/tickets/{ticket.ticket_id}/edit/', follow=True) self.assertContains(rsp, 'Update my ticket') - self.assertContains(rsp, '', html=True) + self.assertContains(rsp, '', html=True) def test_post(self): alice = factories.create_user('Alice') From 8fc0fc612db6e7da327500bd7c529600c0f13eb2 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 16:20:19 +0100 Subject: [PATCH 37/66] More data integrity --- tickets/tests/factories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index bec560f..1e6a192 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -107,7 +107,7 @@ def create_ticket_with_claimed_invitation(owner=None): order = create_confirmed_order_for_others() ticket = order.all_tickets()[0] owner = owner or create_user() - actions.claim_ticket_invitation(owner, ticket.invitation()) + actions.claim_ticket_invitation(owner, ticket.invitation) return ticket @@ -119,7 +119,7 @@ def create_free_ticket(email_addr=None, pot='Financial assistance'): def create_claimed_free_ticket(user, pot='Financial assistance'): ticket = create_free_ticket(user.email_addr, pot) - actions.claim_ticket_invitation(user, ticket.invitation()) + actions.claim_ticket_invitation(user, ticket.invitation) return ticket From 270c0aa50ecbe0c89656092faeb0555e4de77e79 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 19:08:46 +0100 Subject: [PATCH 38/66] Update Broken and changed URLs and attributes --- .../templates/payments/_invoice_details.html | 2 +- payments/views.py | 2 +- tickets/mailer.py | 16 +-- tickets/tests/test_mailer.py | 12 +- tickets/tests/test_views.py | 124 ++++++++++-------- tickets/urls.py | 1 - 6 files changed, 84 insertions(+), 73 deletions(-) diff --git a/payments/templates/payments/_invoice_details.html b/payments/templates/payments/_invoice_details.html index 5ed85b3..ea0bafb 100644 --- a/payments/templates/payments/_invoice_details.html +++ b/payments/templates/payments/_invoice_details.html @@ -3,7 +3,7 @@ - + diff --git a/payments/views.py b/payments/views.py index b6981a1..542b2c9 100644 --- a/payments/views.py +++ b/payments/views.py @@ -39,7 +39,7 @@ def order(request, invoice_id): context = { 'invoice': invoice, - # 'ticket': ticket, + 'ticket': invoice.tickets()[0], 'stripe_api_key': settings.STRIPE_API_KEY_PUBLISHABLE, } return render(request, 'payments/order.html', context) diff --git a/tickets/mailer.py b/tickets/mailer.py index 2baa45f..37f32cd 100644 --- a/tickets/mailer.py +++ b/tickets/mailer.py @@ -44,7 +44,7 @@ def send_invitation_mail(ticket): if ticket.rate == Ticket.FREE: body = FREE_TICKET_INVITATION_TEMPLATE.format(url=url) else: - purchaser_name = ticket.order.purchaser.name + purchaser_name = ticket.invoice.purchaser.name body = INVITATION_TEMPLATE.format(purchaser_name=purchaser_name, url=url) send_mail( @@ -55,21 +55,21 @@ def send_invitation_mail(ticket): def send_order_confirmation_mail(order): - assert not order.payment_required() + assert not order.payment_required template = get_template('tickets/emails/order-confirmation.txt') context = { 'purchaser_name': order.purchaser.name, - 'num_tickets': order.num_tickets(), - 'tickets_for_others': order.tickets_for_others(), - 'ticket_for_self': order.ticket_for_self(), - 'receipt_url': settings.DOMAIN + reverse('tickets:order_receipt', args=[order.order_id]), + 'num_tickets': len(order.tickets()), + 'tickets_for_others': [ticket for ticket in order.tickets() if ticket.owner != order.purchaser], + 'ticket_for_self': [ticket for ticket in order.tickets() if ticket.owner == order.purchaser], + 'receipt_url': settings.DOMAIN + reverse('payments:payment', args=[order.payments.all()[0].id]), } body_raw = template.render(context) body = re.sub('\n\n\n+', '\n\n', body_raw) send_mail( - f'PyCon UK 2018 order confirmation ({order.order_id})', + f'PyCon UK 2018 order confirmation ({order.item_id})', body, order.purchaser.email_addr, ) @@ -88,7 +88,7 @@ def send_order_refund_mail(order): body = ORDER_REFUND_TEMPLATE.format(purchaser_name=order.purchaser.name) send_mail( - f'PyCon UK 2018 order refund ({order.order_id})', + f'PyCon UK 2018 order refund ({order.item_id})', body, order.purchaser.email_addr, ) diff --git a/tickets/tests/test_mailer.py b/tickets/tests/test_mailer.py index b4d58bc..244361e 100644 --- a/tickets/tests/test_mailer.py +++ b/tickets/tests/test_mailer.py @@ -55,9 +55,9 @@ def test_send_order_confirmation_mail_for_order_for_self(self): email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) self.assertEqual(email.from_email, 'PyCon UK 2018 ') - self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.item_id})') self.assertTrue(re.search(r'You have purchased 1 ticket for PyCon UK 2018', email.body)) - self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) + self.assertTrue(re.search(fr'http://testserver/payments/payment/{order.payment.id}/', email.body)) self.assertFalse(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('We look forward to seeing you in Cardiff', email.body)) @@ -72,9 +72,9 @@ def test_send_order_confirmation_mail_for_order_for_others(self): email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) self.assertEqual(email.from_email, 'PyCon UK 2018 ') - self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.item_id})') self.assertTrue(re.search(r'You have purchased 2 tickets for PyCon UK 2018', email.body)) - self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) + self.assertTrue(re.search(fr'http://testserver/payments/payment/{order.payment.id}/', email.body)) self.assertTrue(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('bob@example.com', email.body)) self.assertTrue(re.search('carol@example.com', email.body)) @@ -91,9 +91,9 @@ def test_send_order_confirmation_mail_for_order_for_self_and_others(self): email = mail.outbox[0] self.assertEqual(email.to, ['alice@example.com']) self.assertEqual(email.from_email, 'PyCon UK 2018 ') - self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.order_id})') + self.assertEqual(email.subject, f'PyCon UK 2018 order confirmation ({order.item_id})') self.assertTrue(re.search(r'You have purchased 3 tickets for PyCon UK 2018', email.body)) - self.assertTrue(re.search(fr'http://testserver/tickets/orders/{order.order_id}/receipt/', email.body)) + self.assertTrue(re.search(fr'http://testserver/payments/payment/{order.payment.id}/', email.body)) self.assertTrue(re.search('Ticket invitations have been sent to the following', email.body)) self.assertTrue(re.search('bob@example.com', email.body)) self.assertTrue(re.search('carol@example.com', email.body)) diff --git a/tickets/tests/test_views.py b/tickets/tests/test_views.py index 01d965e..0c15789 100644 --- a/tickets/tests/test_views.py +++ b/tickets/tests/test_views.py @@ -263,24 +263,27 @@ class OrderTests(TestCase): def test_for_confirmed_order_for_self(self): order = factories.create_confirmed_order_for_self() self.client.force_login(order.purchaser) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n ', html=True) + self.assertContains(rsp, f'\n ', html=True) self.assertNotContains(rsp, '
    ') def test_stripe_failure(self): self.client.force_login(self.order.purchaser) with utils.patched_charge_creation_failure(): rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) @@ -368,37 +375,37 @@ def test_when_already_has_ticket(self): factories.create_confirmed_order_for_self(self.order.purchaser) self.client.force_login(self.order.purchaser) rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) - self.assertRedirects(rsp, f'/tickets/orders/{self.order.order_id}/edit/') + self.assertRedirects(rsp, f'/tickets/orders/{self.order.id}/edit/') self.assertContains(rsp, 'You already have a ticket. Please amend your order. Your card has not been charged.') def test_when_already_paid(self): factories.confirm_order(self.order) self.client.force_login(self.order.purchaser) rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) - self.assertRedirects(rsp, f'/tickets/orders/{self.order.order_id}/') + self.assertRedirects(rsp, f'/payments/orders/{self.order.id}/') self.assertContains(rsp, 'This order has already been paid') def test_when_not_authenticated(self): rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) - self.assertRedirects(rsp, f'/accounts/login/?next=/tickets/orders/{self.order.order_id}/payment/') + self.assertRedirects(rsp, f'/accounts/login/?next=/payments/orders/{self.order.id}/payment/') def test_when_not_authorized(self): bob = factories.create_user('Bob') self.client.force_login(bob) rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) @@ -409,53 +416,58 @@ def test_when_not_authorized(self): class OrderReceiptTests(TestCase): @classmethod def setUpTestData(cls): - cls.order = factories.create_confirmed_order_for_self_and_others() + cls.invoice = factories.create_confirmed_order_for_self_and_others() def test_order_receipt(self): - self.client.force_login(self.order.purchaser) - rsp = self.client.get(f'/tickets/orders/{self.order.order_id}/receipt/', follow=True) - self.assertContains(rsp, f'Receipt for PyCon UK 2018 order {self.order.order_id}') - self.assertContains(rsp, '3 tickets for PyCon UK 2018') - self.assertContains(rsp, '
    ', html=True) - self.assertContains(rsp, '', html=True) - self.assertContains(rsp, '', html=True) - self.assertContains(rsp, '', html=True) + self.client.force_login(self.invoice.purchaser) + + invoice_date = self.invoice.created_at.strftime('%B %d, %Y') + + rsp = self.client.get(f'/payments/payment/{self.invoice.payment.id}/', follow=True) + self.assertContains(rsp, f'Receipt for PyCon UK 2018') + self.assertContains(rsp, f'\n ') + self.assertContains(rsp, f'\n ') + self.assertContains(rsp, f'\n ', html=True) + self.assertContains(rsp, '\n ', html=True) + self.assertContains(rsp, '\n ', html=True) + self.assertContains(rsp, '\n ', html=True) + self.assertContains(rsp, '\n ', html=True) self.assertContains(rsp, ''' - - - - - - + + + + + + ''', html=True) self.assertContains(rsp, ''' - + + - - - - + + + ''', html=True) self.assertContains(rsp, ''' - - - - - - + + + + + + ''', html=True) def test_when_not_authenticated(self): - rsp = self.client.get(f'/tickets/orders/{self.order.order_id}/receipt/', follow=True) - self.assertRedirects(rsp, f'/accounts/login/?next=/tickets/orders/{self.order.order_id}/receipt/') + rsp = self.client.get(f'/payments/payment/{self.invoice.payment.id}/', follow=True) + self.assertRedirects(rsp, f'/accounts/login/?next=/payments/payment/{self.invoice.payment.id}/') def test_when_not_authorized(self): bob = factories.create_user('Bob') self.client.force_login(bob) - rsp = self.client.get(f'/tickets/orders/{self.order.order_id}/receipt/', follow=True) + rsp = self.client.get(f'/payments/payment/{self.invoice.payment.id}/', follow=True) self.assertRedirects(rsp, '/') self.assertContains(rsp, 'Only the purchaser of an order can view the receipt') diff --git a/tickets/urls.py b/tickets/urls.py index 4fb5c07..4b7371f 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -8,7 +8,6 @@ urlpatterns = [ url(r'^orders/new/$', views.new_order, name='new_order'), url(r'^orders/(?P\w+)/edit/$', payment_views.order_edit, name='order_edit'), - url(r'^orders/(?P\w+)/receipt/$', views.order_receipt, name='order_receipt'), url(r'^tickets/(?P\w+)/$', views.ticket, name='ticket'), url(r'^tickets/(?P\w+)/edit/$', views.ticket_edit, name='ticket_edit'), url(r'^invitations/(?P\w+)/$', views.ticket_invitation, name='ticket_invitation'), From d2980564d00714cf87a9f1aa5a370bf2f785a7a1 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 19:10:51 +0100 Subject: [PATCH 39/66] Change stripe charge creation patch --- ironcage/tests/utils.py | 4 +++- tickets/tests/factories.py | 3 ++- tickets/tests/test_views.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ironcage/tests/utils.py b/ironcage/tests/utils.py index 3a3105c..aadfe0c 100644 --- a/ironcage/tests/utils.py +++ b/ironcage/tests/utils.py @@ -9,10 +9,12 @@ @contextmanager -def patched_charge_creation_success(): +def patched_charge_creation_success(amount_in_pence): path = os.path.join(settings.BASE_DIR, 'tickets', 'tests', 'data', 'stripe_charge_success.json') with open(path) as f: charge_data = json.load(f) + if amount_in_pence: + charge_data['amount'] = amount_in_pence charge = stripe.Charge.construct_from(charge_data, settings.STRIPE_API_KEY_PUBLISHABLE) with patch('stripe.Charge.create') as mock: mock.return_value = charge diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index 1e6a192..d523677 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -52,7 +52,8 @@ def create_pending_order_for_self_and_others(user=None, rate=None): def confirm_order(order): - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) + with utils.patched_charge_creation_success(order.total_pence_inc_vat): + return payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') def mark_order_as_failed(order): diff --git a/tickets/tests/test_views.py b/tickets/tests/test_views.py index 0c15789..ea9e117 100644 --- a/tickets/tests/test_views.py +++ b/tickets/tests/test_views.py @@ -348,7 +348,7 @@ def setUpTestData(cls): def test_stripe_success(self): self.client.force_login(self.order.purchaser) - with utils.patched_charge_creation_success(): + with utils.patched_charge_creation_success(self.order.total_pence_inc_vat): rsp = self.client.post( f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, From 10e05f775785668f7028e5ea44ef2032f8e59433 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 19:12:30 +0100 Subject: [PATCH 40/66] More data integrity changes --- tickets/prices.py | 8 ++++---- tickets/tests/test_actions.py | 18 +++++++++--------- tickets/views.py | 12 ++++++------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tickets/prices.py b/tickets/prices.py index feeee8e..53e7b42 100644 --- a/tickets/prices.py +++ b/tickets/prices.py @@ -1,17 +1,17 @@ PRICES_EXCL_VAT = { - 'free': { + 'FREE': { 'ticket_price': 0, 'day_price': 0, }, - 'individual': { + 'INDI': { 'ticket_price': 15, 'day_price': 30, }, - 'corporate': { + 'CORP': { 'ticket_price': 30, 'day_price': 60, }, - 'education': { + 'EDUC': { 'ticket_price': 5, 'day_price': 10, }, diff --git a/tickets/tests/test_actions.py b/tickets/tests/test_actions.py index 6455c3c..7e5199a 100644 --- a/tickets/tests/test_actions.py +++ b/tickets/tests/test_actions.py @@ -7,7 +7,7 @@ from ironcage.tests import utils from tickets import actions -from tickets.models import TicketInvitation +from tickets.models import TicketInvitation, Ticket class CreatePendingOrderTests(TestCase): @@ -18,7 +18,7 @@ def setUpTestData(cls): def test_order_for_self_individual(self): order = actions.create_pending_order( self.alice, - 'individual', + Ticket.INDIVIDUAL, days_for_self=['sat', 'sun', 'mon'] ) @@ -27,12 +27,12 @@ def test_order_for_self_individual(self): self.assertEqual(order.purchaser, self.alice) self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, 'individual') + self.assertEqual(order.rate, Ticket.INDIVIDUAL) def test_order_for_self_corporate(self): order = actions.create_pending_order( self.alice, - 'corporate', + Ticket.CORPORATE, days_for_self=['sat', 'sun', 'mon'], company_details={ 'name': 'Sirius Cybernetics Corp.', @@ -45,14 +45,14 @@ def test_order_for_self_corporate(self): self.assertEqual(order.purchaser, self.alice) self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, 'corporate') + self.assertEqual(order.rate, Ticket.CORPORATE) self.assertEqual(order.company_name, 'Sirius Cybernetics Corp.') self.assertEqual(order.company_addr, 'Eadrax, Sirius Tau') def test_order_for_others(self): order = actions.create_pending_order( self.alice, - 'individual', + Ticket.INDIVIDUAL, email_addrs_and_days_for_others=[ ('bob@example.com', ['sat', 'sun']), ('carol@example.com', ['sun', 'mon']), @@ -64,12 +64,12 @@ def test_order_for_others(self): self.assertEqual(order.purchaser, self.alice) self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, 'individual') + self.assertEqual(order.rate, Ticket.INDIVIDUAL) def test_order_for_self_and_others(self): order = actions.create_pending_order( self.alice, - 'individual', + Ticket.INDIVIDUAL, days_for_self=['sat', 'sun', 'mon'], email_addrs_and_days_for_others=[ ('bob@example.com', ['sat', 'sun']), @@ -82,7 +82,7 @@ def test_order_for_self_and_others(self): self.assertEqual(order.purchaser, self.alice) self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, 'individual') + self.assertEqual(order.rate, Ticket.INDIVIDUAL) class UpdatePendingOrderTests(TestCase): diff --git a/tickets/views.py b/tickets/views.py index edbde17..1a52bd8 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -48,7 +48,7 @@ def new_order(request): assert False if valid: - if rate == 'corporate': + if rate == Ticket.CORPORATE: valid = company_details_form.is_valid() if valid: company_details = { @@ -189,10 +189,10 @@ def ticket_invitation(request, token): ticket = invitation.ticket - if invitation.status == 'unclaimed': + if invitation.status == TicketInvitation.UNCLAIMED: assert ticket.owner is None actions.claim_ticket_invitation(request.user, invitation) - elif invitation.status == 'claimed': + elif invitation.status == TicketInvitation.CLAIMED: assert ticket.owner is not None messages.info(request, 'This invitation has already been claimed') else: @@ -210,9 +210,9 @@ def _rates_table_data(): data.append(['', 'Individual rate', 'Corporate rate', "Education rate"]) for ix in range(5): num_days = ix + 1 - individual_rate = cost_incl_vat('individual', num_days) - corporate_rate = cost_incl_vat('corporate', num_days) - education_rate = cost_incl_vat('education', num_days) + individual_rate = cost_incl_vat('INDI', num_days) + corporate_rate = cost_incl_vat('CORP', num_days) + education_rate = cost_incl_vat('EDUC', num_days) row = [] if num_days == 1: row.append('1 day') From 4ec3d0379742128711a7b7811fe068fe76117aa6 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 19:15:55 +0100 Subject: [PATCH 41/66] Moving lots of things round to replace old flows --- payments/actions.py | 115 ++++-- payments/stripe_integration.py | 5 +- payments/tests/factories.py | 12 +- payments/tests/test_actions.py | 293 +++++++++++---- tickets/actions.py | 56 ++- tickets/models.py | 628 +++++++++++++++++---------------- tickets/tests/factories.py | 57 ++- tickets/tests/test_actions.py | 90 ++--- tickets/views.py | 19 +- 9 files changed, 766 insertions(+), 509 deletions(-) diff --git a/payments/actions.py b/payments/actions.py index 2f942e9..93d8085 100644 --- a/payments/actions.py +++ b/payments/actions.py @@ -1,78 +1,119 @@ +import structlog + import stripe from django.db import transaction - -from payments.models import Invoice, Payment - from django.db.utils import IntegrityError +from payments.models import ( + CreditNote, + Invoice, + Payment, +) from payments.stripe_integration import create_charge_for_invoice -import structlog +from tickets.mailer import send_order_confirmation_mail, send_invitation_mail +from django_slack import slack_message + logger = structlog.get_logger() -def _create_invoice(purchaser, invoice_to, is_credit): - logger.info('_create_invoice', purchaser=purchaser.id, - invoice_to=invoice_to) +def create_new_invoice(purchaser, company_name=None, company_addr=None): + logger.info('create_new_invoice', purchaser=purchaser.id, + company_name=company_name, company_addr=company_addr) + with transaction.atomic(): return Invoice.objects.create( purchaser=purchaser, - invoice_to=invoice_to, - is_credit=is_credit + company_name=company_name, + company_addr=company_addr, ) -def create_new_invoice(purchaser, invoice_to=None): - logger.info('create_new_invoice', purchaser=purchaser.id, - invoice_to=invoice_to) - invoice_to = invoice_to or purchaser.name - return _create_invoice(purchaser, invoice_to, is_credit=False) - - -def create_new_credit_note(purchaser, invoice_to=None): +def create_new_credit_note(purchaser, invoice, reason, + company_name=None, company_addr=None): logger.info('create_new_credit_note', purchaser=purchaser.id, - invoice_to=invoice_to) - invoice_to = invoice_to or purchaser.name - return _create_invoice(purchaser, invoice_to, is_credit=True) - + invoice=invoice.item_id, reason=reason, + company_name=company_name, company_addr=company_addr) -def confirm_order(order, charge_id, charge_created): - logger.info('confirm_order', invoice=invoice.id, charge_id=charge_id) with transaction.atomic(): - invoice.confirm(charge_id, charge_created) + return CreditNote.objects.create( + purchaser=purchaser, + invoice=invoice, + reason=reason, + company_name=company_name, + company_addr=company_addr, + ) + + +def confirm_invoice(invoice, charge_id, charge_created): + logger.info('confirm_invoice', invoice=invoice.id, charge_id=charge_id) + # with transaction.atomic(): + # This used to actually register the stripe charge in the DB and create tickets + # invoice.confirm(charge_id, charge_created) send_receipt(invoice) send_ticket_invitations(invoice) slack_message('tickets/order_created.slack', {'invoice': invoice}) +def mark_payment_as_failed(invoice, failure_message): + with transaction.atomic(): + return Payment.objects.create( + invoice=invoice, + method=Payment.STRIPE, + status=Payment.FAILED, + charge_failure_reason=failure_message, + ) + + +def mark_payment_as_errored_after_charge(invoice, charge_id, charge_amount): + with transaction.atomic(): + return Payment.objects.create( + invoice=invoice, + method=Payment.STRIPE, + status=Payment.ERRORED, + charge_id=charge_id, + amount=charge_amount/100 + ) + + def process_stripe_charge(invoice, token): - logger.info('process_stripe_charge', invoice=invoice.invoice_id, token=token) + logger.info('process_stripe_charge', invoice=invoice.item_id, token=token) assert invoice.payment_required try: + # This registers the payment we just got with stripe charge = create_charge_for_invoice(invoice, token) - Payment.objects.create( - invoice=invoice, - method=Payment.STRIPE, - status=Payment.SUCCESSFUL, - charge_id=charge.id, - amount=charge.amount/100 - ) - confirm_order(invoice, charge.id, charge.created) + # Then we record it locally + with transaction.atomic(): + payment = Payment.objects.create( + invoice=invoice, + method=Payment.STRIPE, + status=Payment.SUCCESSFUL, + charge_id=charge.id, + amount=charge.amount/100 + ) + + # Then we run any additional tasks + confirm_invoice(invoice, charge.id, charge.created) + + return payment + except stripe.error.CardError as e: - mark_order_as_failed(invoice, e._message) + return mark_payment_as_failed(invoice, e._message) + except IntegrityError: refund_charge(charge.id) - mark_order_as_errored_after_charge(invoice, charge.id) + + mark_payment_as_errored_after_charge(invoice, charge.id. charge.amount) def send_receipt(order): - logger.info('send_receipt', order=order.order_id) + logger.info('send_receipt', order=order.item_id) send_order_confirmation_mail(order) def send_ticket_invitations(order): - logger.info('send_ticket_invitations', order=order.order_id) + logger.info('send_ticket_invitations', order=order.item_id) for ticket in order.unclaimed_tickets(): send_invitation_mail(ticket) diff --git a/payments/stripe_integration.py b/payments/stripe_integration.py index c2c8f8c..7cfc114 100644 --- a/payments/stripe_integration.py +++ b/payments/stripe_integration.py @@ -21,10 +21,11 @@ def create_charge(amount_pence, description, statement_descriptor, token): def create_charge_for_invoice(invoice, token): assert invoice.payment_required + return create_charge( invoice.total_pence_inc_vat, - f'PyCon UK invoice {invoice.invoice_id}', - f'PyCon UK {invoice.invoice_id}', + f'PyCon UK invoice {invoice.item_id}', + f'PyCon UK {invoice.item_id}', token, ) diff --git a/payments/tests/factories.py b/payments/tests/factories.py index e94dceb..02d1ff4 100644 --- a/payments/tests/factories.py +++ b/payments/tests/factories.py @@ -11,23 +11,23 @@ ) -def create_invoice(user=None, invoice_to=None): +def create_invoice(user=None, company_name=None, company_addr=None): user = user or create_user() - invoice_to = invoice_to or user.name return actions.create_new_invoice( purchaser=user, - invoice_to=invoice_to + company_name=company_name, + company_addr=company_addr, ) -def create_credit_note(user=None, invoice_to=None): +def create_credit_note(user=None, company_name=None, company_addr=None): user = user or create_user() - invoice_to = invoice_to or user.name return actions.create_new_credit_note( purchaser=user, - invoice_to=invoice_to + company_name=company_name, + company_addr=company_addr, ) diff --git a/payments/tests/test_actions.py b/payments/tests/test_actions.py index f220326..4b61e09 100644 --- a/payments/tests/test_actions.py +++ b/payments/tests/test_actions.py @@ -1,126 +1,281 @@ -from unittest.mock import patch - +from django.core.exceptions import ValidationError from django.test import TestCase from payments import actions +from payments.models import Invoice, CreditNote from payments.tests import factories +from ironcage.tests import utils -class CreateInvoiceTests(TestCase): +class CreateNewInvoiceTests(TestCase): @classmethod def setUpTestData(cls): cls.alice = factories.create_user() - def test_create_invoice(self): - invoice = actions._create_invoice( - self.alice, - 'Alice', - False + def test_create_new_invoice(self): + # arrange + + # act + invoice = actions.create_new_invoice( + self.alice ) + # assert self.assertEqual(self.alice.invoices.count(), 1) self.assertEqual(invoice.purchaser, self.alice) - self.assertEqual(invoice.invoice_to, 'Alice') - self.assertEqual(invoice.is_credit, False) - self.assertEqual(invoice.total, 0) + self.assertEqual(invoice.company_name, None) + self.assertEqual(invoice.company_addr, None) - def test_create_invoice_as_credit(self): - invoice = actions._create_invoice( - self.alice, - 'Alice', - True - ) + self.assertEqual(invoice.total_ex_vat, 0) + self.assertEqual(invoice.total_vat, 0) + self.assertEqual(invoice.total_inc_vat, 0) - self.assertEqual(self.alice.invoices.count(), 1) + self.assertTrue(isinstance(invoice, Invoice)) - self.assertEqual(invoice.purchaser, self.alice) - self.assertEqual(invoice.invoice_to, 'Alice') - self.assertEqual(invoice.is_credit, True) - self.assertEqual(invoice.total, 0) + def test_create_new_invoice_invoiced_to_company(self): + # arrange - def test_create_invoice_invoiced_to_company(self): - invoice = actions._create_invoice( + # act + invoice = actions.create_new_invoice( self.alice, 'My Company Limited', - False + 'My Company House, My Company Lane, Companyland, MY1 1CO', ) + # assert self.assertEqual(self.alice.invoices.count(), 1) self.assertEqual(invoice.purchaser, self.alice) - self.assertEqual(invoice.invoice_to, 'My Company Limited') - self.assertEqual(invoice.is_credit, False) - self.assertEqual(invoice.total, 0) + self.assertEqual(invoice.company_name, 'My Company Limited') + self.assertEqual(invoice.company_addr, 'My Company House, My Company Lane, Companyland, MY1 1CO') + + self.assertEqual(invoice.total_ex_vat, 0) + self.assertEqual(invoice.total_vat, 0) + self.assertEqual(invoice.total_inc_vat, 0) + + self.assertTrue(isinstance(invoice, Invoice)) + + def test_create_new_invoice_invoiced_to_company_but_only_name_provided(self): + # arrange + + # assert + with self.assertRaises(ValidationError): + # act + actions.create_new_invoice( + self.alice, + 'My Company Limited', + ) + + # assert + self.assertEqual(self.alice.invoices.count(), 0) + + def test_create_new_invoice_invoiced_to_company_but_only_address_provided(self): + # arrange + + # assert + with self.assertRaises(ValidationError): + # act + actions.create_new_invoice( + self.alice, + company_addr='My Company House, My Company Lane, Companyland, MY1 1CO', + ) - def test_create_invoice_change_purchaser_name_does_not_change_invoice_to(self): - invoice = actions._create_invoice( + # assert + self.assertEqual(self.alice.invoices.count(), 0) + + def test_create_invoice_change_purchaser_name_does_not_change_invoice(self): + # arrange + invoice = actions.create_new_invoice( self.alice, - 'Alice', - False + 'My Company Limited', + 'My Company House, My Company Lane, Companyland, MY1 1CO', ) + # act self.alice.name = 'Bob' self.alice.save() + # assert self.assertEqual(self.alice.invoices.count(), 1) self.assertEqual(invoice.purchaser, self.alice) self.assertEqual(invoice.purchaser.name, 'Bob') - self.assertEqual(invoice.invoice_to, 'Alice') - self.assertEqual(invoice.is_credit, False) - self.assertEqual(invoice.total, 0) + self.assertEqual(invoice.company_name, 'My Company Limited') + self.assertEqual(invoice.company_addr, 'My Company House, My Company Lane, Companyland, MY1 1CO') -class CreateNewInvoiceTests(TestCase): +class CreateNewCreditNoteTests(TestCase): @classmethod def setUpTestData(cls): cls.alice = factories.create_user() - - @patch('payments.actions._create_invoice') - def test_create_new_invoice(self, _create_invoice): - actions.create_new_invoice( - self.alice + cls.invoice = factories.create_invoice() + cls.invoice_with_company = factories.create_invoice( + cls.alice, + 'My Company Limited', + 'My Company House, My Company Lane, Companyland, MY1 1CO', ) - _create_invoice.assert_called_once_with( - self.alice, 'Alice', is_credit=False - ) + def test_create_new_credit_note_requires_invoice_and_reason(self): + # arrange - @patch('payments.actions._create_invoice') - def test_create_new_invoice_invoiced_to_company(self, _create_invoice): - actions.create_new_invoice( + # assert + with self.assertRaises(TypeError): + # act + actions.create_new_credit_note( + self.alice, + ) + + # assert + self.assertEqual(self.alice.creditnotes.count(), 0) + + def test_create_new_credit_note_must_have_valid_reason(self): + # arrange + + # assert + with self.assertRaises(ValidationError): + # act + actions.create_new_credit_note( + self.alice, + self.invoice, + 'Not a valid reason' + ) + + # assert + self.assertEqual(self.alice.creditnotes.count(), 0) + + def test_create_new_credit_note(self): + # arrange + + # act + credit_note = actions.create_new_credit_note( self.alice, - 'My Company Limited' + self.invoice, + CreditNote.CREATED_BY_MISTAKE, ) - _create_invoice.assert_called_once_with( - self.alice, 'My Company Limited', is_credit=False - ) + # assert + self.assertEqual(self.alice.creditnotes.count(), 1) + self.assertEqual(credit_note.purchaser, self.alice) + self.assertEqual(credit_note.invoice, self.invoice) + self.assertEqual(credit_note.reason, CreditNote.CREATED_BY_MISTAKE) + self.assertEqual(credit_note.company_name, None) + self.assertEqual(credit_note.company_addr, None) -class CreateNewCreditNoteTests(TestCase): - @classmethod - def setUpTestData(cls): - cls.alice = factories.create_user() + self.assertEqual(credit_note.total_ex_vat, 0) + self.assertEqual(credit_note.total_vat, 0) + self.assertEqual(credit_note.total_inc_vat, 0) - @patch('payments.actions._create_invoice') - def test_create_new_credit_note(self, _create_invoice): - actions.create_new_credit_note( - self.alice - ) + self.assertTrue(isinstance(credit_note, CreditNote)) - _create_invoice.assert_called_once_with( - self.alice, 'Alice', is_credit=True - ) + def test_create_new_credit_note_invoiced_to_company(self): + # arrange - @patch('payments.actions._create_invoice') - def test_create_new_credit_note_invoiced_to_company(self, _create_invoice): - actions.create_new_credit_note( + # act + credit_note = actions.create_new_credit_note( self.alice, - 'My Company Limited' + self.invoice, + CreditNote.CREATED_BY_MISTAKE, + 'My Company Limited', + 'My Company House, My Company Lane, Companyland, MY1 1CO', ) - _create_invoice.assert_called_once_with( - self.alice, 'My Company Limited', is_credit=True + # assert + self.assertEqual(self.alice.creditnotes.count(), 1) + + self.assertEqual(credit_note.purchaser, self.alice) + self.assertEqual(credit_note.invoice, self.invoice) + self.assertEqual(credit_note.reason, CreditNote.CREATED_BY_MISTAKE) + self.assertEqual(credit_note.company_name, 'My Company Limited') + self.assertEqual(credit_note.company_addr, 'My Company House, My Company Lane, Companyland, MY1 1CO') + + self.assertEqual(credit_note.total_ex_vat, 0) + self.assertEqual(credit_note.total_vat, 0) + self.assertEqual(credit_note.total_inc_vat, 0) + + self.assertTrue(isinstance(credit_note, CreditNote)) + + def test_create_new_credit_note_invoiced_to_company_but_only_name_provided(self): + # arrange + + # assert + with self.assertRaises(ValidationError): + # act + actions.create_new_credit_note( + self.alice, + self.invoice, + CreditNote.CREATED_BY_MISTAKE, + 'My Company Limited', + ) + + # assert + self.assertEqual(self.alice.creditnotes.count(), 0) + + def test_create_new_credit_note_invoiced_to_company_but_only_address_provided(self): + # arrange + + # assert + with self.assertRaises(ValidationError): + # act + actions.create_new_credit_note( + self.alice, + self.invoice, + CreditNote.CREATED_BY_MISTAKE, + company_addr='My Company House, My Company Lane, Companyland, MY1 1CO', + ) + + # assert + self.assertEqual(self.alice.creditnotes.count(), 0) + + def test_create_invoice_change_purchaser_name_does_not_change_invoice(self): + # arrange + credit_note = actions.create_new_credit_note( + self.alice, + self.invoice, + CreditNote.CREATED_BY_MISTAKE, + 'My Company Limited', + 'My Company House, My Company Lane, Companyland, MY1 1CO', ) + + # act + self.alice.name = 'Bob' + self.alice.save() + + # assert + self.assertEqual(self.alice.creditnotes.count(), 1) + + self.assertEqual(credit_note.purchaser, self.alice) + self.assertEqual(credit_note.purchaser.name, 'Bob') + self.assertEqual(credit_note.company_name, 'My Company Limited') + self.assertEqual(credit_note.company_addr, 'My Company House, My Company Lane, Companyland, MY1 1CO') + + +class ProcessStripeChargeTests(TestCase): + def setUp(self): + self.order = factories.create_pending_order_for_self() + + def test_process_stripe_charge_success(self): + token = 'tok_ abcdefghijklmnopqurstuvwx' + with utils.patched_charge_creation_success(): + actions.process_stripe_charge(self.order, token) + self.order.refresh_from_db() + self.assertEqual(self.order.status, 'successful') + + def test_process_stripe_charge_failure(self): + token = 'tok_ abcdefghijklmnopqurstuvwx' + with utils.patched_charge_creation_failure(): + actions.process_stripe_charge(self.order, token) + self.order.refresh_from_db() + self.assertEqual(self.order.status, 'failed') + + def test_process_stripe_charge_error_after_charge(self): + factories.create_confirmed_order_for_self(self.order.purchaser) + token = 'tok_ abcdefghijklmnopqurstuvwx' + + with utils.patched_charge_creation_success(), utils.patched_refund_creation_expected(): + actions.process_stripe_charge(self.order, token) + + self.order.refresh_from_db() + self.assertEqual(self.order.status, 'errored') + self.assertEqual(self.order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') diff --git a/tickets/actions.py b/tickets/actions.py index 6d9073a..241edac 100644 --- a/tickets/actions.py +++ b/tickets/actions.py @@ -17,23 +17,67 @@ from .mailer import send_invitation_mail, send_order_confirmation_mail, send_order_refund_mail from .models import Ticket #Order, +from payments import actions as payment_actions import structlog logger = structlog.get_logger() -def create_pending_order(purchaser, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): - logger.info('create_pending_order', purchaser=purchaser.id, rate=rate) +def create_ticket(purchaser, rate, days=None): + logger.info('create_ticket', purchaser=purchaser.id, rate=rate, days=days) with transaction.atomic(): - return Order.objects.create_pending( + return Ticket.objects.create_for_user( purchaser, rate, - days_for_self, - email_addrs_and_days_for_others, - company_details=company_details, + days, ) +def create_ticket_with_invitation(email, rate, days=None): + logger.info('create_ticket_with_invitation', email=email, rate=rate, days=days) + with transaction.atomic(): + return Ticket.objects.create_with_invitation( + email, + rate, + days, + ) + + +def create_invoice_with_tickets(user, rate, days_for_self, email_addrs_and_days_for_others, + company_details): + logger.info('create_invoice_with_tickets', purchaser=user.id, rate=rate) + + company_name = company_details['name'] if isinstance(company_details, dict) else None + company_addr = company_details['addr'] if isinstance(company_details, dict) else None + + invoice = payment_actions.create_new_invoice(user, company_name, company_addr) + + if days_for_self: + ticket = create_ticket(user, rate, days_for_self) + invoice.add_item(ticket) + + if email_addrs_and_days_for_others is not None: + for email_addr, days in email_addrs_and_days_for_others: + ticket = create_ticket_with_invitation(email_addr, rate, days) + invoice.add_item(ticket) + + return invoice + + + +def create_pending_order(purchaser, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): + return create_invoice_with_tickets(purchaser, rate, days_for_self, email_addrs_and_days_for_others, company_details) + # logger.info('create_pending_order', purchaser=purchaser.id, rate=rate) + # with transaction.atomic(): + # return Order.objects.create_pending( + # purchaser, + # rate, + # days_for_self, + # email_addrs_and_days_for_others, + # company_details=company_details, + # ) + + def update_pending_order(order, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): logger.info('update_pending_order', order=order.order_id, rate=rate) with transaction.atomic(): diff --git a/tickets/models.py b/tickets/models.py index 164b163..0109f8e 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -7,290 +7,299 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.crypto import get_random_string +from django.core.exceptions import ValidationError +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from ironcage.utils import Scrambler from .constants import DAYS from .prices import cost_excl_vat, cost_incl_vat +from payments.models import InvoiceRow, CreditNoteRow, Invoice + + +# class Order(models.Model): +# purchaser = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='orders', on_delete=models.CASCADE) +# rate = models.CharField(max_length=40) +# company_name = models.CharField(max_length=200, null=True) +# company_addr = models.TextField(null=True) +# status = models.CharField(max_length=10) +# stripe_charge_id = models.CharField(max_length=80) +# stripe_charge_created = models.DateTimeField(null=True) +# stripe_charge_failure_reason = models.CharField(max_length=400, blank=True) +# unconfirmed_details = JSONField() + +# created_at = models.DateTimeField(auto_now_add=True) +# updated_at = models.DateTimeField(auto_now=True) + +# id_scrambler = Scrambler(1000) + +# class Manager(models.Manager): +# def get_by_order_id_or_404(self, order_id): +# id = self.model.id_scrambler.backward(order_id) +# return get_object_or_404(self.model, pk=id) + +# def create_pending(self, purchaser, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): +# assert days_for_self is not None or email_addrs_and_days_for_others is not None + +# if rate == 'corporate': +# assert company_details is not None +# company_name = company_details['name'] +# company_addr = company_details['addr'] +# elif rate in ['individual', 'education']: +# assert company_details is None +# company_name = None +# company_addr = None +# else: +# assert False + +# unconfirmed_details = { +# 'days_for_self': days_for_self, +# 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, +# } + +# return self.create( +# purchaser=purchaser, +# rate=rate, +# company_name=company_name, +# company_addr=company_addr, +# status='pending', +# unconfirmed_details=unconfirmed_details, +# ) + +# objects = Manager() + +# def __str__(self): +# return self.order_id + +# @property +# def order_id(self): +# if self.id is None: +# return None +# return self.id_scrambler.forward(self.id) + +# def get_absolute_url(self): +# return reverse('tickets:order', args=[self.order_id]) + +# def update(self, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): +# assert self.payment_required() +# assert days_for_self is not None or email_addrs_and_days_for_others is not None + +# if rate == 'corporate': +# assert company_details is not None +# self.company_name = company_details['name'] +# self.company_addr = company_details['addr'] +# elif rate in ['individual', 'education']: +# assert company_details is None +# self.company_name = None +# self.company_addr = None +# else: +# assert False + +# self.rate = rate +# self.unconfirmed_details = { +# 'days_for_self': days_for_self, +# 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, +# } +# self.save() + +# def confirm(self, charge_id, charge_created): +# assert self.payment_required() + +# days_for_self = self.unconfirmed_details['days_for_self'] +# if days_for_self is not None: +# self.tickets.create_for_user(self.purchaser, self.rate, days_for_self) + +# email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] +# if email_addrs_and_days_for_others is not None: +# for email_addr, days in email_addrs_and_days_for_others: +# self.tickets.create_with_invitation(email_addr, self.rate, days) + +# self.stripe_charge_id = charge_id +# self.stripe_charge_created = datetime.fromtimestamp(charge_created, tz=timezone.utc) +# self.stripe_charge_failure_reason = '' +# self.status = 'successful' + +# self.save() + +# def mark_as_failed(self, charge_failure_reason): +# self.stripe_charge_failure_reason = charge_failure_reason +# self.status = 'failed' + +# self.save() + +# def march_as_errored_after_charge(self, charge_id): +# self.stripe_charge_id = charge_id +# self.stripe_charge_failure_reason = '' +# self.status = 'errored' + +# self.save() + +# def delete_tickets_and_mark_as_refunded(self): +# self.tickets.all().delete() +# self.status = 'refunded' + +# self.save() + +# def all_tickets(self): +# if self.payment_required(): +# tickets = [] + +# days_for_self = self.unconfirmed_details['days_for_self'] +# if days_for_self is not None: +# ticket = UnconfirmedTicket( +# order=self, +# owner=self.purchaser, +# days=days_for_self, +# ) +# tickets.append(ticket) + +# email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] +# if email_addrs_and_days_for_others is not None: +# for email_addr, days in email_addrs_and_days_for_others: +# ticket = UnconfirmedTicket( +# order=self, +# email_addr=email_addr, +# days=days, +# ) +# tickets.append(ticket) +# return tickets +# else: +# return self.tickets.order_by('id') + +# def form_data(self): +# assert self.payment_required() + +# data = { +# 'rate': self.rate +# } + +# days_for_self = self.unconfirmed_details['days_for_self'] +# email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] + +# if days_for_self is None: +# assert email_addrs_and_days_for_others is not None +# data['who'] = 'others' +# elif email_addrs_and_days_for_others is None: +# assert days_for_self is not None +# data['who'] = 'self' +# else: +# data['who'] = 'self and others' + +# return data + +# def self_form_data(self): +# assert self.payment_required() + +# days_for_self = self.unconfirmed_details['days_for_self'] +# if days_for_self is None: +# return None + +# return {'days': days_for_self} + +# def others_formset_data(self): +# assert self.payment_required() + +# email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] +# if email_addrs_and_days_for_others is None: +# return None + +# data = { +# 'form-TOTAL_FORMS': str(len(email_addrs_and_days_for_others)), +# 'form-INITIAL_FORMS': str(len(email_addrs_and_days_for_others)), +# } + +# for ix, (email_addr, days) in enumerate(email_addrs_and_days_for_others): +# data[f'form-{ix}-email_addr'] = email_addr +# data[f'form-{ix}-days'] = days + +# return data + +# def company_details_form_data(self): +# if self.rate == 'corporate': +# return { +# 'company_name': self.company_name, +# 'company_addr': self.company_addr, +# } +# else: +# return None + +# def ticket_details(self): +# return [ticket.details() for ticket in self.all_tickets()] + +# def ticket_summary(self): +# num_tickets_by_num_days = defaultdict(int) + +# for ticket in self.all_tickets(): +# num_tickets_by_num_days[ticket.num_days()] += 1 + +# summary = [] + +# for ix in range(5): +# num_days = ix + 1 +# if num_tickets_by_num_days[num_days]: +# num_tickets = num_tickets_by_num_days[num_days] +# summary.append({ +# 'num_days': num_days, +# 'num_tickets': num_tickets, +# 'per_item_cost_excl_vat': cost_excl_vat(self.rate, num_days), +# 'per_item_cost_incl_vat': cost_incl_vat(self.rate, num_days), +# 'total_cost_excl_vat': cost_excl_vat(self.rate, num_days) * num_tickets, +# 'total_cost_incl_vat': cost_incl_vat(self.rate, num_days) * num_tickets, +# }) + +# return summary + +# def brief_summary(self): +# summary = f'{self.num_tickets()} {self.rate}-rate ticket' +# if self.num_tickets() > 1: +# summary += 's' +# return summary + +# def cost_excl_vat(self): +# return sum(ticket.cost_excl_vat() for ticket in self.all_tickets()) + +# def cost_incl_vat(self): +# return sum(ticket.cost_incl_vat() for ticket in self.all_tickets()) + +# def vat(self): +# return self.cost_incl_vat() - self.cost_excl_vat() -class Order(models.Model): - purchaser = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='orders', on_delete=models.CASCADE) - rate = models.CharField(max_length=40) - company_name = models.CharField(max_length=200, null=True) - company_addr = models.TextField(null=True) - status = models.CharField(max_length=10) - stripe_charge_id = models.CharField(max_length=80) - stripe_charge_created = models.DateTimeField(null=True) - stripe_charge_failure_reason = models.CharField(max_length=400, blank=True) - unconfirmed_details = JSONField() +# def cost_pence_incl_vat(self): +# return 100 * self.cost_incl_vat() - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - id_scrambler = Scrambler(1000) - - class Manager(models.Manager): - def get_by_order_id_or_404(self, order_id): - id = self.model.id_scrambler.backward(order_id) - return get_object_or_404(self.model, pk=id) - - def create_pending(self, purchaser, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): - assert days_for_self is not None or email_addrs_and_days_for_others is not None - - if rate == 'corporate': - assert company_details is not None - company_name = company_details['name'] - company_addr = company_details['addr'] - elif rate in ['individual', 'education']: - assert company_details is None - company_name = None - company_addr = None - else: - assert False - - unconfirmed_details = { - 'days_for_self': days_for_self, - 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, - } - - return self.create( - purchaser=purchaser, - rate=rate, - company_name=company_name, - company_addr=company_addr, - status='pending', - unconfirmed_details=unconfirmed_details, - ) - - objects = Manager() - - def __str__(self): - return self.order_id - - @property - def order_id(self): - if self.id is None: - return None - return self.id_scrambler.forward(self.id) - - def get_absolute_url(self): - return reverse('tickets:order', args=[self.order_id]) - - def update(self, rate, days_for_self=None, email_addrs_and_days_for_others=None, company_details=None): - assert self.payment_required() - assert days_for_self is not None or email_addrs_and_days_for_others is not None - - if rate == 'corporate': - assert company_details is not None - self.company_name = company_details['name'] - self.company_addr = company_details['addr'] - elif rate in ['individual', 'education']: - assert company_details is None - self.company_name = None - self.company_addr = None - else: - assert False - - self.rate = rate - self.unconfirmed_details = { - 'days_for_self': days_for_self, - 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, - } - self.save() - - def confirm(self, charge_id, charge_created): - assert self.payment_required() - - days_for_self = self.unconfirmed_details['days_for_self'] - if days_for_self is not None: - self.tickets.create_for_user(self.purchaser, self.rate, days_for_self) +# def num_tickets(self): +# return len(self.all_tickets()) - email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] - if email_addrs_and_days_for_others is not None: - for email_addr, days in email_addrs_and_days_for_others: - self.tickets.create_with_invitation(email_addr, self.rate, days) +# def unclaimed_tickets(self): +# return self.tickets.filter(owner=None) - self.stripe_charge_id = charge_id - self.stripe_charge_created = datetime.fromtimestamp(charge_created, tz=timezone.utc) - self.stripe_charge_failure_reason = '' - self.status = 'successful' +# def ticket_for_self(self): +# tickets = [ticket for ticket in self.all_tickets() if ticket.owner == self.purchaser] +# if len(tickets) == 0: +# return None +# elif len(tickets) == 1: +# return tickets[0] +# else: +# assert False - self.save() - - def mark_as_failed(self, charge_failure_reason): - self.stripe_charge_failure_reason = charge_failure_reason - self.status = 'failed' - - self.save() - - def march_as_errored_after_charge(self, charge_id): - self.stripe_charge_id = charge_id - self.stripe_charge_failure_reason = '' - self.status = 'errored' - - self.save() +# def tickets_for_others(self): +# return [ticket for ticket in self.all_tickets() if ticket.owner != self.purchaser] - def delete_tickets_and_mark_as_refunded(self): - self.tickets.all().delete() - self.status = 'refunded' +# def payment_required(self): +# return self.status in ['pending', 'failed'] - self.save() - - def all_tickets(self): - if self.payment_required(): - tickets = [] - - days_for_self = self.unconfirmed_details['days_for_self'] - if days_for_self is not None: - ticket = UnconfirmedTicket( - order=self, - owner=self.purchaser, - days=days_for_self, - ) - tickets.append(ticket) - - email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] - if email_addrs_and_days_for_others is not None: - for email_addr, days in email_addrs_and_days_for_others: - ticket = UnconfirmedTicket( - order=self, - email_addr=email_addr, - days=days, - ) - tickets.append(ticket) - return tickets - else: - return self.tickets.order_by('id') - - def form_data(self): - assert self.payment_required() - - data = { - 'rate': self.rate - } +# def company_addr_formatted(self): +# if self.rate == 'corporate': +# lines = [line.strip(',') for line in self.company_addr.splitlines() if line] +# return ', '.join(lines) +# else: +# return None - days_for_self = self.unconfirmed_details['days_for_self'] - email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] - - if days_for_self is None: - assert email_addrs_and_days_for_others is not None - data['who'] = 'others' - elif email_addrs_and_days_for_others is None: - assert days_for_self is not None - data['who'] = 'self' - else: - data['who'] = 'self and others' - - return data - - def self_form_data(self): - assert self.payment_required() - - days_for_self = self.unconfirmed_details['days_for_self'] - if days_for_self is None: - return None - - return {'days': days_for_self} - - def others_formset_data(self): - assert self.payment_required() - - email_addrs_and_days_for_others = self.unconfirmed_details['email_addrs_and_days_for_others'] - if email_addrs_and_days_for_others is None: - return None - - data = { - 'form-TOTAL_FORMS': str(len(email_addrs_and_days_for_others)), - 'form-INITIAL_FORMS': str(len(email_addrs_and_days_for_others)), - } - for ix, (email_addr, days) in enumerate(email_addrs_and_days_for_others): - data[f'form-{ix}-email_addr'] = email_addr - data[f'form-{ix}-days'] = days - - return data - - def company_details_form_data(self): - if self.rate == 'corporate': - return { - 'company_name': self.company_name, - 'company_addr': self.company_addr, - } - else: - return None - - def ticket_details(self): - return [ticket.details() for ticket in self.all_tickets()] - - def ticket_summary(self): - num_tickets_by_num_days = defaultdict(int) - - for ticket in self.all_tickets(): - num_tickets_by_num_days[ticket.num_days()] += 1 - - summary = [] - - for ix in range(5): - num_days = ix + 1 - if num_tickets_by_num_days[num_days]: - num_tickets = num_tickets_by_num_days[num_days] - summary.append({ - 'num_days': num_days, - 'num_tickets': num_tickets, - 'per_item_cost_excl_vat': cost_excl_vat(self.rate, num_days), - 'per_item_cost_incl_vat': cost_incl_vat(self.rate, num_days), - 'total_cost_excl_vat': cost_excl_vat(self.rate, num_days) * num_tickets, - 'total_cost_incl_vat': cost_incl_vat(self.rate, num_days) * num_tickets, - }) - - return summary - - def brief_summary(self): - summary = f'{self.num_tickets()} {self.rate}-rate ticket' - if self.num_tickets() > 1: - summary += 's' - return summary - - def cost_excl_vat(self): - return sum(ticket.cost_excl_vat() for ticket in self.all_tickets()) - - def cost_incl_vat(self): - return sum(ticket.cost_incl_vat() for ticket in self.all_tickets()) - - def vat(self): - return self.cost_incl_vat() - self.cost_excl_vat() - - def cost_pence_incl_vat(self): - return 100 * self.cost_incl_vat() - - def num_tickets(self): - return len(self.all_tickets()) - - def unclaimed_tickets(self): - return self.tickets.filter(owner=None) - - def ticket_for_self(self): - tickets = [ticket for ticket in self.all_tickets() if ticket.owner == self.purchaser] - if len(tickets) == 0: - return None - elif len(tickets) == 1: - return tickets[0] - else: - assert False - - def tickets_for_others(self): - return [ticket for ticket in self.all_tickets() if ticket.owner != self.purchaser] - - def payment_required(self): - return self.status in ['pending', 'failed'] +class Ticket(models.Model): - def company_addr_formatted(self): - if self.rate == 'corporate': - lines = [line.strip(',') for line in self.company_addr.splitlines() if line] - return ', '.join(lines) - else: - return None INDIVIDUAL = 'INDI' CORPORATE = 'CORP' EDUCATION = 'EDUC' @@ -303,11 +312,6 @@ def company_addr_formatted(self): (FREE, 'Free'), ) -class Ticket(models.Model): - # order = models.ForeignKey(Order, related_name='tickets', null=True, on_delete=models.CASCADE) - pot = models.CharField(max_length=100, null=True) - owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) - rate = models.CharField(max_length=40) pot = models.CharField(max_length=100, null=True, blank=True) owner = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, on_delete=models.DO_NOTHING, related_name='ticket') @@ -380,12 +384,14 @@ def reassign(self, email_addr): self.save() try: - invitation = self.invitation() + invitation = self.invitation invitation.delete() except TicketInvitation.DoesNotExist: pass - self.invitations.create(email_addr=email_addr) + TicketInvitation.objects.create( + ticket=self, email_addr=email_addr + ) def details(self): return { @@ -453,41 +459,49 @@ def item_id(self): @property def invoice_description(self): return 'PyCon UK 2018 Ticket for {} ({})'.format( - self.invitation().email_addr if self.invitation() else self.owner, + self.invitation.email_addr if hasattr(self, 'invitation') else self.owner, ', '.join(self.days()), ) - -class UnconfirmedTicket: - def __init__(self, order, days, owner=None, email_addr=None): - assert owner or email_addr - self.order = order - self.days = [DAYS[day] for day in days] - self.owner = owner - self.email_addr = email_addr - - def details(self): - return { - 'name': self.ticket_holder_name(), - 'days': ', '.join(self.days), - 'cost_excl_vat': self.cost_excl_vat(), - 'cost_incl_vat': self.cost_incl_vat(), - } - - def num_days(self): - return len(self.days) - - def ticket_holder_name(self): - if self.owner: - return self.owner.name - else: - return self.email_addr - - def cost_incl_vat(self): - return cost_incl_vat(self.order.rate, self.num_days()) - - def cost_excl_vat(self): - return cost_excl_vat(self.order.rate, self.num_days()) + @property + def invoice(self): + # TODO: Better + content_type = ContentType.objects.get_for_model(Ticket) + return Invoice.objects.filter( + rows__content_type=content_type, + rows__object_id=self.id + ).first() + +# class UnconfirmedTicket: +# def __init__(self, order, days, owner=None, email_addr=None): +# assert owner or email_addr +# self.order = order +# self.days = [DAYS[day] for day in days] +# self.owner = owner +# self.email_addr = email_addr + +# def details(self): +# return { +# 'name': self.ticket_holder_name(), +# 'days': ', '.join(self.days), +# 'cost_excl_vat': self.cost_excl_vat(), +# 'cost_incl_vat': self.cost_incl_vat(), +# } + +# def num_days(self): +# return len(self.days) + +# def ticket_holder_name(self): +# if self.owner: +# return self.owner.name +# else: +# return self.email_addr + +# def cost_incl_vat(self): +# return cost_incl_vat(self.order.rate, self.num_days()) + +# def cost_excl_vat(self): +# return cost_excl_vat(self.order.rate, self.num_days()) class TicketInvitation(models.Model): diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index d523677..8b9d603 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -1,6 +1,33 @@ from accounts.tests.factories import create_user from tickets import actions +from tickets.models import Ticket +from payments import actions as payment_actions +from ironcage.tests import utils + + +def create_ticket_for_self(user=None, rate=None, num_days=None): + user = user or create_user() + rate = rate or Ticket.INDIVIDUAL + num_days = num_days or 3 + + return actions.create_ticket( + purchaser=user, + rate=rate, + days=['sat', 'sun', 'mon', 'tue', 'wed'][:num_days], + ) + + +def create_ticket_with_unclaimed_invitation_ticket(email=None, rate=None, num_days=None): + email = email or 'example@example.com' + rate = rate or Ticket.INDIVIDUAL + num_days = num_days or 3 + + return actions.create_ticket_with_invitation( + email=email, + rate=rate, + days=['sat', 'sun', 'mon', 'tue', 'wed'][:num_days], + ) def create_pending_order_for_self(user=None, rate=None, num_days=None): @@ -57,29 +84,29 @@ def confirm_order(order): def mark_order_as_failed(order): - actions.mark_order_as_failed(order, 'Your card was declined.') + payment_actions.mark_payment_as_failed(order, 'Your card was declined.') def mark_order_as_errored_after_charge(order): - actions.mark_order_as_errored_after_charge(order, 'ch_abcdefghijklmnopqurstuvw') + payment_actions.mark_payment_as_errored_after_charge(order, 'ch_abcdefghijklmnopqurstuvw', order.total_inc_vat) def create_confirmed_order_for_self(user=None, rate=None, num_days=None): - order = create_pending_order_for_self(user, rate, num_days) - confirm_order(order) - return order + invoice = create_pending_order_for_self(user, rate, num_days) + payment = confirm_order(invoice) + return invoice def create_confirmed_order_for_others(user=None, rate=None): - order = create_pending_order_for_others(user, rate) - confirm_order(order) - return order + invoice = create_pending_order_for_others(user, rate) + payment = confirm_order(invoice) + return invoice def create_confirmed_order_for_self_and_others(user=None, rate=None): - order = create_pending_order_for_self_and_others(user, rate) - confirm_order(order) - return order + invoice = create_pending_order_for_self_and_others(user, rate) + payment = confirm_order(invoice) + return invoice def create_failed_order(user=None, rate=None): @@ -95,13 +122,13 @@ def create_errored_order(user=None, rate=None): def create_ticket(user=None, rate=None, num_days=None): - order = create_confirmed_order_for_self(user, rate, num_days) - return order.all_tickets()[0] + invoice = create_confirmed_order_for_self(user, rate, num_days) + return invoice.tickets()[0] def create_ticket_with_unclaimed_invitation(): - order = create_confirmed_order_for_others() - return order.all_tickets()[0] + invoice = create_confirmed_order_for_others() + return invoice.tickets()[0] def create_ticket_with_claimed_invitation(owner=None): diff --git a/tickets/tests/test_actions.py b/tickets/tests/test_actions.py index 7e5199a..477909c 100644 --- a/tickets/tests/test_actions.py +++ b/tickets/tests/test_actions.py @@ -8,6 +8,8 @@ from tickets import actions from tickets.models import TicketInvitation, Ticket +from payments import actions as payment_actions +from payments.models import Payment class CreatePendingOrderTests(TestCase): @@ -191,14 +193,16 @@ def test_corporate_order_to_individual_order(self): class ConfirmOrderTests(TestCase): def test_order_for_self(self): order = factories.create_pending_order_for_self() - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) - self.assertEqual(order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.stripe_charge_created.timestamp(), 1495355163) - self.assertEqual(order.stripe_charge_failure_reason, '') - self.assertEqual(order.status, 'successful') + with utils.patched_charge_creation_success(): + payment = payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.purchaser.orders.count(), 1) + self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') + self.assertEqual(payment.charge_failure_reason, '') + self.assertEqual(payment.method, Payment.STRIPE) + self.assertEqual(payment.status, Payment.SUCCESSFUL) + + self.assertEqual(order.purchaser.invoices[0].payments.count(), 1) self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() @@ -208,14 +212,16 @@ def test_order_for_self(self): def test_order_for_others(self): order = factories.create_pending_order_for_others() - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) - self.assertEqual(order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.stripe_charge_created.timestamp(), 1495355163) - self.assertEqual(order.stripe_charge_failure_reason, '') - self.assertEqual(order.status, 'successful') + with utils.patched_charge_creation_success(): + payment = payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') + + self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') + self.assertEqual(payment.charge_failure_reason, '') + self.assertEqual(payment.method, Payment.STRIPE) + self.assertEqual(payment.status, Payment.SUCCESSFUL) - self.assertEqual(order.purchaser.orders.count(), 1) + self.assertEqual(order.purchaser.invoices[0].payments.count(), 1) self.assertIsNone(order.purchaser.get_ticket()) ticket = TicketInvitation.objects.get(email_addr='bob@example.com').ticket @@ -228,14 +234,16 @@ def test_order_for_others(self): def test_order_for_self_and_others(self): order = factories.create_pending_order_for_self_and_others() - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) - self.assertEqual(order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.stripe_charge_created.timestamp(), 1495355163) - self.assertEqual(order.stripe_charge_failure_reason, '') - self.assertEqual(order.status, 'successful') + with utils.patched_charge_creation_success(): + payment = payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.purchaser.orders.count(), 1) + self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') + self.assertEqual(payment.charge_failure_reason, '') + self.assertEqual(payment.method, Payment.STRIPE) + self.assertEqual(payment.status, Payment.SUCCESSFUL) + + self.assertEqual(order.purchaser.invoices[0].payments.count(), 1) self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() @@ -253,14 +261,15 @@ def test_after_order_marked_as_failed(self): order = factories.create_pending_order_for_self() actions.mark_order_as_failed(order, 'There was a problem') - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) + with utils.patched_charge_creation_success(): + payment = payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.stripe_charge_created.timestamp(), 1495355163) - self.assertEqual(order.stripe_charge_failure_reason, '') - self.assertEqual(order.status, 'successful') + self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') + self.assertEqual(payment.charge_failure_reason, '') + self.assertEqual(payment.method, Payment.STRIPE) + self.assertEqual(payment.status, Payment.SUCCESSFUL) - self.assertEqual(order.purchaser.orders.count(), 1) + self.assertEqual(order.purchaser.invoices[0].payments.count(), 1) self.assertIsNotNone(order.purchaser.get_ticket()) ticket = order.purchaser.get_ticket() @@ -271,7 +280,8 @@ def test_sends_slack_message(self): order = factories.create_pending_order_for_self() backend.reset_messages() - actions.confirm_order(order, 'ch_abcdefghijklmnopqurstuvw', 1495355163) + with utils.patched_charge_creation_success(): + payment_actions.process_stripe_charge(order, 'ch_abcdefghijklmnopqurstuvw') messages = backend.retrieve_messages() self.assertEqual(len(messages), 1) @@ -319,36 +329,6 @@ def test_update_free_ticket(self): self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) -class ProcessStripeChargeTests(TestCase): - def setUp(self): - self.order = factories.create_pending_order_for_self() - - def test_process_stripe_charge_success(self): - token = 'tok_ abcdefghijklmnopqurstuvwx' - with utils.patched_charge_creation_success(): - actions.process_stripe_charge(self.order, token) - self.order.refresh_from_db() - self.assertEqual(self.order.status, 'successful') - - def test_process_stripe_charge_failure(self): - token = 'tok_ abcdefghijklmnopqurstuvwx' - with utils.patched_charge_creation_failure(): - actions.process_stripe_charge(self.order, token) - self.order.refresh_from_db() - self.assertEqual(self.order.status, 'failed') - - def test_process_stripe_charge_error_after_charge(self): - factories.create_confirmed_order_for_self(self.order.purchaser) - token = 'tok_ abcdefghijklmnopqurstuvwx' - - with utils.patched_charge_creation_success(), utils.patched_refund_creation_expected(): - actions.process_stripe_charge(self.order, token) - - self.order.refresh_from_db() - self.assertEqual(self.order.status, 'errored') - self.assertEqual(self.order.stripe_charge_id, 'ch_abcdefghijklmnopqurstuvw') - - class TicketInvitationTests(TestCase): def test_claim_ticket_invitation(self): factories.create_confirmed_order_for_others() diff --git a/tickets/views.py b/tickets/views.py index 1a52bd8..532725b 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -59,18 +59,13 @@ def new_order(request): company_details = None if valid: - invoice_to = company_details.get('name') if company_details else None - - invoice = payment_actions.create_new_invoice(request.user, invoice_to) - - if days_for_self: - ticket = Ticket.objects.create_for_user(request.user, rate, days_for_self) - invoice.add_item(ticket) - - if email_addrs_and_days_for_others is not None: - for email_addr, days in email_addrs_and_days_for_others: - ticket = Ticket.objects.create_with_invitation(email_addr, rate, days) - invoice.add_item(ticket) + invoice = actions.create_invoice_with_tickets( + request.user, + rate, + days_for_self, + email_addrs_and_days_for_others, + company_details, + ) # self.save() From 164266c2f162134ea045026647724b0f75517c51 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 19:16:15 +0100 Subject: [PATCH 42/66] Payment models (new version) --- payments/migrations/0001_initial.py | 111 ++++++++++++++ payments/models.py | 222 +++++++++++++++++++++------- 2 files changed, 280 insertions(+), 53 deletions(-) create mode 100644 payments/migrations/0001_initial.py diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..5f65af1 --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# Generated by Django 2.0.2 on 2018-03-31 10:32 + +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.query_utils + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CreditNote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_name', models.CharField(max_length=200, null=True)), + ('company_addr', models.TextField(null=True)), + ('total_ex_vat', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=7)), + ('total_vat', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=7)), + ('reason', models.CharField(choices=[('Mistake', 'Order created by mistake'), ('Refunded', 'Purchaser requested refund'), ('Entitled to Free', 'Purchaser entitled to a free ticket'), ('Payment Bounced', 'Payment did not complete'), ('Chargeback', 'Payment chargeback received'), ('Wrong ticket type', 'Ticket booked was of wrong type'), ('Attendance Refused', 'Organisers or venue denied entry'), ('Breach of CoC', 'Organisers removed attendee from conference'), ('Issues with travel Visa', 'Attendee could not get travel visa'), ('Issues with travel', 'Attendee could not arrange travel to conference'), ('Issues with accommodation', 'Attendee could not attend conference due lack of available accommodation')], max_length=30, blank=False, null=False)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CreditNoteRow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('total_ex_vat', models.DecimalField(decimal_places=2, max_digits=7)), + ('vat_rate', models.DecimalField(choices=[(Decimal('20'), 'Standard 20%'), (Decimal('0'), 'Zero Rated')], decimal_places=2, max_digits=4)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('credit_note', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rows', to='payments.CreditNote')), + ], + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_name', models.CharField(max_length=200, null=True)), + ('company_addr', models.TextField(null=True)), + ('total_ex_vat', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=7)), + ('total_vat', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=7)), + ('purchaser', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InvoiceRow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('total_ex_vat', models.DecimalField(decimal_places=2, max_digits=7)), + ('vat_rate', models.DecimalField(choices=[(Decimal('20'), 'Standard 20%'), (Decimal('0'), 'Zero Rated')], decimal_places=2, max_digits=4)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rows', to='payments.Invoice')), + ], + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('method', models.CharField(choices=[('S', 'Stripe')], max_length=1)), + ('status', models.CharField(choices=[('SUC', 'Successful'), ('FLD', 'Failed'), ('ERR', 'Errored'), ('RFD', 'Refunded'), ('CBK', 'Chargeback')], max_length=3)), + ('charge_id', models.CharField(max_length=80)), + ('charge_failure_reason', models.CharField(blank=True, max_length=400)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=7)), + ('content_type', models.ForeignKey(limit_choices_to=django.db.models.query_utils.Q(django.db.models.query_utils.Q(('app_label', 'payments'), ('model', 'invoice'), _connector='AND'), django.db.models.query_utils.Q(('app_label', 'payments'), ('model', 'credit_note'), _connector='AND'), _connector='OR'), on_delete=django.db.models.deletion.DO_NOTHING, related_name='rows', to='contenttypes.ContentType')), + ], + ), + migrations.AddField( + model_name='creditnote', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='credit_note', to='payments.Invoice'), + ), + migrations.AddField( + model_name='creditnote', + name='purchaser', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='creditnotes', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='invoicerow', + unique_together={('invoice', 'content_type', 'object_id')}, + ), + migrations.AlterUniqueTogether( + name='creditnoterow', + unique_together={('credit_note', 'content_type', 'object_id')}, + ), + ] diff --git a/payments/models.py b/payments/models.py index 8ea7c54..4986fdd 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,16 +1,16 @@ from decimal import Decimal from django.conf import settings -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models, transaction from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.urls import reverse - -from ironcage.utils import Scrambler +from django.core.exceptions import ValidationError import structlog + logger = structlog.get_logger() @@ -26,60 +26,64 @@ class ItemNotOnInvoiceException(Exception): pass -class Invoice(models.Model): +class SalesRecord(models.Model): + + CREDIT_RECORD = False + + SEQUENCE_PREFIX = '' created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + company_name = models.CharField(max_length=200, null=True, blank=True) + company_addr = models.TextField(null=True, blank=True) + purchaser = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name='invoices', + related_name='%(class)ss', on_delete=models.PROTECT) - invoice_to = models.TextField() + total_ex_vat = models.DecimalField(max_digits=7, decimal_places=2, + default=Decimal(0.0)) - is_credit = models.BooleanField() + total_vat = models.DecimalField(max_digits=7, decimal_places=2, + default=Decimal(0.0)) - total = models.DecimalField(max_digits=7, decimal_places=2, - default=Decimal(0.0)) + class Meta: + abstract = True + + def clean(self): + if (self.company_name is not None and self.company_addr is None) \ + or (self.company_name is None and self.company_addr is not None): + raise ValidationError('Both company name and company address must be provided.') + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) @property - def invoice_id(self): - return 'IC-2018-{}'.format(self.id) + def item_id(self): + return '{}-{}'.format(self.SEQUENCE_PREFIX, self.id) def _recalculate_total(self): - self.total = Decimal(0) + self.total_ex_vat = Decimal(0) + self.total_vat = Decimal(0) for row in self.rows.all(): - if self.is_credit: - self.total -= row.total_inc_vat + if self.CREDIT_RECORD: + self.total_ex_vat -= row.total_ex_vat + self.total_vat -= row.total_vat else: - self.total += row.total_inc_vat + self.total_ex_vat += row.total_ex_vat + self.total_vat += row.total_vat - self.save() + self.total_ex_vat = round(self.total_ex_vat, 2) + self.total_vat = round(self.total_vat, 2) - @property - def total_ex_vat(self): - total_ex_vat = Decimal(0) - - for row in self.rows.all(): - if self.is_credit: - total_ex_vat -= row.total_ex_vat - else: - total_ex_vat += row.total_ex_vat - - return total_ex_vat + self.save() @property - def total_vat(self): - total_vat = Decimal(0) - - for row in self.rows.all(): - if self.is_credit: - total_vat -= row.total_vat - else: - total_vat += row.total_vat - - return total_vat + def total_inc_vat(self): + return self.total_ex_vat + self.total_vat def get_absolute_url(self): return reverse('payments:order', args=[self.id]) @@ -90,7 +94,7 @@ def get_payment(self): @property def total_pence_inc_vat(self): - return int(100 * self.total) + return int(100 * self.total_inc_vat) def add_item(self, item, vat_rate=STANDARD_RATE_VAT): logger.info('add invoice row', invoice=self.id, item=item.id, @@ -110,7 +114,7 @@ def add_item(self, item, vat_rate=STANDARD_RATE_VAT): with transaction.atomic(): return InvoiceRow.objects.create( invoice=self, - invoice_item=item, + item=item, total_ex_vat=item.cost_excl_vat(), vat_rate=vat_rate ) @@ -148,21 +152,39 @@ def delete_item(self, item): def payment_required(self): return self.payments.count() == 0 + def tickets(self): + from tickets.models import Ticket + tickets = [] + for row in self.rows.all(): + if isinstance(row.item, Ticket): + tickets.append(row.item) + + return tickets + + def unclaimed_tickets(self): + from tickets.models import Ticket, TicketInvitation + tickets = [] + + for row in self.rows.all(): + if isinstance(row.item, Ticket) \ + and hasattr(row.item, 'invitation') \ + and row.item.invitation.status == TicketInvitation.UNCLAIMED: + tickets.append(row.item) -class InvoiceRow(models.Model): + return tickets + + +class SalesRecordRow(models.Model): VAT_RATE_CHOICES = ( (STANDARD_RATE_VAT, 'Standard 20%'), (ZERO_RATE_VAT, 'Zero Rated'), ) - invoice = models.ForeignKey(Invoice, related_name='rows', - on_delete=models.PROTECT) - - object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + content_type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING) object_id = models.PositiveIntegerField() - invoice_item = GenericForeignKey('object_type', 'object_id') + item = GenericForeignKey('content_type', 'object_id') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -173,7 +195,11 @@ class InvoiceRow(models.Model): choices=VAT_RATE_CHOICES) class Meta: - unique_together = ("invoice", "object_type", "object_id") + abstract = True + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) @property def total_inc_vat(self): @@ -186,12 +212,6 @@ def total_vat(self): return self.total_ex_vat * vat_rate_as_percent -@receiver(post_save, sender=InvoiceRow) -@receiver(post_delete, sender=InvoiceRow) -def update_invoice_total(sender, instance, **kwargs): - instance.invoice._recalculate_total() - - class Payment(models.Model): STRIPE = 'S' @@ -214,9 +234,20 @@ class Payment(models.Model): (CHARGEBACK, 'Chargeback'), ) - invoice = models.ForeignKey(Invoice, related_name='payments', + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + invoice = models.ForeignKey(SalesRecord, related_name='payments', on_delete=models.PROTECT) + limit = models.Q(app_label='payments', model='invoice') | \ + models.Q(app_label='payments', model='credit_note') + content_type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING, + limit_choices_to=limit) + object_id = models.PositiveIntegerField() + invoice = GenericForeignKey('content_type', 'object_id') + method = models.CharField(max_length=1, choices=METHOD_CHOICES) status = models.CharField(max_length=3, choices=STATUS_CHOICES) charge_id = models.CharField(max_length=80) @@ -226,3 +257,88 @@ class Payment(models.Model): updated_at = models.DateTimeField(auto_now=True) amount = models.DecimalField(max_digits=7, decimal_places=2) + + +class Invoice(SalesRecord): + + CREDIT_RECORD = False + + SEQUENCE_PREFIX = 'IC-2018' + + payments = GenericRelation(Payment) + + +class CreditNote(SalesRecord): + + CREATED_BY_MISTAKE = 'Mistake' + REFUNDED_ORDER = 'Refunded' + ENTITLED_TO_FREE = 'Entitled to Free' + PAYMENT_BOUNCED = 'Payment Bounced' + CHARGEBACK = 'Chargeback' + WRONG_TICKET_TYPE = 'Wrong ticket type' + ATTENDANCE_REFUSED = 'Attendance Refused' + COC_BREACH = 'Breach of CoC' + VISA_ISSUES = 'Issues with travel Visa' + TRAVEL_ISSUES = 'Issues with travel' + ACCOMMODATION_ISSUES = 'Issues with accommodation' + + REASON_CHOICES = ( + (CREATED_BY_MISTAKE, 'Order created by mistake'), + (REFUNDED_ORDER, 'Purchaser requested refund'), + (ENTITLED_TO_FREE, 'Purchaser entitled to a free ticket'), + (PAYMENT_BOUNCED, 'Payment did not complete'), + (CHARGEBACK, 'Payment chargeback received'), + (WRONG_TICKET_TYPE, 'Ticket booked was of wrong type'), + (ATTENDANCE_REFUSED, 'Organisers or venue denied entry'), + (COC_BREACH, 'Organisers removed attendee from conference'), + (VISA_ISSUES, 'Attendee could not get travel visa'), + (TRAVEL_ISSUES, 'Attendee could not arrange travel to conference'), + (ACCOMMODATION_ISSUES, 'Attendee could not attend conference due lack of available accommodation'), + ) + + CREDIT_RECORD = True + + SEQUENCE_PREFIX = 'CI-2018' + + invoice = models.ForeignKey(Invoice, on_delete=models.DO_NOTHING, + related_name='credit_note') + + reason = models.CharField(max_length=30, choices=REASON_CHOICES, blank=False, null=False) + + payments = GenericRelation(Payment) + + +class InvoiceRow(SalesRecordRow): + + invoice = models.ForeignKey(Invoice, related_name='rows', + on_delete=models.DO_NOTHING) + + class Meta: + unique_together = ("invoice", "content_type", "object_id") + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.invoice._recalculate_total() + + def delete(self, *args, **kwargs): + invoice = self.invoice + super().delete(*args, **kwargs) + invoice._recalculate_total() + + +class CreditNoteRow(SalesRecordRow): + + credit_note = models.ForeignKey(CreditNote, related_name='rows', + on_delete=models.DO_NOTHING) + + class Meta: + unique_together = ("credit_note", "content_type", "object_id") + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.credit_note._recalculate_total() + + def delete(self, *args, **kwargs): + credit_note = self.credit_note + super().delete(*args, **kwargs) + credit_note._recalculate_total() From e2141a207d4d216e90787d4d1fd94357236b5e75 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sat, 31 Mar 2018 20:04:27 +0100 Subject: [PATCH 43/66] Changes to failed stripe payments, and how they are shown on receipts and invoices --- payments/actions.py | 7 +++-- payments/models.py | 6 ++-- .../templates/payments/_invoice_details.html | 20 ++++++++----- payments/templates/payments/order.html | 14 ++++++--- .../templates/payments/payment_receipt.html | 20 ++++--------- payments/tests/test_actions.py | 29 +++++++++--------- payments/urls.py | 2 +- payments/views.py | 30 ++++++++++--------- 8 files changed, 68 insertions(+), 60 deletions(-) diff --git a/payments/actions.py b/payments/actions.py index 93d8085..4e3d396 100644 --- a/payments/actions.py +++ b/payments/actions.py @@ -56,13 +56,15 @@ def confirm_invoice(invoice, charge_id, charge_created): slack_message('tickets/order_created.slack', {'invoice': invoice}) -def mark_payment_as_failed(invoice, failure_message): +def mark_payment_as_failed(invoice, failure_message, charge_id): with transaction.atomic(): return Payment.objects.create( invoice=invoice, method=Payment.STRIPE, status=Payment.FAILED, charge_failure_reason=failure_message, + charge_id=charge_id, + amount=0 ) @@ -100,7 +102,8 @@ def process_stripe_charge(invoice, token): return payment except stripe.error.CardError as e: - return mark_payment_as_failed(invoice, e._message) + charge_id = e.json_body['error']['charge'] + return mark_payment_as_failed(invoice, e._message, charge_id) except IntegrityError: refund_charge(charge.id) diff --git a/payments/models.py b/payments/models.py index 4986fdd..8818a99 100644 --- a/payments/models.py +++ b/payments/models.py @@ -90,7 +90,7 @@ def get_absolute_url(self): def get_payment(self): # TODO: BETTER - return self.payments.first() + return self.payments.first() if self.payments else 0 @property def total_pence_inc_vat(self): @@ -150,7 +150,9 @@ def delete_item(self, item): @property def payment_required(self): - return self.payments.count() == 0 + return self.payments.filter( + status=Payment.SUCCESSFUL + ).count() < 1 def tickets(self): from tickets.models import Ticket diff --git a/payments/templates/payments/_invoice_details.html b/payments/templates/payments/_invoice_details.html index ea0bafb..6669ef1 100644 --- a/payments/templates/payments/_invoice_details.html +++ b/payments/templates/payments/_invoice_details.html @@ -25,37 +25,41 @@ {% endif %} - + - + - +
    Invoice Number{{ invoice.invoice_id }}{{ invoice.item_id }}
    Date{order.item_id}') self.assertNotContains(rsp, '
    ') self.assertContains(rsp, 'View your ticket') def test_for_confirmed_order_for_others(self): order = factories.create_confirmed_order_for_others() self.client.force_login(order.purchaser) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n
    {order.item_id}') self.assertNotContains(rsp, '
    ') self.assertNotContains(rsp, 'View your ticket') def test_for_confirmed_order_for_self_and_others(self): order = factories.create_confirmed_order_for_self_and_others() self.client.force_login(order.purchaser) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n
    {order.item_id}') self.assertNotContains(rsp, '
    ') self.assertContains(rsp, 'View your ticket') @@ -288,8 +291,9 @@ def test_for_pending_order(self): user = factories.create_user(email_addr='alice@example.com') order = factories.create_pending_order_for_self(user) self.client.force_login(user) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n
    {order.item_id}') self.assertContains(rsp, '
    ') self.assertContains(rsp, 'data-amount="12600"') self.assertContains(rsp, 'data-email="alice@example.com"') @@ -299,15 +303,16 @@ def test_for_pending_order_for_self_when_already_has_ticket(self): factories.create_confirmed_order_for_self(user) order = factories.create_pending_order_for_self(user) self.client.force_login(user) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertRedirects(rsp, f'/tickets/orders/{order.order_id}/edit/') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertRedirects(rsp, f'/payments/orders/{order.id}/edit/') self.assertContains(rsp, 'You already have a ticket. Please amend your order.') def test_for_failed_order(self): order = factories.create_failed_order() self.client.force_login(order.purchaser) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n
    {order.item_id}') self.assertContains(rsp, 'Payment for this order failed (Your card was declined.)') self.assertContains(rsp, '
    ') self.assertNotContains(rsp, 'View your ticket') @@ -315,22 +320,23 @@ def test_for_failed_order(self): def test_for_errored_order(self): order = factories.create_errored_order() self.client.force_login(order.purchaser) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertContains(rsp, f'Details of your order ({order.order_id})') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertContains(rsp, f'Details of your order') + self.assertContains(rsp, f'Invoice Number\n
    {order.item_id}') self.assertContains(rsp, 'There was an error creating your order') self.assertNotContains(rsp, '
    ') self.assertNotContains(rsp, 'View your ticket') def test_when_not_authenticated(self): order = factories.create_confirmed_order_for_self() - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) - self.assertRedirects(rsp, f'/accounts/login/?next=/tickets/orders/{order.order_id}/') + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) + self.assertRedirects(rsp, f'/accounts/login/?next=/payments/orders/{order.id}/') def test_when_not_authorized(self): order = factories.create_confirmed_order_for_self() bob = factories.create_user('Bob') self.client.force_login(bob) - rsp = self.client.get(f'/tickets/orders/{order.order_id}/', follow=True) + rsp = self.client.get(f'/payments/orders/{order.id}/', follow=True) self.assertRedirects(rsp, '/') self.assertContains(rsp, 'Only the purchaser of an order can view the order') @@ -344,19 +350,20 @@ def test_stripe_success(self): self.client.force_login(self.order.purchaser) with utils.patched_charge_creation_success(): rsp = self.client.post( - f'/tickets/orders/{self.order.order_id}/payment/', + f'/payments/orders/{self.order.id}/payment/', {'stripeToken': 'tok_abcdefghijklmnopqurstuvwx'}, follow=True, ) + invoice_date = self.invoice.created_at.strftime('%B %d, %Y') self.assertContains(rsp, 'Payment for this order has been received') - self.assertContains(rsp, '
    DateMay 21, 2017Date{invoice_date}DateMay 21, 2017Total (excl. VAT)£255VAT at 20%£51Total (incl. VAT)£306Invoice number{self.invoice.item_id}Payment number{self.invoice.payments.all()[0].id}Date{invoice_date}Total (excl. VAT)£255.00VAT£51.00Total (incl. VAT)£306.00Total received£306.00
    Ticket for 2 days2£75£90£150£1802C62PyCon UK 2018 Ticket for bob@example.com (Saturday, Sunday)1£75.0020%£90.00
    Ticket for 3 daysBEABPyCon UK 2018 Ticket for carol@example.com (Sunday, Monday) 1£105£126£105£126£75.0020%£90.00
    Total£255£3069A19PyCon UK 2018 Ticket for alice-1@example.com (Saturday, Sunday, Monday)1£105.0020%£126.00
    Total (excl. VAT)£{{ invoice.total_ex_vat|floatformat:2 }}£{{ invoice.total_ex_vat|floatformat:2 }}
    VAT£{{ invoice.total_vat|floatformat:2 }}£{{ invoice.total_vat|floatformat:2 }}
    Total (incl. VAT)£{{ invoice.total|floatformat:2 }}£{{ invoice.total_inc_vat|floatformat:2 }}
    -
    +

    Invoice details

    + + - + {% for row in invoice.rows.all %} - - + + + + - + {% endfor %}
    Item ID ItemQuantityTotal ex VAT VAT RateTotalTotal inc VAT
    {{ row.invoice_item.item_id }}{{ row.invoice_item.invoice_description }}{{ row.item.item_id }}{{ row.item.invoice_description }}1£{{ row.total_ex_vat|floatformat:2 }} {{ row.vat_rate|floatformat:0 }}%£{{ row.total_inc_vat|floatformat:2 }}£{{ row.total_inc_vat|floatformat:2 }}
    diff --git a/payments/templates/payments/order.html b/payments/templates/payments/order.html index 2ee86f2..456a00f 100644 --- a/payments/templates/payments/order.html +++ b/payments/templates/payments/order.html @@ -9,7 +9,7 @@

    Details of your order

    {% if invoice.payment_required %}
    -
    + {% csrf_token %} - - - - - Make changes to your invoice -
    - -
    -{% elif not invoice.status == 'errored' %} -
    -
    - View receipt - {% if ticket %} - View your ticket - {% endif %} -
    -
    -{% endif %} - -{% endblock %} diff --git a/tickets/templates/tickets/order_confirm.html b/tickets/templates/tickets/order_confirm.html new file mode 100644 index 0000000..2e6b427 --- /dev/null +++ b/tickets/templates/tickets/order_confirm.html @@ -0,0 +1,38 @@ +{% extends 'ironcage/base.html' %} + +{% block content %} +

    Confirm your order

    + +
    +
    +

    You have ordered {{ number_of_tickets }} ticket{{ number_of_tickets|pluralize }} at the {{ rate }} rate.

    + + {% if company_details %} +

    Invoiced to {{ company_details.name }}

    +

    {{ company_details.addr }}

    + {% endif %} + + {% if details_for_self %} +

    Your ticket

    +

    You have ordered a ticket for yourself for {{ details_for_self.days|length }} day{{ details_for_self.days|pluralize }} ({{ details_for_self.days|join:", " }}) at a cost of £{{ details_for_self.amount|floatformat:2 }} (inc VAT).

    + {% endif %} + + {% if details_for_others %} +

    Tickets for others

    + {% for email, days, price in details_for_others %} +

    You have ordered a ticket for {{ email }} for {{ days|length }} day{{ days|pluralize }} ({{ days|join:", " }}) at a cost of £{{ price|floatformat:2 }} (inc VAT).

    + {% endfor %} + {% endif %} + +

    Order Total

    +

    The order total comes to £{{ order_total|floatformat:2 }} (inc VAT).

    + +
    + {% csrf_token %} + + Make changes +
    +
    +
    + +{% endblock %} diff --git a/tickets/urls.py b/tickets/urls.py index 4b7371f..4e360ba 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -1,13 +1,13 @@ from django.conf.urls import url from . import views -from payments import views as payment_views app_name = 'tickets' urlpatterns = [ url(r'^orders/new/$', views.new_order, name='new_order'), - url(r'^orders/(?P\w+)/edit/$', payment_views.order_edit, name='order_edit'), + url(r'^orders/confirm/$', views.order_confirm, name='order_confirm'), + url(r'^orders/edit/$', views.order_edit, name='order_edit'), url(r'^tickets/(?P\w+)/$', views.ticket, name='ticket'), url(r'^tickets/(?P\w+)/edit/$', views.ticket_edit, name='ticket_edit'), url(r'^invitations/(?P\w+)/$', views.ticket_invitation, name='ticket_invitation'), diff --git a/tickets/views.py b/tickets/views.py index 87389b2..0f9b62e 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from datetime import datetime, timezone from django.conf import settings @@ -13,6 +15,7 @@ from .forms import CompanyDetailsForm, TicketForm, TicketForSelfForm, TicketForOthersFormSet from .models import Ticket, TicketInvitation from .prices import PRICES_INCL_VAT, cost_incl_vat +from .constants import DAYS def new_order(request): @@ -59,15 +62,19 @@ def new_order(request): company_details = None if valid: - invoice = actions.create_invoice_with_tickets( - request.user, - rate, - days_for_self, - email_addrs_and_days_for_others, - company_details, - ) - return redirect(invoice) + request.session['ticket_details'] = { + 'rate': rate, + 'days_for_self': days_for_self, + 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, + 'company_details': company_details, + 'form': form.data, + 'self_form': self_form.data, + 'others_formset': others_formset.data, + 'company_details_form': company_details_form.data, + } + + return redirect('tickets:order_confirm') else: if datetime.now(timezone.utc) > settings.TICKET_SALES_CLOSE_AT: @@ -94,6 +101,145 @@ def new_order(request): return render(request, 'tickets/new_order.html', context) +@login_required +def order_confirm(request): + + details = request.session['ticket_details'] + + if request.method == 'POST': + + print(details) + + invoice = actions.create_invoice_with_tickets( + request.user, + details['rate'], + details['days_for_self'], + details['email_addrs_and_days_for_others'], + details['company_details'], + ) + + return redirect(invoice) + + else: + + RATES = { + 'INDI': 'Individual', + 'CORP': 'Corporate', + 'EDUC': 'Education', + } + + order_total = Decimal() + + number_of_tickets = len(details['email_addrs_and_days_for_others']) + if details['days_for_self']: + number_of_tickets += 1 + + rate = RATES[details['rate']] + + details_for_self = {} + + if details['days_for_self']: + details_for_self['days'] = [DAYS[day] for day in details['days_for_self']] + details_for_self['amount'] = cost_incl_vat(details['rate'], len(details['days_for_self'])) + order_total += details_for_self['amount'] + + details_for_others = [] + for email, days in details['email_addrs_and_days_for_others']: + details_for_others.append(( + email, + [DAYS[day] for day in days], + cost_incl_vat(details['rate'], len(days)) + )) + order_total += cost_incl_vat(details['rate'], len(days)) + + context = { + 'rate': rate, + 'number_of_tickets': number_of_tickets, + 'details_for_self': details_for_self, + 'details_for_others': details_for_others, + 'order_total': order_total, + 'company_details': details['company_details'], + } + + return render(request, 'tickets/order_confirm.html', context) + + +@login_required +def order_edit(request): + + if request.method == 'POST': + form = TicketForm(request.POST) + self_form = TicketForSelfForm(request.POST) + others_formset = TicketForOthersFormSet(request.POST) + company_details_form = CompanyDetailsForm(request.POST) + + if form.is_valid(): + who = form.cleaned_data['who'] + rate = form.cleaned_data['rate'] + + if who == 'self': + valid = self_form.is_valid() + if valid: + days_for_self = self_form.cleaned_data['days'] + email_addrs_and_days_for_others = None + elif who == 'others': + valid = others_formset.is_valid() + if valid: + days_for_self = None + email_addrs_and_days_for_others = others_formset.email_addrs_and_days + elif who == 'self and others': + valid = self_form.is_valid() and others_formset.is_valid() + if valid: + days_for_self = self_form.cleaned_data['days'] + email_addrs_and_days_for_others = others_formset.email_addrs_and_days + else: + assert False + + if valid: + if rate == 'corporate': + valid = company_details_form.is_valid() + if valid: + company_details = { + 'name': company_details_form.cleaned_data['company_name'], + 'addr': company_details_form.cleaned_data['company_addr'], + } + else: + company_details = None + + if valid: + request.session['ticket_details'] = { + 'rate': rate, + 'days_for_self': days_for_self, + 'email_addrs_and_days_for_others': email_addrs_and_days_for_others, + 'company_details': company_details, + 'form': form.data, + 'self_form': self_form.data, + 'others_formset': others_formset.data, + 'company_details_form': company_details_form.data, + } + + return redirect('tickets:order_confirm') + + else: + form = TicketForm(request.session['ticket_details']['form']) + self_form = TicketForSelfForm(request.session['ticket_details']['self_form']) + others_formset = TicketForOthersFormSet(request.session['ticket_details']['others_formset']) + company_details_form = CompanyDetailsForm(request.session['ticket_details']['company_details_form']) + + context = { + 'form': form, + 'self_form': self_form, + 'others_formset': others_formset, + 'company_details_form': company_details_form, + 'user_can_buy_for_self': not request.user.get_ticket(), + 'rates_table_data': _rates_table_data(), + 'rates_data': _rates_data(), + 'js_paths': ['tickets/order_form.js'], + } + + return render(request, 'tickets/order_edit.html', context) + + @login_required def ticket(request, ticket_id): ticket = Ticket.objects.get_by_ticket_id_or_404(ticket_id) From 9e0e3a645f98da780c86aee8522903f3d8ef5637 Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 1 Apr 2018 14:41:18 +0100 Subject: [PATCH 65/66] Action tests and other fixes --- ironcage/templates/ironcage/index.html | 4 +- tickets/tests/factories.py | 2 +- tickets/tests/test_actions.py | 356 ++++++++----------------- 3 files changed, 112 insertions(+), 250 deletions(-) diff --git a/ironcage/templates/ironcage/index.html b/ironcage/templates/ironcage/index.html index ab82d00..531e5cc 100644 --- a/ironcage/templates/ironcage/index.html +++ b/ironcage/templates/ironcage/index.html @@ -25,7 +25,7 @@
  • Order conference tickets
  • {% endif %} {% elif orders|length == 1 %} -
  • View your order
  • +
  • View {% if not orders.0.successful_payment %}and pay for{% endif %} your order
  • {% if ticket_sales_open %}
  • Order more conference tickets
  • {% endif %} @@ -33,7 +33,7 @@
  • View your orders:
  • {% if ticket_sales_open %} diff --git a/tickets/tests/factories.py b/tickets/tests/factories.py index 97812b4..9f3f7de 100644 --- a/tickets/tests/factories.py +++ b/tickets/tests/factories.py @@ -133,7 +133,7 @@ def create_ticket_with_unclaimed_invitation(): def create_ticket_with_claimed_invitation(owner=None): order = create_paid_order_for_others() - ticket = order.all_tickets()[0] + ticket = order.tickets()[0] owner = owner or create_user() actions.claim_ticket_invitation(owner, ticket.invitation) return ticket diff --git a/tickets/tests/test_actions.py b/tickets/tests/test_actions.py index 2c11aa1..e8d3427 100644 --- a/tickets/tests/test_actions.py +++ b/tickets/tests/test_actions.py @@ -1,38 +1,91 @@ -from django_slack.utils import get_backend as get_slack_backend +from unittest.mock import patch, MagicMock -from django.core import mail from django.test import TestCase -from . import factories -from ironcage.tests import utils - +from payments.models import Invoice from tickets import actions -from tickets.models import TicketInvitation, Ticket -from payments import actions as payment_actions -from payments.models import Payment +from tickets.models import Ticket +from tickets.tests import factories + + +class CreateTicketTests(TestCase): + + def setUp(self): + self.user = factories.create_user() + + @patch('tickets.models.Ticket.objects.create_for_user') + def test_create_individual_ticket(self, create_for_user): + # arrange + + # act + actions.create_ticket(self.user, Ticket.INDIVIDUAL, days=['sat', 'sun', 'mon']) + + # assert + create_for_user.assert_called_once_with( + self.user, Ticket.INDIVIDUAL, ['sat', 'sun', 'mon'] + ) + + +class CreateTicketWithInvitationTests(TestCase): + + def setUp(self): + self.user = factories.create_user() + @patch('tickets.models.Ticket.objects.create_with_invitation') + def test_create_ticket_with_invitation(self, create_with_invitation): + # arrange -class CreatePendingOrderTests(TestCase): + # act + actions.create_ticket_with_invitation(self.user, Ticket.CORPORATE, days=['sat', 'sun', 'mon']) + + # assert + create_with_invitation.assert_called_once_with( + self.user, Ticket.CORPORATE, ['sat', 'sun', 'mon'] + ) + + +class CreateInvoiceWithTicketsTests(TestCase): @classmethod def setUpTestData(cls): cls.alice = factories.create_user() - def test_order_for_self_individual(self): - order = actions.create_invoice_with_tickets( + def setUp(self): + self.invoice_mock = MagicMock() + self.create_invoice_patch = patch('payments.actions.create_new_invoice', + return_value=self.invoice_mock) + self.create_invoice = self.create_invoice_patch.start() + + def tearDown(self): + self.create_invoice_patch.stop() + + @patch('tickets.actions.create_ticket') + @patch('tickets.actions.create_ticket_with_invitation') + @patch.object(Invoice, 'add_item') + def test_order_for_self_individual(self, add_item, create_ticket_with_invite, + create_ticket): + # arrange + + # act + actions.create_invoice_with_tickets( self.alice, Ticket.INDIVIDUAL, days_for_self=['sat', 'sun', 'mon'] ) - self.assertEqual(self.alice.orders.count(), 1) - self.assertIsNone(self.alice.get_ticket()) + # assert + self.create_invoice.assert_called_once_with(self.alice, None, None) + create_ticket.assert_called_once_with(self.alice, Ticket.INDIVIDUAL, ['sat', 'sun', 'mon']) + self.invoice_mock.add_item.assert_called_once() + create_ticket_with_invite.assert_not_called() - self.assertEqual(order.purchaser, self.alice) - self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, Ticket.INDIVIDUAL) + @patch('tickets.actions.create_ticket') + @patch('tickets.actions.create_ticket_with_invitation') + def test_order_for_self_corporate(self, create_ticket_with_invite, + create_ticket): + # arrange - def test_order_for_self_corporate(self): - order = actions.create_invoice_with_tickets( + # act + actions.create_invoice_with_tickets( self.alice, Ticket.CORPORATE, days_for_self=['sat', 'sun', 'mon'], @@ -42,17 +95,20 @@ def test_order_for_self_corporate(self): }, ) - self.assertEqual(self.alice.orders.count(), 1) - self.assertIsNone(self.alice.get_ticket()) + # assert + self.create_invoice.assert_called_once_with(self.alice, 'Sirius Cybernetics Corp.', 'Eadrax, Sirius Tau') + create_ticket.assert_called_once_with(self.alice, Ticket.CORPORATE, ['sat', 'sun', 'mon']) + self.invoice_mock.add_item.assert_called_once() + create_ticket_with_invite.assert_not_called() - self.assertEqual(order.purchaser, self.alice) - self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, Ticket.CORPORATE) - self.assertEqual(order.company_name, 'Sirius Cybernetics Corp.') - self.assertEqual(order.company_addr, 'Eadrax, Sirius Tau') + @patch('tickets.actions.create_ticket') + @patch('tickets.actions.create_ticket_with_invitation') + def test_order_for_others(self, create_ticket_with_invite, + create_ticket): + # arrange - def test_order_for_others(self): - order = actions.create_invoice_with_tickets( + # act + actions.create_invoice_with_tickets( self.alice, Ticket.INDIVIDUAL, email_addrs_and_days_for_others=[ @@ -61,15 +117,22 @@ def test_order_for_others(self): ] ) - self.assertEqual(self.alice.orders.count(), 1) - self.assertIsNone(self.alice.get_ticket()) - - self.assertEqual(order.purchaser, self.alice) - self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, Ticket.INDIVIDUAL) - - def test_order_for_self_and_others(self): - order = actions.create_invoice_with_tickets( + # assert + self.create_invoice.assert_called_once_with(self.alice, None, None) + self.assertEqual(create_ticket_with_invite.call_count, 2) + create_ticket_with_invite.assert_any_call('bob@example.com', Ticket.INDIVIDUAL, ['sat', 'sun']) + create_ticket_with_invite.assert_any_call('carol@example.com', Ticket.INDIVIDUAL, ['sun', 'mon']) + self.assertEqual(self.invoice_mock.add_item.call_count, 2) + create_ticket.assert_not_called() + + @patch('tickets.actions.create_ticket') + @patch('tickets.actions.create_ticket_with_invitation') + def test_order_for_self_and_others(self, create_ticket_with_invite, + create_ticket): + # arrange + + # act + actions.create_invoice_with_tickets( self.alice, Ticket.INDIVIDUAL, days_for_self=['sat', 'sun', 'mon'], @@ -79,221 +142,20 @@ def test_order_for_self_and_others(self): ] ) - self.assertEqual(self.alice.orders.count(), 1) - self.assertIsNone(self.alice.get_ticket()) - - self.assertEqual(order.purchaser, self.alice) - self.assertEqual(order.status, 'pending') - self.assertEqual(order.rate, Ticket.INDIVIDUAL) - - -class ConfirmOrderTests(TestCase): - def test_order_for_self(self): - order = factories.create_unpaid_order_for_self() - - with utils.patched_charge_creation_success(order.total_pence_inc_vat): - payment = payment_actions.pay_invoice_by_stripe(order, 'ch_abcdefghijklmnopqurstuvw') - - self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(payment.charge_failure_reason, '') - self.assertEqual(payment.method, Payment.STRIPE) - self.assertEqual(payment.status, Payment.SUCCESSFUL) - - self.assertEqual(order.purchaser.invoices.all()[0].payments.count(), 1) - self.assertIsNotNone(order.purchaser.get_ticket()) - - ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) - - self.assertEqual(len(mail.outbox), 1) - - def test_order_for_others(self): - order = factories.create_unpaid_order_for_others() - - with utils.patched_charge_creation_success(order.total_pence_inc_vat): - payment = payment_actions.pay_invoice_by_stripe(order, 'ch_abcdefghijklmnopqurstuvw') - - self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(payment.charge_failure_reason, '') - self.assertEqual(payment.method, Payment.STRIPE) - self.assertEqual(payment.status, Payment.SUCCESSFUL) - - self.assertEqual(order.purchaser.invoices.all()[0].payments.count(), 1) - self.assertIsNone(order.purchaser.get_ticket()) - - ticket = TicketInvitation.objects.get(email_addr='bob@example.com').ticket - self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) - - ticket = TicketInvitation.objects.get(email_addr='carol@example.com').ticket - self.assertEqual(ticket.days(), ['Sunday', 'Monday']) - - self.assertEqual(len(mail.outbox), 3) - - def test_order_for_self_and_others(self): - order = factories.create_unpaid_order_for_self_and_others() - - with utils.patched_charge_creation_success(order.total_pence_inc_vat): - payment = payment_actions.pay_invoice_by_stripe(order, 'ch_abcdefghijklmnopqurstuvw') - - self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(payment.charge_failure_reason, '') - self.assertEqual(payment.method, Payment.STRIPE) - self.assertEqual(payment.status, Payment.SUCCESSFUL) - - self.assertEqual(order.purchaser.invoices.all()[0].payments.count(), 1) - self.assertIsNotNone(order.purchaser.get_ticket()) - - ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) - - ticket = TicketInvitation.objects.get(email_addr='bob@example.com').ticket - self.assertEqual(ticket.days(), ['Saturday', 'Sunday']) - - ticket = TicketInvitation.objects.get(email_addr='carol@example.com').ticket - self.assertEqual(ticket.days(), ['Sunday', 'Monday']) - - self.assertEqual(len(mail.outbox), 3) - - def test_after_order_marked_as_failed(self): - order = factories.create_unpaid_order_for_self() - payment_actions.create_failed_payment(order, 'There was a problem', 'ch_abcdefghijklmnopqurstuvw') + # assert + self.create_invoice.assert_called_once_with(self.alice, None, None) + create_ticket.assert_called_once_with(self.alice, Ticket.INDIVIDUAL, ['sat', 'sun', 'mon']) + self.assertEqual(create_ticket_with_invite.call_count, 2) + create_ticket_with_invite.assert_any_call('bob@example.com', Ticket.INDIVIDUAL, ['sat', 'sun']) + create_ticket_with_invite.assert_any_call('carol@example.com', Ticket.INDIVIDUAL, ['sun', 'mon']) + self.assertEqual(self.invoice_mock.add_item.call_count, 3) - with utils.patched_charge_creation_success(order.total_pence_inc_vat): - payment = payment_actions.pay_invoice_by_stripe(order, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(payment.charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(payment.charge_failure_reason, '') - self.assertEqual(payment.method, Payment.STRIPE) - self.assertEqual(payment.status, Payment.SUCCESSFUL) - - self.assertEqual(order.purchaser.invoices.all()[0].payments.count(), 1) - self.assertIsNotNone(order.purchaser.get_ticket()) - - ticket = order.purchaser.get_ticket() - self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) - - def test_sends_slack_message(self): - backend = get_slack_backend() - order = factories.create_unpaid_order_for_self() - backend.reset_messages() - - with utils.patched_charge_creation_success(order.total_pence_inc_vat): - payment_actions.pay_invoice_by_stripe(order, 'ch_abcdefghijklmnopqurstuvw') - - messages = backend.retrieve_messages() - self.assertEqual(len(messages), 1) - text = messages[0]['text'] - self.assertIn('Alice has just placed an order for 1 ticket at the individual rate', text) - - -class MarkOrderAsFailed(TestCase): - def test_mark_order_as_failed(self): - order = factories.create_unpaid_order_for_self() - - payment_actions.create_failed_payment(order, 'There was a problem', 'ch_abcdefghijklmnopqurstuvw') - - self.assertEqual(order.payments.first().charge_failure_reason, 'There was a problem') - self.assertEqual(order.payments.first().status, Payment.FAILED) - - -class MarkOrderAsErroredAfterCharge(TestCase): - def test_mark_order_as_errored_after_charge(self): - order = factories.create_unpaid_order_for_self() - - payment_actions.create_errored_after_charge_payment(order, 'ch_abcdefghijklmnopqurstuvw', order.total_pence_inc_vat) - - self.assertEqual(order.payments.first().charge_id, 'ch_abcdefghijklmnopqurstuvw') - self.assertEqual(order.payments.first().status, Payment.ERRORED) +class UpdateUnpaidOrderTests(TestCase): + pass class CreateFreeTicketTests(TestCase): - def test_create_free_ticket(self): - ticket = actions.create_free_ticket('alice@example.com', 'Financial assistance') - - self.assertEqual(ticket.days(), []) - self.assertEqual(ticket.pot, 'Financial assistance') - self.assertEqual(ticket.invitation.email_addr, 'alice@example.com') - self.assertEqual(len(mail.outbox), 1) - - -class UpdateFreeTicketTests(TestCase): - def test_update_free_ticket(self): - ticket = factories.create_free_ticket() - - actions.update_free_ticket(ticket, ['sat', 'sun', 'mon']) - ticket.refresh_from_db() - - self.assertEqual(ticket.days(), ['Saturday', 'Sunday', 'Monday']) - - -class TicketInvitationTests(TestCase): - def test_claim_ticket_invitation(self): - factories.create_paid_order_for_others() - bob = factories.create_user('Bob') - - invitation = TicketInvitation.objects.get(email_addr='bob@example.com') - actions.claim_ticket_invitation(bob, invitation) - - self.assertIsNotNone(bob.get_ticket()) - - -class ReassignTicketTests(TestCase): - @classmethod - def setUpTestData(cls): - cls.alice = factories.create_user() - - def test_reassign_assigned_ticket_with_existing_invitation(self): - ticket = factories.create_ticket_with_claimed_invitation(self.alice) - mail.outbox = [] - - actions.reassign_ticket(ticket, 'zoe@example.com') - self.alice.refresh_from_db() - self.assertIsNone(self.alice.get_ticket()) - - ticket.refresh_from_db() - self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) - - self.assertEqual(len(mail.outbox), 1) - - def test_reassign_assigned_ticket_with_no_existing_invitation(self): - ticket = factories.create_ticket(self.alice) - mail.outbox = [] - - actions.reassign_ticket(ticket, 'zoe@example.com') - - # For some reason, refresh_from_db doesn't work here (although it does - # in test_reassign_assigned_ticket_with_existing_invitation), so let's - # get the object from the database directly. - # self.alice.refresh_from_db() - alice = type(self.alice).objects.get(id=self.alice.id) - self.assertIsNone(alice.get_ticket()) - - ticket.refresh_from_db() - self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) - - self.assertEqual(len(mail.outbox), 1) - - def test_reassign_unassigned_ticket(self): - ticket = factories.create_ticket_with_unclaimed_invitation() - mail.outbox = [] - - actions.reassign_ticket(ticket, 'zoe@example.com') - - ticket.refresh_from_db() - self.assertEqual(ticket.invitation.status, TicketInvitation.UNCLAIMED) - - self.assertEqual(len(mail.outbox), 1) - - -class OrderRefundTests(TestCase): - def test_refund_order(self): - order = factories.create_paid_order_for_self() - mail.outbox = [] - - with utils.patched_refund_creation_expected(): - actions.refund_order(order) + pass - self.assertEqual(order.status, 'refunded') - self.assertEqual(order.tickets.count(), 0) - self.assertEqual(len(mail.outbox), 1) From 327c9296e5b152cdb1439212bf1e8348509978dd Mon Sep 17 00:00:00 2001 From: Kirk Northrop Date: Sun, 1 Apr 2018 19:48:33 +0100 Subject: [PATCH 66/66] =?UTF-8?q?Make=20it=20work=20when=20you=E2=80=99re?= =?UTF-8?q?=20not=20buying=20other=20people=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/views.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tickets/views.py b/tickets/views.py index 0f9b62e..351efc6 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -130,7 +130,9 @@ def order_confirm(request): order_total = Decimal() - number_of_tickets = len(details['email_addrs_and_days_for_others']) + number_of_tickets = 0 + if details['email_addrs_and_days_for_others']: + number_of_tickets += len(details['email_addrs_and_days_for_others']) if details['days_for_self']: number_of_tickets += 1 @@ -144,13 +146,14 @@ def order_confirm(request): order_total += details_for_self['amount'] details_for_others = [] - for email, days in details['email_addrs_and_days_for_others']: - details_for_others.append(( - email, - [DAYS[day] for day in days], - cost_incl_vat(details['rate'], len(days)) - )) - order_total += cost_incl_vat(details['rate'], len(days)) + if details['email_addrs_and_days_for_others']: + for email, days in details['email_addrs_and_days_for_others']: + details_for_others.append(( + email, + [DAYS[day] for day in days], + cost_incl_vat(details['rate'], len(days)) + )) + order_total += cost_incl_vat(details['rate'], len(days)) context = { 'rate': rate,