Luke Plant avatar Luke Plant committed bfb629a Merge

Merged from default

Comments (0)

Files changed (16)

cciw/bookings/admin.py

 from django.utils.html import escape, escapejs
 
 from cciw.bookings.email import send_booking_approved_mail
-from cciw.bookings.models import Price, BookingAccount, Booking, ChequePayment, RefundPayment, BOOKING_APPROVED, BOOKING_INFO_COMPLETE
+from cciw.bookings.models import Price, BookingAccount, Booking, ManualPayment, RefundPayment, BOOKING_APPROVED, BOOKING_INFO_COMPLETE
 from cciw.cciwmain.common import get_thisyear
 from cciw.utils.views import close_window_response
 
     def camp(obj):
         return "%s-%s" % (obj.camp.year, obj.camp.number)
     camp.admin_order_field = 'camp__year'
-    list_display = ['first_name', 'last_name', 'sex', 'account', camp, 'state', 'confirmed_booking', 'created']
+
+    def confirmed(obj):
+        return obj.is_confirmed
+    confirmed.boolean = True
+
+    list_display = ['first_name', 'last_name', 'sex', 'account', camp, 'state', confirmed, 'created']
     del camp
     search_fields = ['first_name', 'last_name']
     ordering = ['-camp__year', 'camp__number']
         return retval
 
 
-class ChequePaymentAdminFormBase(forms.ModelForm):
+class ManualPaymentAdminFormBase(forms.ModelForm):
 
     account = account_autocomplete_field()
 
     def clean(self):
-        retval = super(ChequePaymentAdminFormBase, self).clean()
+        retval = super(ManualPaymentAdminFormBase, self).clean()
         if self.instance is not None and self.instance.id is not None:
-            raise forms.ValidationError("Cheque payments cannot be changed "
+            raise forms.ValidationError("Manual payments cannot be changed "
                                         "after being created. If an error was made, "
                                         "delete this record and create a new one. ")
         return retval
 
 
-class ChequePaymentAdminForm(ChequePaymentAdminFormBase):
+class ManualPaymentAdminForm(ManualPaymentAdminFormBase):
 
     class Meta:
-        model = ChequePayment
+        model = ManualPayment
 
 
-class RefundPaymentAdminForm(ChequePaymentAdminFormBase):
+class RefundPaymentAdminForm(ManualPaymentAdminFormBase):
 
     class Meta:
         model = RefundPayment
 
 
-class ChequePaymentAdminBase(admin.ModelAdmin):
-    list_display = ['account', 'amount', 'created']
+class ManualPaymentAdminBase(admin.ModelAdmin):
+    list_display = ['account', 'amount', 'payment_type', 'created']
     search_fields = ['account__name']
     date_hierarchy = 'created'
     fieldsets = [(None,
                   {'fields':
-                       ['account', 'amount', 'created']})]
+                       ['account', 'amount', 'created', 'payment_type']})]
 
     def get_readonly_fields(self, request, obj=None):
         if obj is not None:
-            return ['account', 'amount', 'created']
+            return ['account', 'amount', 'created', 'payment_type']
         else:
             return []
 
 
-class ChequePaymentAdmin(ChequePaymentAdminBase):
-    form = ChequePaymentAdminForm
+class ManualPaymentAdmin(ManualPaymentAdminBase):
+    form = ManualPaymentAdminForm
 
 
-class RefundPaymentAdmin(ChequePaymentAdminBase):
+class RefundPaymentAdmin(ManualPaymentAdminBase):
     form = RefundPaymentAdminForm
 
 
 admin.site.register(Price, PriceAdmin)
 admin.site.register(BookingAccount, BookingAccountAdmin)
 admin.site.register(Booking, BookingAdmin)
-admin.site.register(ChequePayment, ChequePaymentAdmin)
+admin.site.register(ManualPayment, ManualPaymentAdmin)
 admin.site.register(RefundPayment, RefundPaymentAdmin)

cciw/bookings/anonymizers.py

-from cciw.bookings.models import Price, BookingAccount, Booking, Payment, ChequePayment, RefundPayment
+from cciw.bookings.models import Price, BookingAccount, Booking, Payment, ManualPayment, RefundPayment
 from anonymizer import Anonymizer
 
 class BookingAccountAnonymizer(Anonymizer):

