Luke Plant avatar Luke Plant committed d48d7e5

Added functionality for dealing with cancellations/deposit.

This involved moving some data from 'htmlchunks' to the template for
simplicity, in order to display the deposit value, and creating one new
'htmlchunk'.

Comments (0)

Files changed (5)

cciw/bookings/models.py

     (SEX_FEMALE, 'Female'),
 ]
 
-PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, PRICE_SOUTH_WALES_TRANSPORT = range(0, 5)
+# Price types that can be selected for a booking
+PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, PRICE_SOUTH_WALES_TRANSPORT, PRICE_DEPOSIT = range(0, 6)
 PRICE_TYPES = [
     (PRICE_FULL,      'Full price'),
     (PRICE_2ND_CHILD, '2nd child discount'),
 
 # Price types that are used by Price model
 VALUED_PRICE_TYPES = [(v,d) for (v,d) in PRICE_TYPES if v is not PRICE_CUSTOM] + \
-    [(PRICE_SOUTH_WALES_TRANSPORT, 'South wales transport surcharge')]
+    [(PRICE_SOUTH_WALES_TRANSPORT, 'South wales transport surcharge'),
+     (PRICE_DEPOSIT, 'Deposit'),
+     ]
 
-
-BOOKING_INFO_COMPLETE, BOOKING_APPROVED, BOOKING_BOOKED = range(0, 3)
+BOOKING_INFO_COMPLETE, BOOKING_APPROVED, BOOKING_BOOKED, BOOKING_CANCELLED = range(0, 4)
 BOOKING_STATES = [
     (BOOKING_INFO_COMPLETE, 'Information complete'),
     (BOOKING_APPROVED, 'Manually approved'),
     (BOOKING_BOOKED, 'Booked'),
+    (BOOKING_CANCELLED, 'Cancelled'),
 ]
 
 
         If confirmed_only=True, then only bookings that are confirmed
         (no expiration date) are included as 'received goods'
         """
-        if confirmed_only:
-            total = self.bookings.confirmed().aggregate(models.Sum('amount_due'))['amount_due__sum']
-
-        else:
-            total = self.bookings.booked().aggregate(models.Sum('amount_due'))['amount_due__sum']
+        total = self.bookings.payable(confirmed_only).aggregate(models.Sum('amount_due'))['amount_due__sum']
         if total is None:
             total = Decimal('0.00')
         return total - self.total_received
         return self.get_query_set().filter(state=BOOKING_BOOKED,
                                            booking_expires__isnull=False)
 
+    def payable(self, confirmed_only):
+        """
+        Returns bookings for which payment is due.
+        If confirmed_only is True, unconfirmed places are excluded.
+        """
+        # Cancelled bookings have payment due - the deposit
+        cancelled = self.get_query_set().filter(state=BOOKING_CANCELLED)
+        return cancelled | (self.confirmed() if confirmed_only else self.booked())
+
 
 class Booking(models.Model):
     account = models.ForeignKey(BookingAccount, related_name='bookings')
     confirmed_booking.boolean = True
 
     def expected_amount_due(self):
-        amount = Price.objects.get(year=self.camp.year,
-                                   price_type=self.price_type).price
-        if self.south_wales_transport:
-            amount += Price.objects.get(price_type=PRICE_SOUTH_WALES_TRANSPORT,
-                                        year=self.camp.year).price
-        return amount
+        if self.state == BOOKING_CANCELLED:
+            return Price.objects.get(year=self.camp.year,
+                                     price_type=PRICE_DEPOSIT).price
+        else:
+            amount = Price.objects.get(year=self.camp.year,
+                                       price_type=self.price_type).price
+            if self.south_wales_transport:
+                amount += Price.objects.get(price_type=PRICE_SOUTH_WALES_TRANSPORT,
+                                            year=self.camp.year).price
+            return amount
 
     def auto_set_amount_due(self):
         if self.price_type == PRICE_CUSTOM:

cciw/bookings/tests.py

 
 from cciw.bookings.management.commands.expire_bookings import Command as ExpireBookingsCommand
 from cciw.bookings.models import BookingAccount, Price, Booking, Payment, ChequePayment, book_basket_now
-from cciw.bookings.models import PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, PRICE_SOUTH_WALES_TRANSPORT, BOOKING_APPROVED, BOOKING_INFO_COMPLETE, BOOKING_BOOKED
+from cciw.bookings.models import PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, PRICE_SOUTH_WALES_TRANSPORT, PRICE_DEPOSIT, BOOKING_APPROVED, BOOKING_INFO_COMPLETE, BOOKING_BOOKED, BOOKING_CANCELLED
 from cciw.cciwmain.common import get_thisyear
 from cciw.cciwmain.models import Camp
 from cciw.cciwmain.tests.mailhelpers import read_email_url
         Price.objects.get_or_create(year=year,
                                     price_type=PRICE_SOUTH_WALES_TRANSPORT,
                                     price=Decimal('20.00'))
+        Price.objects.get_or_create(year=year,
+                                    price_type=PRICE_DEPOSIT,
+                                    price=Decimal('20.00'))
 
 
 class LogInMixin(object):
         data = self.place_details.copy()
         if extra is not None:
             data.update(extra)
+
+        # Sanity check:
+        resp0 = self.client.get(reverse('cciw.bookings.views.add_place'))
+        self.assertEqual(resp0.status_code, 200)
         resp = self.client.post(reverse('cciw.bookings.views.add_place'), data)
         self.assertEqual(resp.status_code, 302)
         newpath = reverse('cciw.bookings.views.list_bookings')
 
     def setUp(self):
         super(TestBookingIndex, self).setUp()
-        HtmlChunk.objects.get_or_create(name="booking_overview")
-        HtmlChunk.objects.get_or_create(name="bookingform_start")
-        HtmlChunk.objects.get_or_create(name="bookingform_end")
-        HtmlChunk.objects.get_or_create(name="no_bookingform_yet")
+        HtmlChunk.objects.get_or_create(name="bookingform_post_to")
 
     def test_show_with_no_prices(self):
         resp = self.client.get(reverse('cciw.bookings.views.index'))
         self.create_camp() # need for booking to be open
         resp = self.client.get(reverse('cciw.bookings.views.index'))
         self.assertContains(resp, "£100")
+        self.assertContains(resp, "£20") # Deposit price
 
 
 class TestBookingStart(CreatePlaceMixin, TestCase):
 
         json = simplejson.loads(resp.content)
         problems = json['problems']
-        self.assertTrue(any(p.startswith("The 'amount due' is not the expected value of ")
+        p_full = Price.objects.get(price_type=PRICE_FULL, year=get_thisyear())
+        self.assertTrue(any(p.startswith(u"The 'amount due' is not the expected value of £%s"
+                                         % p_full.price)
+                            for p in problems))
+
+
+    def test_booking_problems_deposit_check(self):
+        # Test that the price is checked.
+        # This is a check that is only run for booking secretary
+        self.add_prices()
+        acc1 = BookingAccount.objects.create(email="foo@foo.com",
+                                             post_code="ABC",
+                                             name="Mr Foo")
+        self.client.login(username=BOOKING_SEC_USERNAME, password=BOOKING_SEC_PASSWORD)
+
+        data = self.place_details.copy()
+        data['account'] = str(acc1.id)
+        data['created_0'] = '1970-01-01'
+        data['created_1'] = '00:00:00'
+        data['state'] = BOOKING_CANCELLED
+        data['amount_due'] = '0.00'
+        data['price_type'] = PRICE_FULL
+        resp = self.client.post(reverse('cciw.bookings.views.booking_problems_json'),
+                                data)
+
+        json = simplejson.loads(resp.content)
+        problems = json['problems']
+        p_deposit = Price.objects.get(price_type=PRICE_DEPOSIT, year=get_thisyear())
+        self.assertTrue(any(p.startswith(u"The 'amount due' is not the expected value of £%s"
+                                         % p_deposit.price)
                             for p in problems))
 
 
 
         cp.amount=Decimal("101.00")
         self.assertRaises(Exception, cp.save)
+
+
+class TestCancel(CreatePlaceMixin, TestCase):
+    """
+    Tests covering what happens when a user cancels.
+    """
+    fixtures = ['basic.json']
+
+    def test_amount_due(self):
+        self.create_place()
+        acc = self.get_account()
+        place = acc.bookings.all()[0]
+        place.state = BOOKING_CANCELLED
+        self.assertEqual(place.expected_amount_due(), Price.objects.get(price_type=PRICE_DEPOSIT).price)
+
+    def test_account_amount_due(self):
+        self.create_place()
+        acc = self.get_account()
+        place = acc.bookings.all()[0]
+        place.state = BOOKING_CANCELLED
+        place.auto_set_amount_due()
+        place.save()
+
+        acc = self.get_account()
+        self.assertEqual(acc.get_balance(), place.amount_due)

cciw/bookings/views.py

 from cciw.bookings.forms import EmailForm, AccountDetailsForm, AddPlaceForm
 from cciw.bookings.models import BookingAccount, Price, Booking, book_basket_now
 from cciw.bookings.models import PRICE_FULL, PRICE_2ND_CHILD, PRICE_3RD_CHILD, PRICE_CUSTOM, \
-    BOOKING_INFO_COMPLETE, BOOKING_APPROVED, VALUED_PRICE_TYPES, PRICE_SOUTH_WALES_TRANSPORT
+    BOOKING_INFO_COMPLETE, BOOKING_APPROVED, VALUED_PRICE_TYPES, PRICE_SOUTH_WALES_TRANSPORT, \
+    PRICE_DEPOSIT
 
 
 # decorators and utilities
             self.context['price_full'] = [p for p in prices if p.price_type == PRICE_FULL][0].price
             self.context['price_2nd_child'] = [p for p in prices if p.price_type == PRICE_2ND_CHILD][0].price
             self.context['price_3rd_child'] = [p for p in prices if p.price_type == PRICE_3RD_CHILD][0].price
+            self.context['price_deposit'] = [p for p in prices if p.price_type == PRICE_DEPOSIT][0].price
         return super(BookingIndex, self).get(request)
 
 

cciw/sitecontent/migrations/0004_create_htmlchunks_for_booking_2.py

+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+
+class Migration(DataMigration):
+
+    def forwards(self, orm):
+        h1, x = orm['sitecontent.HtmlChunk'].objects.get_or_create(name="bookingform_post_to")
+        h1.html = """
+
+    <p>Alan Lansdown<br/>
+    28 Bryntirion<br/>
+    Rhiwbina<br/>
+    Cardiff<br/>
+    CF14 6NQ
+    </p>
+"""
+        h1.save()
+
+    def backwards(self, orm):
+        orm['sitecontent.HtmlChunk'].objects.filter(name="bookingform_post_to").delete()
+
+
+    models = {
+        'sitecontent.htmlchunk': {
+            'Meta': {'object_name': 'HtmlChunk'},
+            'html': ('django.db.models.fields.TextField', [], {}),
+            'menu_link': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sitecontent.MenuLink']", 'null': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'primary_key': 'True', 'db_index': 'True'}),
+            'page_title': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'})
+        },
+        'sitecontent.menulink': {
+            'Meta': {'ordering': "('-parent_item__id', 'listorder')", 'object_name': 'MenuLink'},
+            'extra_title': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'listorder': ('django.db.models.fields.SmallIntegerField', [], {}),
+            'parent_item': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'child_links'", 'null': 'True', 'to': "orm['sitecontent.MenuLink']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'url': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'visible': ('django.db.models.fields.BooleanField', [], {'default': 'True'})
+        }
+    }
+
+    complete_apps = ['sitecontent']

templates/cciw/bookings/index.html

 {% extends "cciw/standard.html" %}
 {% load standardpage %}
+{% load url from future %}
 {% block content %}
 
 <h2>Prices {{ thisyear }}</h2>
 
 {% endif %}
 
-{% htmlchunk booking_overview %}
+<h2>Booking</h2>
+
+<p>There are two ways to book for camp:</p>
+
+<h3>Paper booking</h3>
+
+<p>You will need the booking form which comes with our camp brochure, which we
+can post to you, or you can download below. You will need to include a cheque to
+pay a deposit of £{{ price_deposit }} with your booking</p>
+
+<h3>Online booking</h3>
+
+<p>You can <a href="{% url 'cciw.bookings.views.start' %}">book and pay online</a>,
+providing you have a debit card, credit card, or PayPal account.</p>
+
+<p>If you book online, you can see how many places there are left in real time,
+and your place will be confirmed immediately. However, you will have to pay the
+full amount when booking, not just the deposit. For refunds, the same rules
+apply to both paper bookings and online bookings.</p>
+
+<h2>Paper booking form</h2>
 
 {% if bookingform %}
-	{% htmlchunk bookingform_start %}
-	<div><a href="{{ MEDIA_URL }}{{ bookingform }}">Download the CCIW
+    <p>If you have a printer you can download and print a booking form for
+    {{ thisyear }}.  Please fill in the form and follow the instructions on it.
+    You will need a PDF reader to view or print the form.</p>
+
+
+    <div><a href="{{ MEDIA_URL }}{{ bookingform }}">Download the CCIW
 	booking form for {{ thisyear }}</a> </div>
-	{% htmlchunk bookingform_end %}
+
+    <p><br/>The completed booking form must be sent to:</p>
+
+    {% htmlchunk bookingform_post_to %}
+
 {% else %}
-	{% htmlchunk no_bookingform_yet %}
+
+    <p>There is no booking form for {{ thisyear }} available yet online. Booking
+    forms are normally printed in January, and are distributed to all
+    individuals who came on camp the previous year (sometimes via churches). We
+    try to make the booking form available online as a PDF file at about the
+    same time, so that you can print your own.</p>
+
+    <p>An item will be added on the <a href="/news/">news page</a> when the
+    booking form is available for download.</p>
+
 
 {% endif %}
+
+<p>If you would like a booking form sent in the post and are not on our
+distribution list (or have not received a booking form and think you should
+have), please use the <a href="{% url 'cciwmain.misc.feedback' %}">feedback
+form</a> to request one. Please remember to to include your address so that we
+can post one. If you are ordering a large number of booking forms, please
+include your telephone number so that we can verify the request.</p>
+
 {% endblock %}
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.