cciw/bookings/email.py

-from datetime import date
+from datetime import date, datetime
 
 from django.conf import settings
 from django.contrib.sites.models import get_current_site
 from django.utils.crypto import constant_time_compare, salted_hmac
 from django.utils.http import int_to_base36, base36_to_int
 
+from cciw.officers.email import admin_emails_for_camp
+
+
+LATE_BOOKING_THRESHOLD = 30 # days
 
 class EmailVerifyTokenGenerator(object):
     """
     subject = u"CCIW booking - place confirmed"
     mail.send_mail(subject, body, settings.SERVER_EMAIL, [account.email])
 
+    # Email leaders. Bookings could be for different camps, so send different
+    # emails.
+
+    # We don't care about timezones, or about accuracy better than 1 day,
+    # so use naive UTC datetimes, not aware datetimes.
+    today = datetime.utcnow().date()
+
+    for booking in bookings:
+        if (booking.camp.start_date - today).days < LATE_BOOKING_THRESHOLD:
+
+            c = {
+                'account': account,
+                'booking': booking,
+                'camp': booking.camp,
+                'url_start': site_address_url_start(),
+                }
+            body = loader.render_to_string('cciw/bookings/late_place_confirmed_email.txt', c)
+            subject = u"CCIW late booking: %s" % booking.name
+
+            mail.send_mail(subject, body, settings.SERVER_EMAIL,
+                           admin_emails_for_camp(booking.camp))
+
 
 def send_booking_expiry_mail(account, bookings, expired):
     if account.email == '':

cciw/bookings/hooks.py

 
 from .signals import places_confirmed
 from .email import send_unrecognised_payment_email, send_places_confirmed_email
-from .models import BookingAccount, ChequePayment, RefundPayment, send_payment
+from .models import BookingAccount, ManualPayment, RefundPayment, send_payment
 
 #### Handlers #####
 
         unrecognised_payment(ipn_obj)
 
 
-def cheque_payment_received(sender, **kwargs):
+def manual_payment_received(sender, **kwargs):
     instance = kwargs['instance']
     send_payment(instance.amount, instance.account, instance)
 
 
-def cheque_payment_deleted(sender, **kwargs):
+def manual_payment_deleted(sender, **kwargs):
     instance = kwargs['instance']
     send_payment(-instance.amount, instance.account, instance)
 
 payment_was_successful.connect(paypal_payment_received)
 payment_was_flagged.connect(unrecognised_payment)
 places_confirmed.connect(places_confirmed_handler)
-post_save.connect(cheque_payment_received, sender=ChequePayment)
-post_delete.connect(cheque_payment_deleted, sender=ChequePayment)
+post_save.connect(manual_payment_received, sender=ManualPayment)
+post_delete.connect(manual_payment_deleted, sender=ManualPayment)
 post_save.connect(refund_payment_sent, sender=RefundPayment)
 post_delete.connect(refund_payment_deleted, sender=RefundPayment)

cciw/bookings/migrations/0012_cheque_to_manual_payment.py

+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        db.rename_table('bookings_chequepayment', 'bookings_manualpayment')
+
+
+    def backwards(self, orm):
+        db.rename_table('bookings_manualpayment', 'bookings_chequepayment')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 10, 17, 47, 39, 474394)'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 10, 17, 47, 39, 474264)'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'bookings.booking': {
+            'Meta': {'ordering': "['-created']", 'object_name': 'Booking'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bookings'", 'to': "orm['bookings.BookingAccount']"}),
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'agreement': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'allergies': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'amount_due': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'booking_expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'camp': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bookings'", 'to': "orm['cciwmain.Camp']"}),
+            'church': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+            'contact_address': ('django.db.models.fields.TextField', [], {}),
+            'contact_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22'}),
+            'contact_post_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'date_of_birth': ('django.db.models.fields.DateField', [], {}),
+            'dietary_requirements': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'gp_address': ('django.db.models.fields.TextField', [], {}),
+            'gp_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'gp_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'illnesses': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'last_tetanus_injection': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'learning_difficulties': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'medical_card_number': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22', 'blank': 'True'}),
+            'post_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+            'price_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'regular_medication_required': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'serious_illness': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'sex': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            'shelved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'south_wales_transport': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'state': ('django.db.models.fields.IntegerField', [], {})
+        },
+        'bookings.bookingaccount': {
+            'Meta': {'unique_together': "[('name', 'post_code'), ('name', 'email')]", 'object_name': 'BookingAccount'},
+            'address': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+            'email_communication': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'first_login': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'null': 'True', 'blank': 'True'}),
+            'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22', 'blank': 'True'}),
+            'post_code': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'share_phone_number': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'total_received': ('django.db.models.fields.DecimalField', [], {'default': "'0.00'", 'max_digits': '10', 'decimal_places': '2'})
+        },
+        'bookings.manualpayment': {
+            'Meta': {'object_name': 'ManualPayment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'bookings.payment': {
+            'Meta': {'object_name': 'Payment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'origin_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'origin_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'processed': ('django.db.models.fields.DateTimeField', [], {'null': 'True'})
+        },
+        'bookings.price': {
+            'Meta': {'unique_together': "[('year', 'price_type')]", 'object_name': 'Price'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'price': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'price_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'year': ('django.db.models.fields.PositiveSmallIntegerField', [], {})
+        },
+        'bookings.refundpayment': {
+            'Meta': {'object_name': 'RefundPayment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'cciwmain.camp': {
+            'Meta': {'ordering': "['-year', 'number']", 'unique_together': "(('year', 'number'),)", 'object_name': 'Camp'},
+            'admins': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'camps_as_admin'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['auth.User']"}),
+            'chaplain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'camps_as_chaplain'", 'null': 'True', 'to': "orm['cciwmain.Person']"}),
+            'end_date': ('django.db.models.fields.DateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'leaders': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'camps_as_leader'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['cciwmain.Person']"}),
+            'max_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '80'}),
+            'max_female_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '60'}),
+            'max_male_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '60'}),
+            'maximum_age': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'minimum_age': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'number': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'officers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'through': "orm['officers.Invitation']", 'symmetrical': 'False'}),
+            'online_applications': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'previous_camp': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'next_camps'", 'null': 'True', 'to': "orm['cciwmain.Camp']"}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cciwmain.Site']"}),
+            'south_wales_transport_available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'start_date': ('django.db.models.fields.DateField', [], {}),
+            'year': ('django.db.models.fields.PositiveSmallIntegerField', [], {})
+        },
+        'cciwmain.person': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Person'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'info': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'cciwmain.site': {
+            'Meta': {'object_name': 'Site'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'info': ('django.db.models.fields.TextField', [], {}),
+            'long_name': ('django.db.models.fields.CharField', [], {'max_length': "'50'"}),
+            'short_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': "'25'"}),
+            'slug_name': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'unique': 'True', 'max_length': "'25'", 'blank': 'True'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'officers.invitation': {
+            'Meta': {'ordering': "('-camp__year', 'officer__first_name', 'officer__last_name')", 'unique_together': "(('officer', 'camp'),)", 'object_name': 'Invitation'},
+            'camp': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cciwmain.Camp']"}),
+            'date_added': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'notes': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'officer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        }
+    }
+
+    complete_apps = ['bookings']

cciw/bookings/migrations/0013_auto__add_field_manualpayment_payment_type__add_field_refundpayment_pa.py

+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'ManualPayment.payment_type'
+        db.add_column('bookings_manualpayment', 'payment_type', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=0), keep_default=False)
+
+        # Adding field 'RefundPayment.payment_type'
+        db.add_column('bookings_refundpayment', 'payment_type', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=0), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'ManualPayment.payment_type'
+        db.delete_column('bookings_manualpayment', 'payment_type')
+
+        # Deleting field 'RefundPayment.payment_type'
+        db.delete_column('bookings_refundpayment', 'payment_type')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 10, 17, 56, 51, 437081)'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 10, 17, 56, 51, 436963)'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'bookings.booking': {
+            'Meta': {'ordering': "['-created']", 'object_name': 'Booking'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bookings'", 'to': "orm['bookings.BookingAccount']"}),
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'agreement': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'allergies': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'amount_due': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'booking_expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'camp': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bookings'", 'to': "orm['cciwmain.Camp']"}),
+            'church': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+            'contact_address': ('django.db.models.fields.TextField', [], {}),
+            'contact_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22'}),
+            'contact_post_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'date_of_birth': ('django.db.models.fields.DateField', [], {}),
+            'dietary_requirements': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'gp_address': ('django.db.models.fields.TextField', [], {}),
+            'gp_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'gp_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'illnesses': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'last_tetanus_injection': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'learning_difficulties': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'medical_card_number': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22', 'blank': 'True'}),
+            'post_code': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+            'price_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'regular_medication_required': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'serious_illness': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'sex': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            'shelved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'south_wales_transport': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'state': ('django.db.models.fields.IntegerField', [], {})
+        },
+        'bookings.bookingaccount': {
+            'Meta': {'unique_together': "[('name', 'post_code'), ('name', 'email')]", 'object_name': 'BookingAccount'},
+            'address': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+            'email_communication': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'first_login': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'null': 'True', 'blank': 'True'}),
+            'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '22', 'blank': 'True'}),
+            'post_code': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'share_phone_number': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'total_received': ('django.db.models.fields.DecimalField', [], {'default': "'0.00'", 'max_digits': '10', 'decimal_places': '2'})
+        },
+        'bookings.manualpayment': {
+            'Meta': {'object_name': 'ManualPayment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'payment_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'})
+        },
+        'bookings.payment': {
+            'Meta': {'object_name': 'Payment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'origin_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'origin_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'processed': ('django.db.models.fields.DateTimeField', [], {'null': 'True'})
+        },
+        'bookings.price': {
+            'Meta': {'unique_together': "[('year', 'price_type')]", 'object_name': 'Price'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'price': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'price_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'year': ('django.db.models.fields.PositiveSmallIntegerField', [], {})
+        },
+        'bookings.refundpayment': {
+            'Meta': {'object_name': 'RefundPayment'},
+            'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['bookings.BookingAccount']"}),
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '10', 'decimal_places': '2'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'payment_type': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'})
+        },
+        'cciwmain.camp': {
+            'Meta': {'ordering': "['-year', 'number']", 'unique_together': "(('year', 'number'),)", 'object_name': 'Camp'},
+            'admins': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'camps_as_admin'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['auth.User']"}),
+            'chaplain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'camps_as_chaplain'", 'null': 'True', 'to': "orm['cciwmain.Person']"}),
+            'end_date': ('django.db.models.fields.DateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'leaders': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'camps_as_leader'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['cciwmain.Person']"}),
+            'max_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '80'}),
+            'max_female_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '60'}),
+            'max_male_campers': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '60'}),
+            'maximum_age': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'minimum_age': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'number': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'officers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'through': "orm['officers.Invitation']", 'symmetrical': 'False'}),
+            'online_applications': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'previous_camp': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'next_camps'", 'null': 'True', 'to': "orm['cciwmain.Camp']"}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cciwmain.Site']"}),
+            'south_wales_transport_available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'start_date': ('django.db.models.fields.DateField', [], {}),
+            'year': ('django.db.models.fields.PositiveSmallIntegerField', [], {})
+        },
+        'cciwmain.person': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Person'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'info': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'cciwmain.site': {
+            'Meta': {'object_name': 'Site'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'info': ('django.db.models.fields.TextField', [], {}),
+            'long_name': ('django.db.models.fields.CharField', [], {'max_length': "'50'"}),
+            'short_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': "'25'"}),
+            'slug_name': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'unique': 'True', 'max_length': "'25'", 'blank': 'True'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'officers.invitation': {
+            'Meta': {'ordering': "('-camp__year', 'officer__first_name', 'officer__last_name')", 'unique_together': "(('officer', 'camp'),)", 'object_name': 'Invitation'},
+            'camp': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cciwmain.Camp']"}),
+            'date_added': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'notes': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'officer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        }
+    }
+
+    complete_apps = ['bookings']

cciw/bookings/models.py

     (BOOKING_CANCELLED_FULL_REFUND, 'Cancelled - full refund'),
 ]
 
+MANUAL_PAYMENT_CHEQUE, MANUAL_PAYMENT_CASH, MANUAL_PAYMENT_ECHEQUE, MANUAL_PAYMENT_BACS = range(0, 4)
+
+MANUAL_PAYMENT_CHOICES = [
+    (MANUAL_PAYMENT_CHEQUE, "Cheque"),
+    (MANUAL_PAYMENT_CASH, "Cash"),
+    (MANUAL_PAYMENT_ECHEQUE, "e-Cheque"),
+    (MANUAL_PAYMENT_BACS, "Bank transfer"),
+]
+
 
 class Price(models.Model):
     year = models.PositiveSmallIntegerField()
         return u"%s %s" % (self.first_name, self.last_name)
 
     ### Main business rules here ###
+    @property
+    def is_booked(self):
+        return self.state == BOOKING_BOOKED
 
-    def confirmed_booking(self):
-        return self.state == BOOKING_BOOKED and self.booking_expires is None
-    confirmed_booking.boolean = True
+    @property
+    def is_confirmed(self):
+        return self.is_booked and self.booking_expires is None
 
     def expected_amount_due(self):
         if self.price_type == PRICE_CUSTOM:
             self.amount_due = self.expected_amount_due()
 
     def age_on_camp(self):
-        # Age is calculated based on shool years, i.e. age on 31st August
+        # Age is calculated based on school years, i.e. age on 31st August
         return relativedelta(date(self.camp.year, 8, 31), self.date_of_birth)
 
     def get_booking_problems(self, booking_sec=False):
         return u"<Payment: %s to %s from %s>" % (self.amount, self.account, self.origin)
 
 
-class ChequePaymentManager(models.Manager):
+class ManualPaymentManager(models.Manager):
     use_for_related_fields = True
 
     def get_query_set(self):
-        return super(ChequePaymentManager, self).get_query_set().select_related('account')
+        return super(ManualPaymentManager, self).get_query_set().select_related('account')
 
 
-class ChequePaymentBase(models.Model):
+class ManualPaymentBase(models.Model):
     amount = models.DecimalField(decimal_places=2, max_digits=10)
     account = models.ForeignKey(BookingAccount)
     created = models.DateTimeField(default=datetime.now)
+    payment_type = models.PositiveSmallIntegerField(choices=MANUAL_PAYMENT_CHOICES,
+                                                    default=MANUAL_PAYMENT_CHEQUE)
 
-    objects = ChequePaymentManager()
+    objects = ManualPaymentManager()
 
     def save(self, **kwargs):
         if self.id is not None:
             raise Exception("%s cannot be edited after it has been saved to DB" %
                             self.__class__.__name__)
         else:
-            return super(ChequePaymentBase, self).save(**kwargs)
+            return super(ManualPaymentBase, self).save(**kwargs)
 
     class Meta:
         abstract = True
 
 
-class ChequePayment(ChequePaymentBase):
+class ManualPayment(ManualPaymentBase):
 
     def __unicode__(self):
-        return u"Cheque payment of £%s from %s" % (self.amount, self.account)
+        return u"Manual payment of £%s from %s" % (self.amount, self.account)
 
 
-class RefundPayment(ChequePaymentBase):
+class RefundPayment(ManualPaymentBase):
 
     def __unicode__(self):
         return u"Refund payment of £%s to %s" % (self.amount, self.account)

cciw/bookings/tests.py

 from decimal import Decimal
 import re
 
+from django.contrib.auth.models import User
 from django.core import mail
 from django.core.urlresolvers import reverse
 from django.test import TestCase
 import xlrd
 
 from cciw.bookings.management.commands.expire_bookings import Command as ExpireBookingsCommand
-from cciw.bookings.models import BookingAccount, Price, Booking, Payment, ChequePayment, RefundPayment, book_basket_now
+from cciw.bookings.models import BookingAccount, Price, Booking, Payment, ManualPayment, RefundPayment, book_basket_now
 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, BOOKING_CANCELLED_FULL_REFUND, BOOKING_CANCELLED_HALF_REFUND
 from cciw.bookings.utils import camp_bookings_to_spreadsheet
 from cciw.cciwmain.common import get_thisyear
-from cciw.cciwmain.models import Camp
+from cciw.cciwmain.models import Camp, Person
 from cciw.cciwmain.tests.mailhelpers import read_email_url
 from cciw.officers.tests.references import OFFICER_USERNAME, OFFICER_PASSWORD, BOOKING_SEC_USERNAME, BOOKING_SEC_PASSWORD, BOOKING_SEC
 from cciw.sitecontent.models import HtmlChunk
                                         site_id=1)
 
 
+class CreateLeadersMixin(object):
+    def create_leaders(self):
+        self.leader_1 = Person.objects.create(name="Mr Leader")
+        self.leader_2 = Person.objects.create(name="Mrs Leaderess")
+
+        self.leader_1_user = User.objects.create(username="leader1",
+                                            email="leader1@mail.com")
+        self.leader_2_user = User.objects.create(username="leader2",
+                                            email="leader2@mail.com")
+
+        self.leader_1.users.add(self.leader_1_user)
+        self.leader_2.users.add(self.leader_2_user)
+
+        self.camp.leaders.add(self.leader_1)
+        self.camp.leaders.add(self.leader_2)
+
+
 class CreatePricesMixin(object):
     def add_prices(self):
         year = get_thisyear()
         self.assertEqual(resp.status_code, 200)
 
 
-class TestPaymentReceived(CreatePlaceMixin, TestCase):
+class TestPaymentReceived(CreatePlaceMixin, CreateLeadersMixin, TestCase):
 
     fixtures = ['basic.json']
 
     def test_receive_payment(self):
         self.login()
         self.create_place()
+        self.create_leaders()
         acc = self.get_account()
         book_basket_now(acc.bookings.basket(self.camp.year))
         self.assertTrue(acc.bookings.all()[0].booking_expires is not None)
 
+        mail.outbox = []
         p = Price.objects.get(year=get_thisyear(), price_type=PRICE_FULL).price
         acc.receive_payment(p)
 
         # Check we updated the bookings
         self.assertTrue(acc.bookings.all()[0].booking_expires is None)
 
+        # Check for emails sent
+        # 1 to account
+        self.assertEqual(len([m for m in mail.outbox if m.to == [self.email]]), 1)
+
+        # This is a late booking, therefore there is also:
+        # 1 to camp leaders altogether
+        self.assertEqual(len([m for m in mail.outbox
+                              if sorted(m.to) == sorted([self.leader_1_user.email,
+                                                         self.leader_2_user.email])]),
+                         1)
+
+
     def test_insufficient_receive_payment(self):
         self.login()
         self.create_place()
                                          payment_status = 'completed',
                                          )
         mail.outbox = []
-        self.assertEqual(len(mail.outbox), 0)
         paypal_payment_received(ipn_1)
 
         # Since payments are processed in a separate process, we cannot
             self.assertEqual(b.state, BOOKING_INFO_COMPLETE)
 
 
-class TestChequePayment(TestCase):
+class TestManualPayment(TestCase):
 
     def test_create(self):
         acc = BookingAccount.objects.create(email='foo@foo.com')
         self.assertEqual(Payment.objects.count(), 0)
-        ChequePayment.objects.create(account=acc,
+        ManualPayment.objects.create(account=acc,
                                      amount=Decimal('100.00'))
         self.assertEqual(Payment.objects.count(), 1)
         self.assertEqual(Payment.objects.all()[0].amount, Decimal('100.00'))
     def test_delete(self):
         # Setup
         acc = BookingAccount.objects.create(email='foo@foo.com')
-        cp = ChequePayment.objects.create(account=acc,
+        cp = ManualPayment.objects.create(account=acc,
                                           amount=Decimal('100.00'))
         Payment.objects.all().delete() # reset
 
     def test_edit(self):
         # Setup
         acc = BookingAccount.objects.create(email='foo@foo.com')
-        cp = ChequePayment.objects.create(account=acc,
+        cp = ManualPayment.objects.create(account=acc,
                                           amount=Decimal('100.00'))
 
         cp.amount=Decimal("101.00")

cciw/bookings/utils.py

         ('Last name', lambda b: b.last_name),
         ('Sex', lambda b: b.get_sex_display()),
         ('State', lambda b: b.get_state_display()),
-        ('Confirmed', lambda b: b.confirmed_booking()),
+        ('Confirmed', lambda b: b.is_confirmed),
         ('Date created', lambda b: b.created),
         ]
 

cciw/bookings/views.py

 # - covers step 4 to 5 of user process
 
 # Admin payments info
-# - 'Add cheque payment' form
-#   - the amount of the cheque payment is entered (and cheque number?)
+# - 'Add manual payment' form
+#   - the amount of the manual payment is entered
 #   - includes name/address, with AJAX view to select account
 # - 'Add refund payment' form
 # - link to general admin page that allows payments to be corrected or deleted

cciw/cciwmain/management/commands/handle_mailing_lists.py

         try:
             l = zc.lockfile.LockFile('.handle_mail_lock')
         except zc.lockfile.LockError:
+            from cciw.cciwmain.common import exception_notify_admins
+            exception_notify_admins('Sending mail lock error')
             return
 
         try:

cciw/officers/views.py

     camps = Camp.objects.filter(year=year).prefetch_related('bookings')
     # Do some filtering in Python to avoid multiple db hits
     for c in camps:
-        c.confirmed_bookings = [b for b in c.bookings.all() if b.confirmed_booking()]
+        c.booked_places = [b for b in c.bookings.booked()]
+        c.confirmed_bookings = [b for b in c.booked_places if b.is_confirmed]
         c.confirmed_bookings_boys = [b for b in c.confirmed_bookings if b.sex == SEX_MALE]
         c.confirmed_bookings_girls = [b for b in c.confirmed_bookings if b.sex == SEX_FEMALE]
 
     INSTALLED_APPS += (
     'django.contrib.staticfiles',
     'south',
-)
+    )
 
 if DEVBOX and DEBUG:
     INSTALLED_APPS += (
         # Need this to stop ~/lib/ dirs getting in:
         run("touch env/lib/python2.7/sitecustomize.py")
 
-        if not getattr(env, 'no_installs', False):
-            with virtualenv(version.venv_dir):
-                with cd(version.project_dir):
-                    run_venv("pip install -r requirements.txt")
+        # We have to do this whether we added anything to requirements.txt
+        # or not, otherwise easy_install.pth is not updated correctly.
+        with virtualenv(version.venv_dir):
+            with cd(version.project_dir):
+                run_venv("pip install -r requirements.txt")
 
         # Need to add project to path.
         pth_file = '\n'.join("../../../../" + n for n in version.additional_sys_paths)
 
 
 
-def no_installs():
-    """
-    Call first to skip installing anything.
-    """
-    env.no_installs = True
-
-
 def no_db():
     """
     Call first to skip upgrading DB
 
 
 def quick():
-    no_installs()
     no_db()
 
 

templates/cciw/bookings/late_place_confirmed_email.txt

+{% load url from future %}{% autoescape off %}
+A booking has been received for camp {{ camp }}
+
+Name: {{ booking.name }}
+Age: {{ booking.age_on_camp.years }} years
+Sex: {{ booking.get_sex_display }}
+
+Please see the spreadsheet for more information:
+
+{{ url_start }}{% url 'cciw.officers.views.export_camper_data' year=camp.year number=camp.number %}
+
+You were sent an email about this because it was a late
+booking - within 30 days of the start of camp.
+
+Thanks,
+
+The cciw.co.uk team.
+
+{% endautoescape %}
+

templates/cciw/officers/booking_secretary_reports.html

 {% for camp in camps %}
   <tr>
     <td>{{ camp }}</td>
-    <td>{{ camp.bookings.all|length }}</td>
+    <td>{{ camp.booked_places|length }}</td>
     <td>{{ camp.confirmed_bookings|length }}</td>
     <td>{{ camp.confirmed_bookings_boys|length }}</td>
     <td>{{ camp.confirmed_bookings_girls|length }}</td>
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.