From c2787d54b05b33c89f339f675a5f4f75e4af295f Mon Sep 17 00:00:00 2001 From: Tom Price Date: Wed, 29 Mar 2017 20:35:25 +0100 Subject: [PATCH 01/44] Add authorisation models. Add EventAuthorisation model + migrations Add authorised property to Event. Add appropriate tests --- RIGS/migrations/0025_eventauthorisation.py | 28 ++++++++++++ RIGS/models.py | 28 ++++++++++++ RIGS/test_models.py | 51 ++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 RIGS/migrations/0025_eventauthorisation.py diff --git a/RIGS/migrations/0025_eventauthorisation.py b/RIGS/migrations/0025_eventauthorisation.py new file mode 100644 index 00000000..88f38ff4 --- /dev/null +++ b/RIGS/migrations/0025_eventauthorisation.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0024_auto_20160229_2042'), + ] + + operations = [ + migrations.CreateModel( + name='EventAuthorisation', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('email', models.EmailField(max_length=254)), + ('name', models.CharField(max_length=255)), + ('uni_id', models.CharField(max_length=10, null=True, verbose_name=b'University ID', blank=True)), + ('account_code', models.CharField(max_length=50, null=True, blank=True)), + ('po', models.CharField(max_length=255, null=True, verbose_name=b'purchase order', blank=True)), + ('amount', models.DecimalField(verbose_name=b'authorisation amount', max_digits=10, decimal_places=2)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('event', models.ForeignKey(related_name='authroisations', to='RIGS.Event')), + ], + ), + ] diff --git a/RIGS/models.py b/RIGS/models.py index 0650d81c..350f406a 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -385,6 +385,10 @@ class Event(models.Model, RevisionMixin): def confirmed(self): return (self.status == self.BOOKED or self.status == self.CONFIRMED) + @property + def authorised(self): + return self.authroisations.latest('created_at').amount >= self.total + @property def has_start_time(self): return self.start_time is not None @@ -501,6 +505,30 @@ class EventCrew(models.Model): notes = models.TextField(blank=True, null=True) +class EventAuthorisation(models.Model): + event = models.ForeignKey('Event', related_name='authroisations') + email = models.EmailField() + name = models.CharField(max_length=255) + uni_id = models.CharField(max_length=10, blank=True, null=True, verbose_name="University ID") + account_code = models.CharField(max_length=50, blank=True, null=True) + po = models.CharField(max_length=255, blank=True, null=True, verbose_name="purchase order") + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") + created_at = models.DateTimeField(auto_now_add=True) + + def clean(self): + if self.amount != self.event.total: + raise ValidationError("The amount authorised must equal the total for the event") + if self.event.organisation and self.event.organisation.union_account: + # Is a union account, requires username and account number + if self.uni_id is None or self.uni_id == "" or self.account_code is None or self.account_code == "": + raise ValidationError("Internal clients require a University ID number and an account code") + else: + # Is an external client, only requires PO + if self.po is None or self.po == "": + raise ValidationError("External clients require a Purchase Order number") + return super(EventAuthorisation, self).clean() + + @python_2_unicode_compatible class Invoice(models.Model): event = models.OneToOneField('Event') diff --git a/RIGS/test_models.py b/RIGS/test_models.py index a7896cbe..0cbe58f1 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -1,5 +1,6 @@ import pytz from django.conf import settings +from django.core.exceptions import ValidationError from django.test import TestCase from RIGS import models from datetime import date, timedelta, datetime, time @@ -327,3 +328,53 @@ class EventPricingTestCase(TestCase): def test_grand_total(self): self.assertEqual(self.e1.total, Decimal('84.48')) self.assertEqual(self.e2.total, Decimal('419.32')) + + +class EventAuthorisationTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.person = models.Person.objects.create(name='Authorisation Test Person') + cls.organisation = models.Organisation.objects.create(name='Authorisation Test Organisation') + cls.event = models.Event.objects.create(name="AuthorisationTestCase", person=cls.person, + start_date=date.today()) + # Add some items + models.EventItem.objects.create(event=cls.event, name="Authorisation test item", quantity=2, cost=123.45, + order=1) + + def test_validation(self): + auth = models.EventAuthorisation(event=self.event, email="authroisation@model.test.case", name="Test Auth") + + auth.amount = self.event.total - 1 + self.assertRaises(ValidationError, auth.clean) + auth.amount = self.event.total + + # Test for externals first + self.assertRaises(ValidationError, auth.clean) + self.event.organisation = self.organisation + self.assertRaises(ValidationError, auth.clean) + auth.po = "TEST123" + self.assertIsNone(auth.clean()) + + auth.po = None + self.organisation.union_account = True + self.assertRaises(ValidationError, auth.clean) + auth.uni_id = "1234567" + self.assertRaises(ValidationError, auth.clean) + auth.account_code = "TST AUTH 12345" + self.assertIsNone(auth.clean()) + + def test_event_property(self): + auth1 = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", + name="Test Auth 1", amount=self.event.total - 1) + self.assertFalse(self.event.authorised) + auth1.amount = self.event.total + auth1.save() + self.assertTrue(self.event.authorised) + + auth2 = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", + name="Test Auth 2", amount=self.event.total - 1) + self.assertEqual(auth2.pk, self.event.authroisations.latest('created_at').pk) + self.assertFalse(self.event.authorised) + auth2.amount = self.event.total + 1 + auth2.save() + self.assertTrue(self.event.authorised) From e65e97b1a36e7b6311ceb3828ead79c70e72f48c Mon Sep 17 00:00:00 2001 From: Tom Price Date: Thu, 6 Apr 2017 22:26:05 +0100 Subject: [PATCH 02/44] Client facing authorisation procedures. Add forms, views, templates and URLs. Remove created at in favour of the built in versioning as that's much more accurate. Switch to a OneToOneField with EventAuthorisation -> event as a result of this. Move validation from models to forms where it probably belongs. Provide more descriptive errors. Add success page for authorisation. --- RIGS/forms.py | 34 +++++ ...26_remove_eventauthorisation_created_at.py | 18 +++ .../0027_eventauthorisation_event_singular.py | 19 +++ RIGS/models.py | 19 +-- RIGS/rigboard.py | 96 ++++++++++++-- RIGS/templates/RIGS/client_eventdetails.html | 78 ++++++++++++ .../RIGS/eventauthorisation_form.html | 118 ++++++++++++++++++ .../RIGS/eventauthorisation_success.html | 69 ++++++++++ RIGS/urls.py | 36 ++++-- templates/base_client.html | 109 ++++++++++++++++ 10 files changed, 555 insertions(+), 41 deletions(-) create mode 100644 RIGS/migrations/0026_remove_eventauthorisation_created_at.py create mode 100644 RIGS/migrations/0027_eventauthorisation_event_singular.py create mode 100644 RIGS/templates/RIGS/client_eventdetails.html create mode 100644 RIGS/templates/RIGS/eventauthorisation_form.html create mode 100644 RIGS/templates/RIGS/eventauthorisation_success.html create mode 100644 templates/base_client.html diff --git a/RIGS/forms.py b/RIGS/forms.py index e1e95012..c19ac5b8 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -141,3 +141,37 @@ class EventForm(forms.ModelForm): 'end_time', 'meet_at', 'access_at', 'description', 'notes', 'mic', 'person', 'organisation', 'dry_hire', 'checked_in_by', 'status', 'collector', 'purchase_order'] + + +class BaseClientEventAuthorisationForm(forms.ModelForm): + tos = forms.BooleanField(required=True, label="Terms of hire") + name = forms.CharField(label="Your Name") + + def clean(self): + if self.cleaned_data.get('amount') != self.instance.event.total: + self.add_error('amount', 'The amount authorised must equal the total for the event.') + return super(BaseClientEventAuthorisationForm, self).clean() + + class Meta: + abstract = True + + +class InternalClientEventAuthorisationForm(BaseClientEventAuthorisationForm): + def __init__(self, **kwargs): + super(InternalClientEventAuthorisationForm, self).__init__(**kwargs) + self.fields['uni_id'].required = True + self.fields['account_code'].required = True + + class Meta: + model = models.EventAuthorisation + fields = ('tos', 'name', 'amount', 'uni_id', 'account_code') + + +class ExternalClientEventAuthorisationForm(BaseClientEventAuthorisationForm): + def __init__(self, **kwargs): + super(ExternalClientEventAuthorisationForm, self).__init__(**kwargs) + self.fields['po'].required = True + + class Meta: + model = models.EventAuthorisation + fields = ('tos', 'name', 'amount', 'po') diff --git a/RIGS/migrations/0026_remove_eventauthorisation_created_at.py b/RIGS/migrations/0026_remove_eventauthorisation_created_at.py new file mode 100644 index 00000000..c5ddc143 --- /dev/null +++ b/RIGS/migrations/0026_remove_eventauthorisation_created_at.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0025_eventauthorisation'), + ] + + operations = [ + migrations.RemoveField( + model_name='eventauthorisation', + name='created_at', + ), + ] diff --git a/RIGS/migrations/0027_eventauthorisation_event_singular.py b/RIGS/migrations/0027_eventauthorisation_event_singular.py new file mode 100644 index 00000000..d7796895 --- /dev/null +++ b/RIGS/migrations/0027_eventauthorisation_event_singular.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0026_remove_eventauthorisation_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='eventauthorisation', + name='event', + field=models.OneToOneField(related_name='authorisation', to='RIGS.Event'), + ), + ] diff --git a/RIGS/models.py b/RIGS/models.py index 350f406a..37225697 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -387,7 +387,7 @@ class Event(models.Model, RevisionMixin): @property def authorised(self): - return self.authroisations.latest('created_at').amount >= self.total + return self.authorisation.amount == self.total @property def has_start_time(self): @@ -505,28 +505,15 @@ class EventCrew(models.Model): notes = models.TextField(blank=True, null=True) +@reversion.register class EventAuthorisation(models.Model): - event = models.ForeignKey('Event', related_name='authroisations') + event = models.OneToOneField('Event', related_name='authorisation') email = models.EmailField() name = models.CharField(max_length=255) uni_id = models.CharField(max_length=10, blank=True, null=True, verbose_name="University ID") account_code = models.CharField(max_length=50, blank=True, null=True) po = models.CharField(max_length=255, blank=True, null=True, verbose_name="purchase order") amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") - created_at = models.DateTimeField(auto_now_add=True) - - def clean(self): - if self.amount != self.event.total: - raise ValidationError("The amount authorised must equal the total for the event") - if self.event.organisation and self.event.organisation.union_account: - # Is a union account, requires username and account number - if self.uni_id is None or self.uni_id == "" or self.account_code is None or self.account_code == "": - raise ValidationError("Internal clients require a University ID number and an account code") - else: - # Is an external client, only requires PO - if self.po is None or self.po == "": - raise ValidationError("External clients require a Purchase Order number") - return super(EventAuthorisation, self).clean() @python_2_unicode_compatible diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 81cf564e..d602d66b 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -10,7 +10,9 @@ from django.template import RequestContext from django.template.loader import get_template from django.conf import settings from django.core.urlresolvers import reverse +from django.core import signing from django.http import HttpResponse +from django.core.exceptions import SuspiciousOperation from django.db.models import Q from django.contrib import messages from z3c.rml import rml2pdf @@ -36,15 +38,17 @@ class RigboardIndex(generic.TemplateView): context['events'] = models.Event.objects.current_events() return context + class WebCalendar(generic.TemplateView): template_name = 'RIGS/calendar.html' def get_context_data(self, **kwargs): context = super(WebCalendar, self).get_context_data(**kwargs) - context['view'] = kwargs.get('view','') - context['date'] = kwargs.get('date','') + context['view'] = kwargs.get('view', '') + context['date'] = kwargs.get('date', '') return context + class EventDetail(generic.DetailView): model = models.Event @@ -53,7 +57,6 @@ class EventOembed(generic.View): model = models.Event def get(self, request, pk=None): - embed_url = reverse('event_embed', args=[pk]) full_url = "{0}://{1}{2}".format(request.scheme, request.META['HTTP_HOST'], embed_url) @@ -85,7 +88,6 @@ class EventCreate(generic.CreateView): if re.search('"-\d+"', form['items_json'].value()): messages.info(self.request, "Your item changes have been saved. Please fix the errors and save the event.") - # Get some other objects to include in the form. Used when there are errors but also nice and quick. for field, model in form.related_models.iteritems(): value = form[field].value() @@ -117,15 +119,17 @@ class EventUpdate(generic.UpdateView): def get_success_url(self): return reverse_lazy('event_detail', kwargs={'pk': self.object.pk}) + class EventDuplicate(EventUpdate): def get_object(self, queryset=None): - old = super(EventDuplicate, self).get_object(queryset) # Get the object (the event you're duplicating) - new = copy.copy(old) # Make a copy of the object in memory - new.based_on = old # Make the new event based on the old event + old = super(EventDuplicate, self).get_object(queryset) # Get the object (the event you're duplicating) + new = copy.copy(old) # Make a copy of the object in memory + new.based_on = old # Make the new event based on the old event new.purchase_order = None - if self.request.method in ('POST', 'PUT'): # This only happens on save (otherwise items won't display in editor) - new.pk = None # This means a new event will be created on save, and all items will be re-created + if self.request.method in ( + 'POST', 'PUT'): # This only happens on save (otherwise items won't display in editor) + new.pk = None # This means a new event will be created on save, and all items will be re-created else: messages.info(self.request, 'Event data duplicated but not yet saved. Click save to complete operation.') @@ -136,6 +140,7 @@ class EventDuplicate(EventUpdate): context["duplicate"] = True return context + class EventPrint(generic.View): def get(self, request, pk): object = get_object_or_404(models.Event, pk=pk) @@ -145,8 +150,7 @@ class EventPrint(generic.View): merger = PdfFileMerger() for copy in copies: - - context = RequestContext(request, { # this should be outside the loop, but bug in 1.8.2 prevents this + context = RequestContext(request, { # this should be outside the loop, but bug in 1.8.2 prevents this 'object': object, 'fonts': { 'opensans': { @@ -154,8 +158,8 @@ class EventPrint(generic.View): 'bold': 'RIGS/static/fonts/OPENSANS-BOLD.TTF', } }, - 'copy':copy, - 'current_user':request.user, + 'copy': copy, + 'current_user': request.user, }) # context['copy'] = copy # this is the way to do it once we upgrade to Django 1.8.3 @@ -183,6 +187,7 @@ class EventPrint(generic.View): response.write(merged.getvalue()) return response + class EventArchive(generic.ArchiveIndexView): model = models.Event date_field = "start_date" @@ -219,3 +224,68 @@ class EventArchive(generic.ArchiveIndexView): messages.add_message(self.request, messages.WARNING, "No events have been found matching those criteria.") return qs + + +class EventAuthorise(generic.UpdateView): + template_name = 'RIGS/eventauthorisation_form.html' + success_template = 'RIGS/eventauthorisation_success.html' + + def form_valid(self, form): + # TODO: send email confirmation + self.template_name = self.success_template + messages.add_message(self.request, messages.SUCCESS, + 'Success! Your event has been authorised. You will also receive email confirmation.') + return self.render_to_response(self.get_context_data()) + + @property + def event(self): + return models.Event.objects.select_related('organisation', 'person', 'venue').get(pk=self.kwargs['pk']) + + def get_object(self, queryset=None): + return self.event.authorisation + + def get_form_class(self): + if self.event.organisation is not None and self.event.organisation.union_account: + return forms.InternalClientEventAuthorisationForm + else: + return forms.ExternalClientEventAuthorisationForm + + def get_context_data(self, **kwargs): + context = super(EventAuthorise, self).get_context_data(**kwargs) + context['event'] = self.event + + if self.get_form_class() is forms.InternalClientEventAuthorisationForm: + context['internal'] = True + else: + context['internal'] = False + + context['tos_url'] = settings.TERMS_OF_HIRE_URL + return context + + def get(self, request, *args, **kwargs): + if self.get_object() is not None and self.get_object().pk is not None: + if self.event.authorised: + messages.add_message(self.request, messages.WARNING, + "This event has already been authorised. Please confirm you wish to reauthorise") + else: + messages.add_message(self.request, messages.WARNING, + "This event has already been authorised, but the amount has changed." + + "Please check the amount and reauthorise.") + return super(EventAuthorise, self).get(request, *args, **kwargs) + + def get_form(self, **kwargs): + form = super(EventAuthorise, self).get_form(**kwargs) + form.instance.event = self.event + form.instance.email = self.request.email + return form + + def dispatch(self, request, *args, **kwargs): + # Verify our signature matches up and all is well with the integrity of the URL + try: + data = signing.loads(kwargs.get('hmac')) + assert int(kwargs.get('pk')) == int(data.get('pk')) + request.email = data['email'] + except (signing.BadSignature, AssertionError, KeyError): + raise SuspiciousOperation( + "The security integrity of that URL is invalid. Please contact your event MIC to obtain a new URL") + return super(EventAuthorise, self).dispatch(request, *args, **kwargs) diff --git a/RIGS/templates/RIGS/client_eventdetails.html b/RIGS/templates/RIGS/client_eventdetails.html new file mode 100644 index 00000000..4f3810f8 --- /dev/null +++ b/RIGS/templates/RIGS/client_eventdetails.html @@ -0,0 +1,78 @@ +
+
+
+
Contact Details
+
+
+
Person
+
+ {% if event.person %} + {{ event.person.name }} + {% endif %} +
+ +
Email
+
+ {{ event.person.email }} +
+ +
Phone Number
+
{{ event.person.phone }}
+
+
+
+ {% if event.organisation %} +
+
Organisation
+
+
+
Organisation
+
+ {{ event.organisation.name }} +
+ +
Phone Number
+
{{ object.organisation.phone }}
+
+
+
+ {% endif %} +
+ +
+
+
Event Info
+
+
+
Event Venue
+
+ {% if object.venue %} + + {{ object.venue }} + + {% endif %} +
+ +
Status
+
{{ event.get_status_display }}
+ +
 
+ +
Access From
+
{{ event.access_at|date:"D d M Y H:i"|default:"" }}
+ +
Event Starts
+
{{ event.start_date|date:"D d M Y" }} {{ event.start_time|date:"H:i" }}
+ +
Event Ends
+
{{ event.end_date|date:"D d M Y" }} {{ event.end_time|date:"H:i" }}
+ +
 
+ +
Event Description
+
{{ event.description|linebreaksbr }}
+
+
+
+
+
diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html new file mode 100644 index 00000000..e9999a73 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -0,0 +1,118 @@ +{% extends 'base_client.html' %} +{% load widget_tweaks %} + +{% block title %} + {% if event.is_rig %}N{{ event.pk|stringformat:"05d" }}{% else %}{{ event.pk }}{% endif %} | {{ event.name }} +{% endblock %} + +{% block content %} +
+
+

+ {% if event.is_rig %}N{{ event.pk|stringformat:"05d" }}{% else %}{{ event.pk }}{% endif %} + | {{ event.name }} {% if event.dry_hire %}Dry Hire{% endif %} +

+
+
+ +
+ {% include 'RIGS/client_eventdetails.html' %} +
+ +
+
+ {% with object=event %} + {% include 'RIGS/item_table.html' %} + {% endwith %} +
+
+ +
+
+
+
Event Authorisation
+ +
+
{% csrf_token %} + {% include 'form_errors.html' %} +
+
+
+ + +
+ {% render_field form.name class+="form-control" %} +
+
+ + {% if internal %} +
+ +
+ {% render_field form.uni_id class+="form-control" %} +
+
+ {% endif %} +
+ +
+ {% if internal %} +
+ +
+ {% render_field form.account_code class+="form-control" %} +
+
+ {% else %} +
+ +
+ {% render_field form.po class+="form-control" %} +
+
+ {% endif %} + +
+ +
+ {% render_field form.amount class+="form-control" %} +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_success.html b/RIGS/templates/RIGS/eventauthorisation_success.html new file mode 100644 index 00000000..2b899076 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_success.html @@ -0,0 +1,69 @@ +{% extends 'base_client.html' %} +{% load widget_tweaks %} + +{% block title %} + {% if event.is_rig %}N{{ event.pk|stringformat:"05d" }}{% else %}{{ event.pk }}{% endif %} | {{ event.name }} +{% endblock %} + +{% block content %} +
+
+

+ {% if event.is_rig %}N{{ event.pk|stringformat:"05d" }}{% else %}{{ event.pk }}{% endif %} + | {{ event.name }} {% if event.dry_hire %}Dry Hire{% endif %} +

+
+
+ + {% include 'RIGS/client_eventdetails.html' %} + +
+
+ {% with object=event %} + {% include 'RIGS/item_table.html' %} + {% endwith %} +
+
+ +
+
+
+
Event Authorisation
+ +
+
+
+
+
Name
+
{{ object.name }}
+ +
Email
+
{{ object.email }}
+ + {% if internal %} +
University ID
+
{{ object.uni_id }}
+ {% endif %} +
+
+ +
+
+ {% if internal %} +
Account code
+
{{ object.account_code }}
+ {% else %} +
PO
+
{{ object.po }}
+ {% endif %} + +
Authorised amount
+
£ {{ object.amount|floatformat:2 }}
+
+
+
+
+
+
+
+{% endblock %} diff --git a/RIGS/urls.py b/RIGS/urls.py index 15bb0990..2273fd01 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -16,7 +16,8 @@ urlpatterns = patterns('', url('^user/login/$', 'RIGS.views.login', name='login'), url('^user/login/embed/$', xframe_options_exempt(views.login_embed), name='login_embed'), - url(r'^user/password_reset/$', 'django.contrib.auth.views.password_reset', {'password_reset_form': forms.PasswordReset}), + url(r'^user/password_reset/$', 'django.contrib.auth.views.password_reset', + {'password_reset_form': forms.PasswordReset}), # People url(r'^people/$', permission_required_with_403('RIGS.view_person')(views.PersonList.as_view()), @@ -70,9 +71,12 @@ urlpatterns = patterns('', # Rigboard url(r'^rigboard/$', login_required(rigboard.RigboardIndex.as_view()), name='rigboard'), - url(r'^rigboard/calendar/$', login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'), - url(r'^rigboard/calendar/(?P(month|week|day))/$', login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'), - url(r'^rigboard/calendar/(?P(month|week|day))/(?P(\d{4}-\d{2}-\d{2}))/$', login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'), + url(r'^rigboard/calendar/$', login_required()(rigboard.WebCalendar.as_view()), + name='web_calendar'), + url(r'^rigboard/calendar/(?P(month|week|day))/$', + login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'), + url(r'^rigboard/calendar/(?P(month|week|day))/(?P(\d{4}-\d{2}-\d{2}))/$', + login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'), url(r'^rigboard/archive/$', RedirectView.as_view(permanent=True, pattern_name='event_archive')), url(r'^rigboard/activity/$', permission_required_with_403('RIGS.view_event')(versioning.ActivityTable.as_view()), @@ -82,10 +86,12 @@ urlpatterns = patterns('', name='activity_feed'), url(r'^event/(?P\d+)/$', - permission_required_with_403('RIGS.view_event', oembed_view="event_oembed")(rigboard.EventDetail.as_view()), + permission_required_with_403('RIGS.view_event', oembed_view="event_oembed")( + rigboard.EventDetail.as_view()), name='event_detail'), url(r'^event/(?P\d+)/embed/$', - xframe_options_exempt(login_required(login_url='/user/login/embed/')(rigboard.EventEmbed.as_view())), + xframe_options_exempt( + login_required(login_url='/user/login/embed/')(rigboard.EventEmbed.as_view())), name='event_embed'), url(r'^event/(?P\d+)/oembed_json/$', rigboard.EventOembed.as_view(), @@ -109,7 +115,8 @@ urlpatterns = patterns('', permission_required_with_403('RIGS.view_event')(versioning.VersionHistory.as_view()), name='event_history', kwargs={'model': models.Event}), - + url(r'^event/(?P\d+)/(?P[-:\w]+)/$', rigboard.EventAuthorise.as_view(), + name='event_authorise'), # Finance url(r'^invoice/$', @@ -152,17 +159,22 @@ urlpatterns = patterns('', name='profile_detail'), url(r'^user/edit/$', login_required(views.ProfileUpdateSelf.as_view()), name='profile_update_self'), - url(r'^user/reset_api_key$', login_required(views.ResetApiKey.as_view(permanent=False)), name='reset_api_key'), + url(r'^user/reset_api_key$', login_required(views.ResetApiKey.as_view(permanent=False)), + name='reset_api_key'), # ICS Calendar - API key authentication - url(r'^ical/(?P\d+)/(?P\w+)/rigs.ics$', api_key_required(ical.CalendarICS()), name="ics_calendar"), + url(r'^ical/(?P\d+)/(?P\w+)/rigs.ics$', api_key_required(ical.CalendarICS()), + name="ics_calendar"), # API - url(r'^api/(?P\w+)/$', login_required(views.SecureAPIRequest.as_view()), name="api_secure"), - url(r'^api/(?P\w+)/(?P\d+)/$', login_required(views.SecureAPIRequest.as_view()), name="api_secure"), + url(r'^api/(?P\w+)/$', login_required(views.SecureAPIRequest.as_view()), + name="api_secure"), + url(r'^api/(?P\w+)/(?P\d+)/$', login_required(views.SecureAPIRequest.as_view()), + name="api_secure"), # Legacy URL's - url(r'^rig/show/(?P\d+)/$', RedirectView.as_view(permanent=True, pattern_name='event_detail')), + url(r'^rig/show/(?P\d+)/$', + RedirectView.as_view(permanent=True, pattern_name='event_detail')), url(r'^bookings/$', RedirectView.as_view(permanent=True, pattern_name='rigboard')), url(r'^bookings/past/$', RedirectView.as_view(permanent=True, pattern_name='event_archive')), ) diff --git a/templates/base_client.html b/templates/base_client.html new file mode 100644 index 00000000..b8cd614b --- /dev/null +++ b/templates/base_client.html @@ -0,0 +1,109 @@ +{% load static from staticfiles %} +{% load raven %} + + + + + + {% block title %}{% endblock %} | Rig Information Gathering System + + + + + + + + + + {% block css %} + {% endblock %} + + + + + {% block preload_js %} + {% endblock %} + + {% block extra-head %}{% endblock %} + + + +{% include "analytics.html" %} + + +
+
+ {% block content-header %} + {% if error %} +
{{ error }}
{% endif %} + {% if info %} +
{{ info }}
{% endif %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endblock %} + + {% block content %}{% endblock %} +
+ + +
+ + + + + + + + + + + +{% block js %} +{% endblock %} + + From 1670b190c2062ac0e540619baeccba509ca5df51 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 18:11:49 +0100 Subject: [PATCH 03/44] Add tests for the client side authorisation --- RIGS/rigboard.py | 3 +- RIGS/test_functional.py | 346 ++++++++++++++++++++++++++-------------- RIGS/test_models.py | 30 ---- 3 files changed, 231 insertions(+), 148 deletions(-) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index d602d66b..2d98e80e 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -231,6 +231,7 @@ class EventAuthorise(generic.UpdateView): success_template = 'RIGS/eventauthorisation_success.html' def form_valid(self, form): + self.object = form.save() # TODO: send email confirmation self.template_name = self.success_template messages.add_message(self.request, messages.SUCCESS, @@ -242,7 +243,7 @@ class EventAuthorise(generic.UpdateView): return models.Event.objects.select_related('organisation', 'person', 'venue').get(pk=self.kwargs['pk']) def get_object(self, queryset=None): - return self.event.authorisation + return getattr(self.event, 'authorisation', None) def get_form_class(self): if self.event.organisation is not None and self.event.organisation.union_account: diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index f6c60865..13d6871b 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -4,9 +4,11 @@ import re from datetime import date, timedelta import reversion -from django.core import mail +from django.core import mail, signing +from django.core.urlresolvers import reverse from django.db import transaction -from django.test import LiveServerTestCase +from django.http import HttpResponseBadRequest +from django.test import LiveServerTestCase, TestCase from django.test.client import Client from selenium import webdriver from selenium.common.exceptions import StaleElementReferenceException, WebDriverException @@ -17,10 +19,9 @@ from RIGS import models class UserRegistrationTest(LiveServerTestCase): - def setUp(self): self.browser = webdriver.Firefox() - self.browser.implicitly_wait(3) # Set implicit wait session wide + self.browser.implicitly_wait(3) # Set implicit wait session wide os.environ['RECAPTCHA_TESTING'] = 'True' def tearDown(self): @@ -149,17 +150,16 @@ class UserRegistrationTest(LiveServerTestCase): class EventTest(LiveServerTestCase): - def setUp(self): self.profile = models.Profile( username="EventTest", first_name="Event", last_name="Test", initials="ETU", is_superuser=True) self.profile.set_password("EventTestPassword") self.profile.save() - self.vatrate = models.VatRate.objects.create(start_at='2014-03-05',rate=0.20,comment='test1') - + self.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1') + self.browser = webdriver.Firefox() - self.browser.implicitly_wait(3) # Set implicit wait session wide + self.browser.implicitly_wait(3) # Set implicit wait session wide self.browser.maximize_window() os.environ['RECAPTCHA_TESTING'] = 'True' @@ -203,7 +203,7 @@ class EventTest(LiveServerTestCase): # Gets redirected to login and back self.authenticate('/event/create/') - wait = WebDriverWait(self.browser, 10) #setup WebDriverWait to use later (to wait for animations) + wait = WebDriverWait(self.browser, 10) # setup WebDriverWait to use later (to wait for animations) wait.until(animation_is_finished()) @@ -370,26 +370,28 @@ class EventTest(LiveServerTestCase): wait.until(animation_is_finished()) modal = self.browser.find_element_by_id("itemModal") modal.find_element_by_id("item_name").send_keys("Test Item 1") - modal.find_element_by_id("item_description").send_keys("This is an item description\nthat for reasons unkown spans two lines") + modal.find_element_by_id("item_description").send_keys( + "This is an item description\nthat for reasons unkown spans two lines") e = modal.find_element_by_id("item_quantity") e.click() e.send_keys(Keys.UP) e.send_keys(Keys.UP) e = modal.find_element_by_id("item_cost") e.send_keys("23.95") - e.send_keys(Keys.ENTER) # enter submit + e.send_keys(Keys.ENTER) # enter submit # Confirm item has been saved to json field objectitems = self.browser.execute_script("return objectitems;") self.assertEqual(1, len(objectitems)) - testitem = objectitems["-1"]['fields'] # as we are deliberately creating this we know the ID + testitem = objectitems["-1"]['fields'] # as we are deliberately creating this we know the ID self.assertEqual("Test Item 1", testitem['name']) - self.assertEqual("2", testitem['quantity']) # test a couple of "worse case" fields + self.assertEqual("2", testitem['quantity']) # test a couple of "worse case" fields # See new item appear in table - row = self.browser.find_element_by_id('item--1') # ID number is known, see above + row = self.browser.find_element_by_id('item--1') # ID number is known, see above self.assertIn("Test Item 1", row.find_element_by_xpath('//span[@class="name"]').text) - self.assertIn("This is an item description", row.find_element_by_xpath('//div[@class="item-description"]').text) + self.assertIn("This is an item description", + row.find_element_by_xpath('//div[@class="item-description"]').text) self.assertEqual(u'£ 23.95', row.find_element_by_xpath('//tr[@id="item--1"]/td[2]').text) self.assertEqual("2", row.find_element_by_xpath('//td[@class="quantity"]').text) self.assertEqual(u'£ 47.90', row.find_element_by_xpath('//tr[@id="item--1"]/td[4]').text) @@ -431,92 +433,95 @@ class EventTest(LiveServerTestCase): # See redirected to success page successTitle = self.browser.find_element_by_xpath('//h1').text event = models.Event.objects.get(name='Test Event Name') - self.assertIn("N0000%d | Test Event Name"%event.pk, successTitle) + self.assertIn("N0000%d | Test Event Name" % event.pk, successTitle) except WebDriverException: # This is a dirty workaround for wercker being a bit funny and not running it correctly. # Waiting for wercker to get back to me about this pass def testEventDuplicate(self): - testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), description="start future no end", purchase_order="TESTPO") + testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, + start_date=date.today() + timedelta(days=6), + description="start future no end", purchase_order="TESTPO") item1 = models.EventItem( - event=testEvent, - name="Test Item 1", - cost="10.00", - quantity="1", - order=1 - ).save() + event=testEvent, + name="Test Item 1", + cost="10.00", + quantity="1", + order=1 + ).save() item2 = models.EventItem( - event=testEvent, - name="Test Item 2", - description="Foo", - cost="9.72", - quantity="3", - order=2, - ).save() + event=testEvent, + name="Test Item 2", + description="Foo", + cost="9.72", + quantity="3", + order=2, + ).save() self.browser.get(self.live_server_url + '/event/' + str(testEvent.pk) + '/duplicate/') self.authenticate('/event/' + str(testEvent.pk) + '/duplicate/') - wait = WebDriverWait(self.browser, 10) #setup WebDriverWait to use later (to wait for animations) + wait = WebDriverWait(self.browser, 10) # setup WebDriverWait to use later (to wait for animations) save = self.browser.find_element_by_xpath( '(//button[@type="submit"])[3]') form = self.browser.find_element_by_tag_name('form') - # Check the items are visible - table = self.browser.find_element_by_id('item-table') # ID number is known, see above + table = self.browser.find_element_by_id('item-table') # ID number is known, see above self.assertIn("Test Item 1", table.text) self.assertIn("Test Item 2", table.text) # Check the info message is visible - self.assertIn("Event data duplicated but not yet saved",self.browser.find_element_by_id('content').text) + self.assertIn("Event data duplicated but not yet saved", self.browser.find_element_by_id('content').text) # Add item form.find_element_by_xpath('//button[contains(@class, "item-add")]').click() wait.until(animation_is_finished()) modal = self.browser.find_element_by_id("itemModal") modal.find_element_by_id("item_name").send_keys("Test Item 3") - modal.find_element_by_id("item_description").send_keys("This is an item description\nthat for reasons unkown spans two lines") + modal.find_element_by_id("item_description").send_keys( + "This is an item description\nthat for reasons unkown spans two lines") e = modal.find_element_by_id("item_quantity") e.click() e.send_keys(Keys.UP) e.send_keys(Keys.UP) e = modal.find_element_by_id("item_cost") e.send_keys("23.95") - e.send_keys(Keys.ENTER) # enter submit + e.send_keys(Keys.ENTER) # enter submit # Attempt to save save.click() - self.assertNotIn("N0000%d"%testEvent.pk, self.browser.find_element_by_xpath('//h1').text) - self.assertNotIn("Event data duplicated but not yet saved", self.browser.find_element_by_id('content').text) # Check info message not visible + self.assertNotIn("N0000%d" % testEvent.pk, self.browser.find_element_by_xpath('//h1').text) + self.assertNotIn("Event data duplicated but not yet saved", + self.browser.find_element_by_id('content').text) # Check info message not visible # Check the new items are visible - table = self.browser.find_element_by_id('item-table') # ID number is known, see above + table = self.browser.find_element_by_id('item-table') # ID number is known, see above self.assertIn("Test Item 1", table.text) self.assertIn("Test Item 2", table.text) self.assertIn("Test Item 3", table.text) infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') - self.assertIn("N0000%d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) + self.assertIn("N0000%d" % testEvent.pk, + infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) # Check the PO hasn't carried through self.assertNotIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) + self.browser.get(self.live_server_url + '/event/' + str(testEvent.pk)) # Go back to the old event - - self.browser.get(self.live_server_url + '/event/' + str(testEvent.pk)) #Go back to the old event - - #Check that based-on hasn't crept into the old event + # Check that based-on hasn't crept into the old event infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') - self.assertNotIn("N0000%d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) + self.assertNotIn("N0000%d" % testEvent.pk, + infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) # Check the PO remains on the old event self.assertIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) # Check the items are as they were - table = self.browser.find_element_by_id('item-table') # ID number is known, see above + table = self.browser.find_element_by_id('item-table') # ID number is known, see above self.assertIn("Test Item 1", table.text) self.assertIn("Test Item 2", table.text) self.assertNotIn("Test Item 3", table.text) @@ -526,7 +531,7 @@ class EventTest(LiveServerTestCase): # Gets redirected to login and back self.authenticate('/event/create/') - wait = WebDriverWait(self.browser, 10) #setup WebDriverWait to use later (to wait for animations) + wait = WebDriverWait(self.browser, 10) # setup WebDriverWait to use later (to wait for animations) wait.until(animation_is_finished()) @@ -553,7 +558,6 @@ class EventTest(LiveServerTestCase): self.assertTrue(error.is_displayed()) self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text) - # Same date, end time before start time form = self.browser.find_element_by_tag_name('form') save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]') @@ -575,7 +579,6 @@ class EventTest(LiveServerTestCase): self.assertTrue(error.is_displayed()) self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text) - # Same date, end time before start time form = self.browser.find_element_by_tag_name('form') save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]') @@ -591,7 +594,6 @@ class EventTest(LiveServerTestCase): form.find_element_by_id('id_end_time').clear() form.find_element_by_id('id_end_time').send_keys('06:00') - # No end date, end time before start time form = self.browser.find_element_by_tag_name('form') save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]') @@ -612,7 +614,6 @@ class EventTest(LiveServerTestCase): self.assertTrue(error.is_displayed()) self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text) - # 2 dates, end after start form = self.browser.find_element_by_tag_name('form') save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]') @@ -623,24 +624,24 @@ class EventTest(LiveServerTestCase): form.find_element_by_id('id_end_date').send_keys('3015-04-26') form.find_element_by_id('id_start_time').clear() - + form.find_element_by_id('id_end_time').clear() - + # Attempt to save - should succeed save.click() - + # See redirected to success page successTitle = self.browser.find_element_by_xpath('//h1').text event = models.Event.objects.get(name='Test Event Name') - self.assertIn("N0000%d | Test Event Name"%event.pk, successTitle) - + self.assertIn("N0000%d | Test Event Name" % event.pk, successTitle) + def testRigNonRig(self): self.browser.get(self.live_server_url + '/event/create/') # Gets redirected to login and back self.authenticate('/event/create/') - wait = WebDriverWait(self.browser, 10) #setup WebDriverWait to use later (to wait for animations) - self.browser.implicitly_wait(3) #Set session-long wait (only works for non-existant DOM objects) + wait = WebDriverWait(self.browser, 10) # setup WebDriverWait to use later (to wait for animations) + self.browser.implicitly_wait(3) # Set session-long wait (only works for non-existant DOM objects) wait.until(animation_is_finished()) @@ -672,7 +673,8 @@ class EventTest(LiveServerTestCase): person = models.Person(name="Event Detail Person", email="eventdetail@person.tests.rigs", phone="123 123") person.save() with transaction.atomic(), reversion.create_revision(): - organisation = models.Organisation(name="Event Detail Organisation", email="eventdetail@organisation.tests.rigs", phone="123 456").save() + organisation = models.Organisation(name="Event Detail Organisation", + email="eventdetail@organisation.tests.rigs", phone="123 456").save() with transaction.atomic(), reversion.create_revision(): venue = models.Venue(name="Event Detail Venue").save() with transaction.atomic(), reversion.create_revision(): @@ -702,59 +704,84 @@ class EventTest(LiveServerTestCase): order=2, ).save() - - self.browser.get(self.live_server_url + '/event/%d'%event.pk) - self.authenticate('/event/%d/'%event.pk) - self.assertIn("N%05d | %s"%(event.pk, event.name), self.browser.find_element_by_xpath('//h1').text) + self.browser.get(self.live_server_url + '/event/%d' % event.pk) + self.authenticate('/event/%d/' % event.pk) + self.assertIn("N%05d | %s" % (event.pk, event.name), self.browser.find_element_by_xpath('//h1').text) personPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Contact Details")]/..') - self.assertEqual(person.name, personPanel.find_element_by_xpath('//dt[text()="Person"]/following-sibling::dd[1]').text) - self.assertEqual(person.email, personPanel.find_element_by_xpath('//dt[text()="Email"]/following-sibling::dd[1]').text) - self.assertEqual(person.phone, personPanel.find_element_by_xpath('//dt[text()="Phone Number"]/following-sibling::dd[1]').text) + self.assertEqual(person.name, + personPanel.find_element_by_xpath('//dt[text()="Person"]/following-sibling::dd[1]').text) + self.assertEqual(person.email, + personPanel.find_element_by_xpath('//dt[text()="Email"]/following-sibling::dd[1]').text) + self.assertEqual(person.phone, + personPanel.find_element_by_xpath('//dt[text()="Phone Number"]/following-sibling::dd[1]').text) organisationPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Contact Details")]/..') -class IcalTest(LiveServerTestCase): +class IcalTest(LiveServerTestCase): def setUp(self): self.all_events = set(range(1, 18)) self.current_events = (1, 2, 3, 6, 7, 8, 10, 11, 12, 14, 15, 16, 18) self.not_current_events = set(self.all_events) - set(self.current_events) - self.vatrate = models.VatRate.objects.create(start_at='2014-03-05',rate=0.20,comment='test1') + self.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1') self.profile = models.Profile( username="EventTest", first_name="Event", last_name="Test", initials="ETU", is_superuser=True) self.profile.set_password("EventTestPassword") self.profile.save() # produce 7 normal events - 5 current - 1 last week - 1 two years ago - 2 provisional - 2 confirmed - 3 booked - models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), description="start future no end") - models.Event.objects.create(name="TE E2", status=models.Event.PROVISIONAL, start_date=date.today(), description="start today no end") - models.Event.objects.create(name="TE E3", status=models.Event.CONFIRMED, start_date=date.today(), end_date=date.today(), description="start today with end today") - models.Event.objects.create(name="TE E4", status=models.Event.CONFIRMED, start_date=date.today()-timedelta(weeks=104), description="start past 2 years no end") - models.Event.objects.create(name="TE E5", status=models.Event.BOOKED, start_date=date.today()-timedelta(days=7), end_date=date.today()-timedelta(days=1), description="start past 1 week with end past") - models.Event.objects.create(name="TE E6", status=models.Event.BOOKED, start_date=date.today()-timedelta(days=2), end_date=date.today()+timedelta(days=2), description="start past, end future") - models.Event.objects.create(name="TE E7", status=models.Event.BOOKED, start_date=date.today()+timedelta(days=2), end_date=date.today()+timedelta(days=2), description="start + end in future") + models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, + start_date=date.today() + timedelta(days=6), description="start future no end") + models.Event.objects.create(name="TE E2", status=models.Event.PROVISIONAL, start_date=date.today(), + description="start today no end") + models.Event.objects.create(name="TE E3", status=models.Event.CONFIRMED, start_date=date.today(), + end_date=date.today(), description="start today with end today") + models.Event.objects.create(name="TE E4", status=models.Event.CONFIRMED, + start_date=date.today() - timedelta(weeks=104), + description="start past 2 years no end") + models.Event.objects.create(name="TE E5", status=models.Event.BOOKED, + start_date=date.today() - timedelta(days=7), + end_date=date.today() - timedelta(days=1), + description="start past 1 week with end past") + models.Event.objects.create(name="TE E6", status=models.Event.BOOKED, + start_date=date.today() - timedelta(days=2), + end_date=date.today() + timedelta(days=2), description="start past, end future") + models.Event.objects.create(name="TE E7", status=models.Event.BOOKED, + start_date=date.today() + timedelta(days=2), + end_date=date.today() + timedelta(days=2), description="start + end in future") # 2 cancelled - 1 current - models.Event.objects.create(name="TE E8", start_date=date.today()+timedelta(days=2), end_date=date.today()+timedelta(days=2), status=models.Event.CANCELLED, description="cancelled in future") - models.Event.objects.create(name="TE E9", start_date=date.today()-timedelta(days=1), end_date=date.today()+timedelta(days=2), status=models.Event.CANCELLED, description="cancelled and started") + models.Event.objects.create(name="TE E8", start_date=date.today() + timedelta(days=2), + end_date=date.today() + timedelta(days=2), status=models.Event.CANCELLED, + description="cancelled in future") + models.Event.objects.create(name="TE E9", start_date=date.today() - timedelta(days=1), + end_date=date.today() + timedelta(days=2), status=models.Event.CANCELLED, + description="cancelled and started") # 5 dry hire - 3 current - 1 cancelled models.Event.objects.create(name="TE E10", start_date=date.today(), dry_hire=True, description="dryhire today") - models.Event.objects.create(name="TE E11", start_date=date.today(), dry_hire=True, checked_in_by=self.profile, description="dryhire today, checked in") - models.Event.objects.create(name="TE E12", start_date=date.today()-timedelta(days=1), dry_hire=True, status=models.Event.BOOKED, description="dryhire past") - models.Event.objects.create(name="TE E13", start_date=date.today()-timedelta(days=2), dry_hire=True, checked_in_by=self.profile, description="dryhire past checked in") - models.Event.objects.create(name="TE E14", start_date=date.today(), dry_hire=True, status=models.Event.CANCELLED, description="dryhire today cancelled") + models.Event.objects.create(name="TE E11", start_date=date.today(), dry_hire=True, checked_in_by=self.profile, + description="dryhire today, checked in") + models.Event.objects.create(name="TE E12", start_date=date.today() - timedelta(days=1), dry_hire=True, + status=models.Event.BOOKED, description="dryhire past") + models.Event.objects.create(name="TE E13", start_date=date.today() - timedelta(days=2), dry_hire=True, + checked_in_by=self.profile, description="dryhire past checked in") + models.Event.objects.create(name="TE E14", start_date=date.today(), dry_hire=True, + status=models.Event.CANCELLED, description="dryhire today cancelled") # 4 non rig - 3 current models.Event.objects.create(name="TE E15", start_date=date.today(), is_rig=False, description="non rig today") - models.Event.objects.create(name="TE E16", start_date=date.today()+timedelta(days=1), is_rig=False, description="non rig tomorrow") - models.Event.objects.create(name="TE E17", start_date=date.today()-timedelta(days=1), is_rig=False, description="non rig yesterday") - models.Event.objects.create(name="TE E18", start_date=date.today(), is_rig=False, status=models.Event.CANCELLED, description="non rig today cancelled") + models.Event.objects.create(name="TE E16", start_date=date.today() + timedelta(days=1), is_rig=False, + description="non rig tomorrow") + models.Event.objects.create(name="TE E17", start_date=date.today() - timedelta(days=1), is_rig=False, + description="non rig yesterday") + models.Event.objects.create(name="TE E18", start_date=date.today(), is_rig=False, status=models.Event.CANCELLED, + description="non rig today cancelled") self.browser = webdriver.Firefox() - self.browser.implicitly_wait(3) # Set implicit wait session wide + self.browser.implicitly_wait(3) # Set implicit wait session wide os.environ['RECAPTCHA_TESTING'] = 'True' def tearDown(self): @@ -785,14 +812,15 @@ class IcalTest(LiveServerTestCase): # Completes and comes back to /user/ # Checks that no api key is displayed - self.assertEqual("No API Key Generated", self.browser.find_element_by_xpath("//div[@id='content']/div/div/div[3]/dl[2]/dd").text) + self.assertEqual("No API Key Generated", + self.browser.find_element_by_xpath("//div[@id='content']/div/div/div[3]/dl[2]/dd").text) self.assertEqual("No API Key Generated", self.browser.find_element_by_css_selector("pre").text) - + # Now creates an API key, and check a URL is displayed one self.browser.find_element_by_link_text("Generate API Key").click() self.assertIn("rigs.ics", self.browser.find_element_by_id("cal-url").text) self.assertNotIn("?", self.browser.find_element_by_id("cal-url").text) - + # Lets change everything so it's not the default value self.browser.find_element_by_xpath("//input[@value='rig']").click() self.browser.find_element_by_xpath("//input[@value='non-rig']").click() @@ -802,7 +830,9 @@ class IcalTest(LiveServerTestCase): self.browser.find_element_by_xpath("//input[@value='confirmed']").click() # and then check the url is correct - self.assertIn("rigs.ics?rig=false&non-rig=false&dry-hire=false&cancelled=true&provisional=false&confirmed=false", self.browser.find_element_by_id("cal-url").text) + self.assertIn( + "rigs.ics?rig=false&non-rig=false&dry-hire=false&cancelled=true&provisional=false&confirmed=false", + self.browser.find_element_by_id("cal-url").text) # Awesome - all seems to work @@ -815,27 +845,24 @@ class IcalTest(LiveServerTestCase): # Now creates an API key, and check a URL is displayed one self.browser.find_element_by_link_text("Generate API Key").click() - - c = Client() - + # Default settings - should have all non-cancelled events # Get the ical file (can't do this in selanium because reasons) icalUrl = self.browser.find_element_by_id("cal-url").text response = c.get(icalUrl) self.assertEqual(200, response.status_code) - #Check has entire file + # Check has entire file self.assertIn("BEGIN:VCALENDAR", response.content) self.assertIn("END:VCALENDAR", response.content) - expectedIn= [1,2,3,5,6,7,10,11,12,13,15,16,17] - for test in range(1,18): + expectedIn = [1, 2, 3, 5, 6, 7, 10, 11, 12, 13, 15, 16, 17] + for test in range(1, 18): if test in expectedIn: - self.assertIn("TE E"+str(test)+" ", response.content) + self.assertIn("TE E" + str(test) + " ", response.content) else: - self.assertNotIn("TE E"+str(test)+" ", response.content) - + self.assertNotIn("TE E" + str(test) + " ", response.content) # Only dry hires self.browser.find_element_by_xpath("//input[@value='rig']").click() @@ -845,13 +872,12 @@ class IcalTest(LiveServerTestCase): response = c.get(icalUrl) self.assertEqual(200, response.status_code) - expectedIn= [10,11,12,13] - for test in range(1,18): + expectedIn = [10, 11, 12, 13] + for test in range(1, 18): if test in expectedIn: - self.assertIn("TE E"+str(test)+" ", response.content) + self.assertIn("TE E" + str(test) + " ", response.content) else: - self.assertNotIn("TE E"+str(test)+" ", response.content) - + self.assertNotIn("TE E" + str(test) + " ", response.content) # Only provisional rigs self.browser.find_element_by_xpath("//input[@value='rig']").click() @@ -862,12 +888,12 @@ class IcalTest(LiveServerTestCase): response = c.get(icalUrl) self.assertEqual(200, response.status_code) - expectedIn= [1,2] - for test in range(1,18): + expectedIn = [1, 2] + for test in range(1, 18): if test in expectedIn: - self.assertIn("TE E"+str(test)+" ", response.content) + self.assertIn("TE E" + str(test) + " ", response.content) else: - self.assertNotIn("TE E"+str(test)+" ", response.content) + self.assertNotIn("TE E" + str(test) + " ", response.content) # Only cancelled non-rigs self.browser.find_element_by_xpath("//input[@value='rig']").click() @@ -879,12 +905,12 @@ class IcalTest(LiveServerTestCase): response = c.get(icalUrl) self.assertEqual(200, response.status_code) - expectedIn= [18] - for test in range(1,18): + expectedIn = [18] + for test in range(1, 18): if test in expectedIn: - self.assertIn("TE E"+str(test)+" ", response.content) + self.assertIn("TE E" + str(test) + " ", response.content) else: - self.assertNotIn("TE E"+str(test)+" ", response.content) + self.assertNotIn("TE E" + str(test) + " ", response.content) # Nothing selected self.browser.find_element_by_xpath("//input[@value='non-rig']").click() @@ -894,17 +920,19 @@ class IcalTest(LiveServerTestCase): response = c.get(icalUrl) self.assertEqual(200, response.status_code) - expectedIn= [] - for test in range(1,18): + expectedIn = [] + for test in range(1, 18): if test in expectedIn: - self.assertIn("TE E"+str(test)+" ", response.content) + self.assertIn("TE E" + str(test) + " ", response.content) else: - self.assertNotIn("TE E"+str(test)+" ", response.content) - - # Wow - that was a lot of tests + self.assertNotIn("TE E" + str(test) + " ", response.content) + + # Wow - that was a lot of tests + class animation_is_finished(object): """ Checks if animation is done """ + def __init__(self): pass @@ -915,3 +943,87 @@ class animation_is_finished(object): import time time.sleep(0.1) return finished + + +class ClientEventAuthorisationTest(TestCase): + auth_data = { + 'name': 'Test ABC', + 'po': '1234ABCZXY', + 'account_code': 'ABC TEST 12345', + 'uni_id': 1234567890, + 'tos': True + } + + def setUp(self): + venue = models.Venue.objects.create(name='Authorisation Test Venue') + client = models.Person.objects.create(name='Authorisation Test Person', email='authorisation@functional.test') + organisation = models.Organisation.objects.create(name='Authorisation Test Organisation', union_account=False) + self.event = models.Event.objects.create( + name='Authorisation Test', + start_date=date.today(), + venue=venue, + person=client, + organisation=organisation, + ) + self.hmac = signing.dumps({'pk': self.event.pk, 'email': 'authemail@function.test'}) + self.url = reverse('event_authorise', kwargs={'pk': self.event.pk, 'hmac': self.hmac}) + + def test_requires_valid_hmac(self): + bad_hmac = self.hmac[:-1] + url = reverse('event_authorise', kwargs={'pk': self.event.pk, 'hmac': bad_hmac}) + response = self.client.get(url) + self.assertIsInstance(response, HttpResponseBadRequest) + # TODO: Add some form of sensbile user facing error + # self.assertIn(response.content, "new URL") # check there is some level of sane instruction + + response = self.client.get(self.url) + self.assertContains(response, self.event.organisation.name) + + def test_generic_validation(self): + response = self.client.get(self.url) + self.assertContains(response, "Terms of Hire") + + response = self.client.post(self.url) + self.assertContains(response, "This field is required.", 4) + + data = self.auth_data + data['amount'] = self.event.total + 1 + + response = self.client.post(self.url, data) + self.assertContains(response, "The amount authorised must equal the total for the event") + self.assertNotContains(response, "This field is required.") + + data['amount'] = self.event.total + response = self.client.post(self.url, data) + self.assertContains(response, "Your event has been authorised") + + self.event.refresh_from_db() + self.assertTrue(self.event.authorised) + self.assertEqual(self.event.authorisation.email, "authemail@function.test") + + def test_internal_validation(self): + self.event.organisation.union_account = True + self.event.organisation.save() + + response = self.client.get(self.url) + self.assertContains(response, "Account code") + self.assertContains(response, "University ID") + + response = self.client.post(self.url) + self.assertContains(response, "This field is required.", 5) + + data = self.auth_data + response = self.client.post(self.url, data) + self.assertContains(response, "Your event has been authorised.") + + def test_duplicate_warning(self): + auth = models.EventAuthorisation.objects.create(event=self.event, name='Test ABC', email='dupe@functional.test', + po='ABC12345', amount=self.event.total) + response = self.client.get(self.url) + self.assertContains(response, 'This event has already been authorised.') + + auth.amount += 1 + auth.save() + + response = self.client.get(self.url) + self.assertContains(response, 'amount has changed') diff --git a/RIGS/test_models.py b/RIGS/test_models.py index 0cbe58f1..a858187d 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -341,28 +341,6 @@ class EventAuthorisationTestCase(TestCase): models.EventItem.objects.create(event=cls.event, name="Authorisation test item", quantity=2, cost=123.45, order=1) - def test_validation(self): - auth = models.EventAuthorisation(event=self.event, email="authroisation@model.test.case", name="Test Auth") - - auth.amount = self.event.total - 1 - self.assertRaises(ValidationError, auth.clean) - auth.amount = self.event.total - - # Test for externals first - self.assertRaises(ValidationError, auth.clean) - self.event.organisation = self.organisation - self.assertRaises(ValidationError, auth.clean) - auth.po = "TEST123" - self.assertIsNone(auth.clean()) - - auth.po = None - self.organisation.union_account = True - self.assertRaises(ValidationError, auth.clean) - auth.uni_id = "1234567" - self.assertRaises(ValidationError, auth.clean) - auth.account_code = "TST AUTH 12345" - self.assertIsNone(auth.clean()) - def test_event_property(self): auth1 = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", name="Test Auth 1", amount=self.event.total - 1) @@ -370,11 +348,3 @@ class EventAuthorisationTestCase(TestCase): auth1.amount = self.event.total auth1.save() self.assertTrue(self.event.authorised) - - auth2 = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", - name="Test Auth 2", amount=self.event.total - 1) - self.assertEqual(auth2.pk, self.event.authroisations.latest('created_at').pk) - self.assertFalse(self.event.authorised) - auth2.amount = self.event.total + 1 - auth2.save() - self.assertTrue(self.event.authorised) From cf11e8235f1c73600b57d4403c3186de6591b4e1 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 18:15:47 +0100 Subject: [PATCH 04/44] Add ID tagging to auth form to autoscroll on error --- RIGS/templates/RIGS/eventauthorisation_form.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html index e9999a73..d989b01e 100644 --- a/RIGS/templates/RIGS/eventauthorisation_form.html +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -30,10 +30,11 @@
-
Event Authorisation
+
Event Authorisation
-
{% csrf_token %} + + {% csrf_token %} {% include 'form_errors.html' %}
From 3b2aa02ae5dc2737537f99e9c0b2b4ae780b77ae Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 19:16:45 +0100 Subject: [PATCH 05/44] Add success notification emails. Enable RevisionMixin for EventAuthorisation. Add signal receivers for RIGS. Expand RIGS into an explicitly defined app to support signals. --- RIGS/__init__.py | 1 + RIGS/apps.py | 8 +++++ RIGS/models.py | 2 +- RIGS/rigboard.py | 5 ++- RIGS/signals.py | 34 +++++++++++++++++++ .../eventauthorisation_client_success.txt | 11 ++++++ .../RIGS/eventauthorisation_mic_success.txt | 5 +++ RIGS/test_models.py | 18 ++++++---- 8 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 RIGS/apps.py create mode 100644 RIGS/signals.py create mode 100644 RIGS/templates/RIGS/eventauthorisation_client_success.txt create mode 100644 RIGS/templates/RIGS/eventauthorisation_mic_success.txt diff --git a/RIGS/__init__.py b/RIGS/__init__.py index e69de29b..f6f847cc 100644 --- a/RIGS/__init__.py +++ b/RIGS/__init__.py @@ -0,0 +1 @@ +default_app_config = 'RIGS.apps.RIGSAppConfig' diff --git a/RIGS/apps.py b/RIGS/apps.py new file mode 100644 index 00000000..a0dc3724 --- /dev/null +++ b/RIGS/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class RIGSAppConfig(AppConfig): + name = 'RIGS' + + def ready(self): + import RIGS.signals diff --git a/RIGS/models.py b/RIGS/models.py index 37225697..d1f20446 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -506,7 +506,7 @@ class EventCrew(models.Model): @reversion.register -class EventAuthorisation(models.Model): +class EventAuthorisation(models.Model, RevisionMixin): event = models.OneToOneField('Event', related_name='authorisation') email = models.EmailField() name = models.CharField(max_length=255) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 2d98e80e..5584184d 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -3,6 +3,9 @@ import cStringIO as StringIO from io import BytesIO import urllib2 +import reversion +from django.core.mail import EmailMessage +from django.db import transaction from django.views import generic from django.core.urlresolvers import reverse_lazy from django.shortcuts import get_object_or_404 @@ -232,7 +235,7 @@ class EventAuthorise(generic.UpdateView): def form_valid(self, form): self.object = form.save() - # TODO: send email confirmation + self.template_name = self.success_template messages.add_message(self.request, messages.SUCCESS, 'Success! Your event has been authorised. You will also receive email confirmation.') diff --git a/RIGS/signals.py b/RIGS/signals.py new file mode 100644 index 00000000..869dbdae --- /dev/null +++ b/RIGS/signals.py @@ -0,0 +1,34 @@ +import reversion + +from django.core.mail import EmailMessage +from django.template.loader import get_template + +from RIGS import models + + +def send_eventauthorisation_success_email(instance): + context = { + 'object': instance, + } + client_email = EmailMessage( + "N%05d | %s - Event Authorised".format(instance.event.pk, instance.event.name), + get_template("RIGS/eventauthorisation_client_success.txt").render(context), + to=[instance.email] + ) + mic_email = EmailMessage( + "N%05d | %s - Event Authorised".format(instance.event.pk, instance.event.name), + get_template("RIGS/eventauthorisation_mic_success.txt").render(context), + to=[instance.event.mic.email] + ) + + client_email.send() + mic_email.send() + + +def on_revision_commit(instances, **kwargs): + for instance in instances: + if isinstance(instance, models.EventAuthorisation): + send_eventauthorisation_success_email(instance) + + +reversion.post_revision_commit.connect(on_revision_commit) diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.txt b/RIGS/templates/RIGS/eventauthorisation_client_success.txt new file mode 100644 index 00000000..23e05786 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.txt @@ -0,0 +1,11 @@ +Hi there, + +Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. + +{% if object.event.organisation and object.event.organisation.union_account %}{# internal #} +Your event is now fully booked and payment will be processed by the finance department automatically. +{% else %}{# external #} +Your event is now fully booked and our finance department will be contact to arrange payment. +{% endif %} + +The TEC Rig Information Gathering System diff --git a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt new file mode 100644 index 00000000..f5577ced --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt @@ -0,0 +1,5 @@ +Hi {{object.event.mic.name}}, + +Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. + +The TEC Rig Information Gathering System diff --git a/RIGS/test_models.py b/RIGS/test_models.py index a858187d..08f1dd69 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -1,4 +1,5 @@ import pytz +import reversion from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase @@ -331,14 +332,13 @@ class EventPricingTestCase(TestCase): class EventAuthorisationTestCase(TestCase): - @classmethod - def setUpTestData(cls): - cls.person = models.Person.objects.create(name='Authorisation Test Person') - cls.organisation = models.Organisation.objects.create(name='Authorisation Test Organisation') - cls.event = models.Event.objects.create(name="AuthorisationTestCase", person=cls.person, + def setUp(self): + self.person = models.Person.objects.create(name='Authorisation Test Person') + self.organisation = models.Organisation.objects.create(name='Authorisation Test Organisation') + self.event = models.Event.objects.create(name="AuthorisationTestCase", person=self.person, start_date=date.today()) # Add some items - models.EventItem.objects.create(event=cls.event, name="Authorisation test item", quantity=2, cost=123.45, + models.EventItem.objects.create(event=self.event, name="Authorisation test item", quantity=2, cost=123.45, order=1) def test_event_property(self): @@ -348,3 +348,9 @@ class EventAuthorisationTestCase(TestCase): auth1.amount = self.event.total auth1.save() self.assertTrue(self.event.authorised) + + def test_last_edited(self): + with reversion.create_revision(): + auth = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", + name="Test Auth", amount=self.event.total) + self.assertIsNotNone(auth.last_edited_at) From 97b11eabbdd649136035c28f3454e28724122f88 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 19:28:35 +0100 Subject: [PATCH 06/44] Add test for sending emails. Add backup email if there isn't an MIC --- PyRIGS/settings.py | 1 + RIGS/signals.py | 9 ++++++++- RIGS/test_functional.py | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index 2e82807e..1cdbcc19 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -221,3 +221,4 @@ TEMPLATE_DIRS = ( USE_GRAVATAR=True TERMS_OF_HIRE_URL = "http://www.nottinghamtec.co.uk/terms.pdf" +AUTHORISATION_NOTIFICATION_ADDRESS = 'productions@nottinghamtec.co.uk' diff --git a/RIGS/signals.py b/RIGS/signals.py index 869dbdae..deac4ce7 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -1,4 +1,5 @@ import reversion +from django.conf import settings from django.core.mail import EmailMessage from django.template.loader import get_template @@ -15,10 +16,16 @@ def send_eventauthorisation_success_email(instance): get_template("RIGS/eventauthorisation_client_success.txt").render(context), to=[instance.email] ) + + if instance.event.mic: + mic_email_address = instance.event.mic.email + else: + mic_email_address = settings.AUTHORISATION_NOTIFICATION_ADDRESS + mic_email = EmailMessage( "N%05d | %s - Event Authorised".format(instance.event.pk, instance.event.name), get_template("RIGS/eventauthorisation_mic_success.txt").render(context), - to=[instance.event.mic.email] + to=[mic_email_address] ) client_email.send() diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 13d6871b..87452f11 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -4,6 +4,7 @@ import re from datetime import date, timedelta import reversion +from django.conf import settings from django.core import mail, signing from django.core.urlresolvers import reverse from django.db import transaction @@ -1027,3 +1028,17 @@ class ClientEventAuthorisationTest(TestCase): response = self.client.get(self.url) self.assertContains(response, 'amount has changed') + + def test_email_sent(self): + mail.outbox = [] + + data = self.auth_data + data['amount'] = self.event.total + + response = self.client.post(self.url, data) + self.assertContains(response, "Your event has been authorised.") + self.assertEqual(len(mail.outbox), 2) + + self.assertEqual(mail.outbox[0].to, ['authemail@function.test']) + self.assertEqual(mail.outbox[1].to, [settings.AUTHORISATION_NOTIFICATION_ADDRESS]) + From 7fd0c50146eef0b973209b03e843d60bf42ac77f Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 20:39:19 +0100 Subject: [PATCH 07/44] Add sending of emails to clients. Add email sending methods. Add TEC side sending of emails. --- RIGS/forms.py | 4 + RIGS/rigboard.py | 46 +++++++++- RIGS/templates/RIGS/event_detail.html | 87 +------------------ RIGS/templates/RIGS/event_detail_buttons.html | 33 +++++++ .../eventauthorisation_client_request.txt | 12 +++ .../RIGS/eventauthorisation_request.html | 32 +++++++ RIGS/test_functional.py | 30 +++++++ RIGS/urls.py | 5 ++ 8 files changed, 162 insertions(+), 87 deletions(-) create mode 100644 RIGS/templates/RIGS/event_detail_buttons.html create mode 100644 RIGS/templates/RIGS/eventauthorisation_client_request.txt create mode 100644 RIGS/templates/RIGS/eventauthorisation_request.html diff --git a/RIGS/forms.py b/RIGS/forms.py index c19ac5b8..14df5772 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -175,3 +175,7 @@ class ExternalClientEventAuthorisationForm(BaseClientEventAuthorisationForm): class Meta: model = models.EventAuthorisation fields = ('tos', 'name', 'amount', 'po') + + +class EventAuthorisationRequestForm(forms.Form): + email = forms.EmailField(required=True, label='Authoriser Email') diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 5584184d..f4ee7bd0 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -1,11 +1,8 @@ -import os import cStringIO as StringIO from io import BytesIO import urllib2 -import reversion from django.core.mail import EmailMessage -from django.db import transaction from django.views import generic from django.core.urlresolvers import reverse_lazy from django.shortcuts import get_object_or_404 @@ -293,3 +290,46 @@ class EventAuthorise(generic.UpdateView): raise SuspiciousOperation( "The security integrity of that URL is invalid. Please contact your event MIC to obtain a new URL") return super(EventAuthorise, self).dispatch(request, *args, **kwargs) + + +class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMixin): + model = models.Event + form_class = forms.EventAuthorisationRequestForm + template_name = 'RIGS/eventauthorisation_request.html' + + @property + def object(self): + return self.get_object() + + def get_success_url(self): + if self.request.is_ajax(): + url = reverse_lazy('closemodal') + messages.info(self.request, "$('.event-authorise-request').addClass('btn-success')") + else: + url = reverse_lazy('event_detail', kwargs={ + 'pk': self.object.pk, + }) + messages.add_message(self.request, messages.SUCCESS, "Authorisation request successfully sent.") + return url + + def form_valid(self, form): + email = form.cleaned_data['email'] + + context = { + 'object': self.object, + 'request': self.request, + 'hmac': signing.dumps({ + 'pk': self.object.pk, + 'email': email + }), + } + + msg = EmailMessage( + "N%05d | %s - Event Authorisation Request".format(self.object.pk, self.object.name), + get_template("RIGS/eventauthorisation_client_request.txt").render(context), + to=[email], + ) + + msg.send() + + return super(EventAuthorisationRequest, self).form_valid(form) diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index d4b089a9..92d97e46 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -11,34 +11,7 @@
-
- - {% if event.is_rig %} - - {% endif %} - - {% if event.is_rig %} - {% if perms.RIGS.add_invoice %} - - - {% endif %} - {% endif %} -
+ {% include 'RIGS/event_detail_buttons.html' %}
{% endif %} @@ -184,34 +157,7 @@
{% if not request.is_ajax %}
-
- - {% if event.is_rig %} - - {% endif %} - - {% if event.is_rig %} - {% if perms.RIGS.add_invoice %} - - - {% endif %} - {% endif %} -
+ {% include 'RIGS/event_detail_buttons.html' %}
{% endif %} {% if event.is_rig %} @@ -229,34 +175,7 @@
{% if not request.is_ajax %}
-
- - {% if event.is_rig %} - - {% endif %} - - {% if event.is_rig %} - {% if perms.RIGS.add_invoice %} - - - {% endif %} - {% endif %} -
+ {% include 'RIGS/event_detail_buttons.html' %}
{% endif %} {% endif %} diff --git a/RIGS/templates/RIGS/event_detail_buttons.html b/RIGS/templates/RIGS/event_detail_buttons.html new file mode 100644 index 00000000..7f91db83 --- /dev/null +++ b/RIGS/templates/RIGS/event_detail_buttons.html @@ -0,0 +1,33 @@ +
+ + {% if event.is_rig %} + + {% endif %} + + {% if event.is_rig %} + + + Authorisation Request + + {% if perms.RIGS.add_invoice %} + + + {% endif %} + {% endif %} +
diff --git a/RIGS/templates/RIGS/eventauthorisation_client_request.txt b/RIGS/templates/RIGS/eventauthorisation_client_request.txt new file mode 100644 index 00000000..959da694 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_request.txt @@ -0,0 +1,12 @@ +Hi there, + +{{request.user.get_full_name}} has requested that you authorise N{{object.pk|stringformat:"05d"}} | {{object.name}}. + +Please find the link below to complete the event booking process. +{% if object.event.organisation and object.event.organisation.union_account %}{# internal #} +Remember that only Presidents or Treasurers are allowed to sign off payments. You may need to forward this email on. +{% endif %} + +{{request.scheme}}://{{request.get_host}}{% url 'event_authorise' object.pk hmac %} + +The TEC Rig Information Gathering System diff --git a/RIGS/templates/RIGS/eventauthorisation_request.html b/RIGS/templates/RIGS/eventauthorisation_request.html new file mode 100644 index 00000000..87880331 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_request.html @@ -0,0 +1,32 @@ +{% extends request.is_ajax|yesno:'base_ajax.html,base.html' %} +{% load widget_tweaks %} + +{% block title %}Request Authorisation{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} + +
+ {% include 'form_errors.html' %} + +
+ + +
+ {% render_field form.email type="email" class+="form-control" %} +
+
+ +
+
+ +
+
+
+ +
+
+{% endblock %} diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 87452f11..ecaf3ac1 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -1042,3 +1042,33 @@ class ClientEventAuthorisationTest(TestCase): self.assertEqual(mail.outbox[0].to, ['authemail@function.test']) self.assertEqual(mail.outbox[1].to, [settings.AUTHORISATION_NOTIFICATION_ADDRESS]) + +class TECEventAuthorisationTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = models.Profile.objects.create( + name='Test TEC User', + email='teccie@functional.test', + is_superuser=True # lazily grant all permissions + ) + + def setUp(self): + venue = models.Venue.objects.create(name='Authorisation Test Venue') + client = models.Person.objects.create(name='Authorisation Test Person', email='authorisation@functional.test') + organisation = models.Organisation.objects.create(name='Authorisation Test Organisation', union_account=False) + self.event = models.Event.objects.create( + name='Authorisation Test', + start_date=date.today(), + venue=venue, + person=client, + organisation=organisation, + ) + self.url = reverse('event_authorise_request', kwargs={'pk': self.event.pk}) + + def test_request_send(self): + self.client.force_login(self.profile) + response = self.client.post(self.url) + self.assertContains(response, 'This field is required.') + + response = self.client.post(self.url, {'email': 'client@functional.test'}) + self.assertEqual(response.status_code, 301) diff --git a/RIGS/urls.py b/RIGS/urls.py index 2273fd01..93a95e6d 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -115,6 +115,11 @@ urlpatterns = patterns('', permission_required_with_403('RIGS.view_event')(versioning.VersionHistory.as_view()), name='event_history', kwargs={'model': models.Event}), + url(r'^event/(?P\d+)/auth/$', + permission_required_with_403('RIGS.change_event')( + rigboard.EventAuthorisationRequest.as_view() + ), + name='event_authorise_request'), url(r'^event/(?P\d+)/(?P[-:\w]+)/$', rigboard.EventAuthorise.as_view(), name='event_authorise'), From 3fa9795cde7f22fbcd6faaabfe65488defe116c7 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 20:40:51 +0100 Subject: [PATCH 08/44] Change mic name to use the full name rather than the display name on RIGS --- RIGS/templates/RIGS/eventauthorisation_mic_success.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt index f5577ced..a15391a5 100644 --- a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt +++ b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt @@ -1,4 +1,4 @@ -Hi {{object.event.mic.name}}, +Hi {{object.event.mic.get_full_name}}, Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. From 306c11bb2f384d987398479a055f7499158a6d92 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 20:47:19 +0100 Subject: [PATCH 09/44] Add tooltip JavaScript --- RIGS/templates/RIGS/eventauthorisation_form.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html index d989b01e..b4a75d72 100644 --- a/RIGS/templates/RIGS/eventauthorisation_form.html +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -1,5 +1,15 @@ {% extends 'base_client.html' %} {% load widget_tweaks %} +{% load static %} + +{% block js %} + + +{% endblock %} {% block title %} {% if event.is_rig %}N{{ event.pk|stringformat:"05d" }}{% else %}{{ event.pk }}{% endif %} | {{ event.name }} From 22119a3d085ddd00f3370c9380fe49b44edc5717 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 20:50:28 +0100 Subject: [PATCH 10/44] Add test for sending authorisation email to client --- RIGS/test_functional.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index ecaf3ac1..bda1ffc6 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -1070,5 +1070,11 @@ class TECEventAuthorisationTest(TestCase): response = self.client.post(self.url) self.assertContains(response, 'This field is required.') + mail.outbox = [] + response = self.client.post(self.url, {'email': 'client@functional.test'}) self.assertEqual(response.status_code, 301) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[1] + self.assertIn(email.to, 'client@functional.test') + self.assertIn(email.body, '/event/%d/'.format(self.event.pk)) From 5d17d642ec7c934e6e11fe9101d6c055e98e12ab Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 21:43:18 +0100 Subject: [PATCH 11/44] Update templates to include the new authorisation fields --- RIGS/templates/RIGS/event_detail.html | 31 ++++++++++++++++-- RIGS/templates/RIGS/event_form.html | 8 ----- RIGS/templates/RIGS/event_invoice.html | 15 +++++++-- RIGS/templates/RIGS/event_table.html | 9 ++++-- RIGS/templates/RIGS/invoice_detail.html | 43 +++++++++++++++++++++++-- RIGS/templates/RIGS/invoice_list.html | 8 +++-- RIGS/urls.py | 17 +++++----- 7 files changed, 102 insertions(+), 29 deletions(-) diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index 92d97e46..22f8aa4a 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -13,7 +13,7 @@
{% include 'RIGS/event_detail_buttons.html' %}
- + {% endif %} {% if object.is_rig %} {# only need contact details for a rig #} @@ -148,8 +148,33 @@ {% endif %} {% if event.is_rig %} -
PO
-
{{ object.purchase_order }}
+ {% if object.purchase_order %} +
PO
+
{{ object.purchase_order }}
+ {% endif %} + +
 
+ +
Authorised
+
{{ object.authorised|yesno:"Yes,No" }}
+ +
Authorised by
+
+ {% if object.authorised %} + {{ object.authorisation.name }} + ({{ object.authorisation.email }}) + {% endif %} +
+ +
Authorised at
+
{{ object.authorisation.last_edited_at }}
+ +
Authorised amount
+
+ {% if object.authorised %} + £ {{ object.authorisation.amount|floatformat:"2" }} + {% endif %} +
{% endif %}
diff --git a/RIGS/templates/RIGS/event_form.html b/RIGS/templates/RIGS/event_form.html index b2b4dcd7..71e91fe6 100644 --- a/RIGS/templates/RIGS/event_form.html +++ b/RIGS/templates/RIGS/event_form.html @@ -398,14 +398,6 @@ {% render_field form.collector class+="form-control" %}
-
- - -
- {% render_field form.purchase_order class+="form-control" %} -
-
diff --git a/RIGS/templates/RIGS/event_invoice.html b/RIGS/templates/RIGS/event_invoice.html index fcbe5e87..71136b35 100644 --- a/RIGS/templates/RIGS/event_invoice.html +++ b/RIGS/templates/RIGS/event_invoice.html @@ -54,7 +54,12 @@ N{{ object.pk|stringformat:"05d" }}
{{ object.get_status_display }} {{ object.start_date }} - {{ object.name }} + + {{ object.name }} + {% if object.is_rig and perms.RIGS.view_event and object.authorised %} + + {% endif %} + {% if object.organisation %} {{ object.organisation.name }} @@ -67,7 +72,11 @@ {% endif %} - {{ object.sum_total|floatformat:2 }} + + {{ object.sum_total|floatformat:2 }} +
+ {{ object.authorisation.po }} + {% if object.mic %} {{ object.mic.initials }}
@@ -92,4 +101,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/RIGS/templates/RIGS/event_table.html b/RIGS/templates/RIGS/event_table.html index 949c710a..c84b6624 100644 --- a/RIGS/templates/RIGS/event_table.html +++ b/RIGS/templates/RIGS/event_table.html @@ -33,13 +33,18 @@

- {{ event.name }} + + {{ event.name }} + {% if event.venue %} at {{ event.venue }} {% endif %} {% if event.dry_hire %} Dry Hire {% endif %} + {% if event.is_rig and perms.RIGS.view_event and event.authorised %} + + {% endif %}

{% if event.is_rig and not event.cancelled %}
@@ -99,4 +104,4 @@ {% endfor %} - \ No newline at end of file + diff --git a/RIGS/templates/RIGS/invoice_detail.html b/RIGS/templates/RIGS/invoice_detail.html index cfcfcfe9..d45deeda 100644 --- a/RIGS/templates/RIGS/invoice_detail.html +++ b/RIGS/templates/RIGS/invoice_detail.html @@ -76,8 +76,45 @@
{{ object.checked_in_by.name }}
{% endif %} -
PO
-
{{ object.event.purchase_order }}
+ {% if object.event.purchase_order %} +
PO
+
{{ object.event.purchase_order }}
+ {% endif %} + +
 
+ +
Authorised
+
{{ object.event.authorised|yesno:"Yes,No" }}
+ +
Authorised by
+
+ {% if object.event.authorised %} + {{ object.event.authorisation.name }} + ({{ object.event.authorisation.email }}) + {% endif %} +
+ + {% if object.event.organisation.union_account %} + {# internal #} +
Uni ID
+
{{ object.event.authorisation.uni_id }}
+ +
Account code
+
{{ object.event.authorisation.account_code }}
+ {% else %} +
PO
+
{{ object.event.authorisation.po }}
+ {% endif %} + +
Authorised at
+
{{ object.event.authorisation.last_edited_at }}
+ +
Authorised amount
+
+ {% if object.event.authorised %} + £ {{ object.event.authorisation.amount|floatformat:"2" }} + {% endif %} +
@@ -139,4 +176,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/RIGS/templates/RIGS/invoice_list.html b/RIGS/templates/RIGS/invoice_list.html index 2f34b6c9..62012238 100644 --- a/RIGS/templates/RIGS/invoice_list.html +++ b/RIGS/templates/RIGS/invoice_list.html @@ -59,7 +59,11 @@ {{ object.event.start_date }} {{ object.invoice_date }} - {{ object.balance|floatformat:2 }} + + {{ object.balance|floatformat:2 }} +
+ {{ object.event.authorisation.po }} + @@ -76,4 +80,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/RIGS/urls.py b/RIGS/urls.py index 93a95e6d..2b4c716d 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -115,14 +115,6 @@ urlpatterns = patterns('', permission_required_with_403('RIGS.view_event')(versioning.VersionHistory.as_view()), name='event_history', kwargs={'model': models.Event}), - url(r'^event/(?P\d+)/auth/$', - permission_required_with_403('RIGS.change_event')( - rigboard.EventAuthorisationRequest.as_view() - ), - name='event_authorise_request'), - url(r'^event/(?P\d+)/(?P[-:\w]+)/$', rigboard.EventAuthorise.as_view(), - name='event_authorise'), - # Finance url(r'^invoice/$', permission_required_with_403('RIGS.view_invoice')(finance.InvoiceIndex.as_view()), @@ -157,6 +149,15 @@ urlpatterns = patterns('', permission_required_with_403('RIGS.add_payment')(finance.PaymentDelete.as_view()), name='payment_delete'), + # Client event authorisation + url(r'^event/(?P\d+)/auth/$', + permission_required_with_403('RIGS.change_event')( + rigboard.EventAuthorisationRequest.as_view() + ), + name='event_authorise_request'), + url(r'^event/(?P\d+)/(?P[-:\w]+)/$', rigboard.EventAuthorise.as_view(), + name='event_authorise'), + # User editing url(r'^user/$', login_required(views.ProfileDetail.as_view()), name='profile_detail'), url(r'^user/(?P\d+)/$', From 391d9ef28f8ec9b2bd0855a0fa788431b16dcf12 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 22:45:27 +0100 Subject: [PATCH 12/44] Update PDF templates and enable sending of PDF via email. PDFs now state QUOTE, INVOICE or RECEIPT. Single copy and all but INVOICE includes terms of hire. --- RIGS/rigboard.py | 37 ++- RIGS/signals.py | 54 +++- RIGS/templates/RIGS/event_print.xml | 19 +- RIGS/templates/RIGS/event_print_page.xml | 239 ++++++++---------- .../RIGS/eventauthorisation_mic_success.txt | 2 +- 5 files changed, 186 insertions(+), 165 deletions(-) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index f4ee7bd0..1c6d6b5e 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -145,33 +145,26 @@ class EventPrint(generic.View): def get(self, request, pk): object = get_object_or_404(models.Event, pk=pk) template = get_template('RIGS/event_print.xml') - copies = ('TEC', 'Client') merger = PdfFileMerger() - for copy in copies: - context = RequestContext(request, { # this should be outside the loop, but bug in 1.8.2 prevents this - 'object': object, - 'fonts': { - 'opensans': { - 'regular': 'RIGS/static/fonts/OPENSANS-REGULAR.TTF', - 'bold': 'RIGS/static/fonts/OPENSANS-BOLD.TTF', - } - }, - 'copy': copy, - 'current_user': request.user, - }) + context = RequestContext(request, { # this should be outside the loop, but bug in 1.8.2 prevents this + 'object': object, + 'fonts': { + 'opensans': { + 'regular': 'RIGS/static/fonts/OPENSANS-REGULAR.TTF', + 'bold': 'RIGS/static/fonts/OPENSANS-BOLD.TTF', + } + }, + 'quote': True, + 'current_user': request.user, + }) - # context['copy'] = copy # this is the way to do it once we upgrade to Django 1.8.3 + rml = template.render(context) - rml = template.render(context) - buffer = StringIO.StringIO() - - buffer = rml2pdf.parseString(rml) - - merger.append(PdfFileReader(buffer)) - - buffer.close() + buffer = rml2pdf.parseString(rml) + merger.append(PdfFileReader(buffer)) + buffer.close() terms = urllib2.urlopen(settings.TERMS_OF_HIRE_URL) merger.append(StringIO.StringIO(terms.read())) diff --git a/RIGS/signals.py b/RIGS/signals.py index deac4ce7..42bb6504 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -1,33 +1,79 @@ -import reversion -from django.conf import settings +import cStringIO as StringIO +import re +import urllib2 +from io import BytesIO +import reversion +from PyPDF2 import PdfFileReader, PdfFileMerger +from django.conf import settings from django.core.mail import EmailMessage from django.template.loader import get_template +from z3c.rml import rml2pdf from RIGS import models def send_eventauthorisation_success_email(instance): + # Generate PDF first to prevent context conflicts + context = { + 'object': instance.event, + 'fonts': { + 'opensans': { + 'regular': 'RIGS/static/fonts/OPENSANS-REGULAR.TTF', + 'bold': 'RIGS/static/fonts/OPENSANS-BOLD.TTF', + } + }, + 'receipt': True, + 'current_user': False, + } + + template = get_template('RIGS/event_print.xml') + merger = PdfFileMerger() + + rml = template.render(context) + + buffer = rml2pdf.parseString(rml) + merger.append(PdfFileReader(buffer)) + buffer.close() + + terms = urllib2.urlopen(settings.TERMS_OF_HIRE_URL) + merger.append(StringIO.StringIO(terms.read())) + + merged = BytesIO() + merger.write(merged) + + # Produce email content context = { 'object': instance, } + + subject = "N%05d | %s - Event Authorised" % (instance.event.pk, instance.event.name) + client_email = EmailMessage( - "N%05d | %s - Event Authorised".format(instance.event.pk, instance.event.name), + subject, get_template("RIGS/eventauthorisation_client_success.txt").render(context), to=[instance.email] ) + escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', instance.event.name) + + client_email.attach('N%05d - %s - RECEIPT.pdf' % (instance.event.pk, escapedEventName), + merged.getvalue(), + 'application/pdf' + ) + if instance.event.mic: mic_email_address = instance.event.mic.email else: mic_email_address = settings.AUTHORISATION_NOTIFICATION_ADDRESS mic_email = EmailMessage( - "N%05d | %s - Event Authorised".format(instance.event.pk, instance.event.name), + subject, get_template("RIGS/eventauthorisation_mic_success.txt").render(context), to=[mic_email_address] ) + # Now we have both emails successfully generated, send them out client_email.send() mic_email.send() diff --git a/RIGS/templates/RIGS/event_print.xml b/RIGS/templates/RIGS/event_print.xml index 6d693117..ad441a21 100644 --- a/RIGS/templates/RIGS/event_print.xml +++ b/RIGS/templates/RIGS/event_print.xml @@ -22,21 +22,21 @@ - + - - + + - + @@ -100,10 +100,11 @@ - {% if not invoice %}[{{ copy }} Copy]{% endif %} [Page of ] - [Paperwork generated by {{current_user.name}} | {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + + [Paperwork generated{% if current_user %}by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + @@ -118,7 +119,9 @@ {% if not invoice %}[{{ copy }} Copy]{% endif %} [Page of ] - [Paperwork generated by {{current_user.name}} | {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + + [Paperwork generated{% if current_user %}by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + @@ -128,4 +131,4 @@ {% include "RIGS/event_print_page.xml" %} - \ No newline at end of file + diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index a7049459..c0a2afa1 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -1,59 +1,71 @@ -{% if invoice %} - + - {% endif %} +

N{{ object.pk|stringformat:"05d" }}: '{{ object.name }}'

-

N{{ object.pk|stringformat:"05d" }}: '{{ object.name }}'

- - -{{object.start_date|date:"D jS N Y"}} - - - - - {{ object.description|default_if_none:""|linebreaksbr }} + + {{object.start_date|date:"D jS N Y"}} - - -{% if invoice %} + + + {{ object.description|default_if_none:""|linebreaksbr }} + + - INVOICE - - - - Invoice Number - - {{ invoice.pk|stringformat:"05d" }} - - - - Invoice Date - - {{ invoice.invoice_date|date:"d/m/Y" }} - - - - PO Number - - {{ object.purchase_order|default_if_none:"" }} - - + {% if invoice %} + INVOICE + + + + Invoice Number + + {{ invoice.pk|stringformat:"05d" }} + + + + Invoice Date + + {{ invoice.invoice_date|date:"d/m/Y" }} + + + {% if object.purchase_order %} + + PO Number + + {{ object.purchase_order|default_if_none:"" }} + + + {% endif %} + - + {% elif quote %} + + QUOTE + + + + Quote Date + + {% now "d/m/Y" %} + + + + + {% elif receipt %} + + RECEIPT + + {% endif %}
- -{% endif %} - @@ -205,17 +217,7 @@ £ {{ object.vat|floatformat:2 }} - - - - {% if invoice %} - VAT Registration Number: 170734807 - {% else %} - This contract is not an invoice. - {% endif %} - - - + Total @@ -229,90 +231,67 @@ -{% if not invoice %} - + + {% if not invoice %} + + + Bookings will + not + be confirmed until the event is authorised online. + + + + + 24 Hour Emergency Contacts: 07825 065681 and 07825 065678 + + {% else %} + + + VAT Registration Number: 170734807 + + + {% endif %} + + + - Bookings will - not - be confirmed until payment is received and the contract is signed. + {% if object.authorised %} + + Event authorised online by {{ object.authorisation.name }} ({{ object.authorisation.email }}) at + {{ object.authorisation.last_edited_at }}. + + {% if object.organisation.union_account %} + + + University ID + Account Code + Authorised Amount + + + {{ object.authorisation.uni_id }} + {{ object.authorisation.account_code }} + £ {{ object.authorisation.amount|floatformat:2 }} + + + {% else %} + + + Purchase Order + Authorised Amount + + + {{ object.authorisation.po }} + £ {{ object.authorisation.amount|floatformat:2 }} + + + {% endif %} + {% endif %} - - 24 Hour Emergency Contacts: 07825 065681 and 07825 065678 - - - - + - - To be signed on booking: - - {% if object.organisation.union_account %} - - - I agree that am authorised to sign this invoice. I agree that I am the President/Treasurer of the hirer, or - that I have provided written permission from either the President or Treasurer of the hirer stating that I can - sign for this invoice. - - - - - I have read, understood and fully accepted the current conditions of hire. I agree to return any dry hire - items to TEC PA & Lighting in the same condition at the end of the hire period. - - - - - - Conditions of hire attached and available on the TEC PA & Lighting website. E&OE - - - - - Please return this form directly to TEC PA & Lighting and not the Students' Union Finance Department. - - - - - Account Code - - - - - - {% else %} - - - I, the hirer, have read, understand and fully accept the current conditions of hire. This document forms a - binding contract between TEC PA & Lighting and the hirer, the aforementioned conditions of hire forming - an integral part of it. - - - - - - Conditions of hire attached and available on the TEC PA & Lighting website. E&OE - - - - {% include "RIGS/event_print_signature.xml" %} - - - To be signed on the day of the event/hire: - - - - I, the hirer, have received the goods/services as requested and in good order. I agree to return any dry hire - items to TEC PA & Lighting in a similar condition at the end of the hire period. - - - {% endif %} - - {% include "RIGS/event_print_signature.xml" %} - - {% endif %} - \ No newline at end of file + diff --git a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt index a15391a5..98e1cdb8 100644 --- a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt +++ b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt @@ -1,4 +1,4 @@ -Hi {{object.event.mic.get_full_name}}, +Hi {{object.event.mic.get_full_name|default_if_none:"somebody"}}, Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. From 067e03b7572c6b932591c83ac03320025220fa50 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 23:14:09 +0100 Subject: [PATCH 13/44] Remove Event.purchase_order in favour of a simple EventAuthorisation object. --- RIGS/forms.py | 2 +- .../migrations/0028_migrate_purchase_order.py | 47 +++++++++++++++++++ RIGS/models.py | 1 - RIGS/rigboard.py | 1 - RIGS/templates/RIGS/event_detail.html | 5 -- RIGS/templates/RIGS/event_print_page.xml | 8 ---- RIGS/templates/RIGS/invoice_detail.html | 5 -- RIGS/test_functional.py | 6 +-- 8 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 RIGS/migrations/0028_migrate_purchase_order.py diff --git a/RIGS/forms.py b/RIGS/forms.py index 14df5772..77e504eb 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -140,7 +140,7 @@ class EventForm(forms.ModelForm): fields = ['is_rig', 'name', 'venue', 'start_time', 'end_date', 'start_date', 'end_time', 'meet_at', 'access_at', 'description', 'notes', 'mic', 'person', 'organisation', 'dry_hire', 'checked_in_by', 'status', - 'collector', 'purchase_order'] + 'collector'] class BaseClientEventAuthorisationForm(forms.ModelForm): diff --git a/RIGS/migrations/0028_migrate_purchase_order.py b/RIGS/migrations/0028_migrate_purchase_order.py new file mode 100644 index 00000000..05275d03 --- /dev/null +++ b/RIGS/migrations/0028_migrate_purchase_order.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db.models import F, Sum, DecimalField + + +def POs_forward(apps, schema_editor): + VatRate = apps.get_model('RIGS', 'VatRate') + Event = apps.get_model('RIGS', 'Event') + EventItem = apps.get_model('RIGS', 'EventItem') + EventAuthorisation = apps.get_model('RIGS', 'EventAuthorisation') + db_alias = schema_editor.connection.alias + for event in Event.objects.using(db_alias).filter(purchase_order__isnull=False): + sum_total = EventItem.objects.filter(event=event).aggregate( + sum_total=Sum(models.F('cost') * F('quantity'), + output_field=DecimalField( + max_digits=10, + decimal_places=2) + ) + )['sum_total'] + + vat = VatRate.objects.using(db_alias).filter(start_at__lte=event.start_date).latest() + total = sum_total + sum_total * vat.rate + + EventAuthorisation.objects.using(db_alias).create(event=event, name='LEGACY', + email='treasurer@nottinghamtec.co.uk', + amount=total) + + +def POs_reverse(apps, schema_editor): + EventAuthorisation = apps.get_model('RIGS', 'EventAuthorisation') + db_alias = schema_editor.connection.alias + for auth in EventAuthorisation.objects.using(db_alias).filter(po__isnull=False): + auth.event.purchase_order = auth.po + auth.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('RIGS', '0027_eventauthorisation_event_singular'), + ] + + operations = [ + migrations.RunPython(POs_forward, POs_reverse), + migrations.RemoveField(model_name='event', name='purchase_order') + ] diff --git a/RIGS/models.py b/RIGS/models.py index d1f20446..9b73a1ab 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -332,7 +332,6 @@ class Event(models.Model, RevisionMixin): # Monies payment_method = models.CharField(max_length=255, blank=True, null=True) payment_received = models.CharField(max_length=255, blank=True, null=True) - purchase_order = models.CharField(max_length=255, blank=True, null=True, verbose_name='PO') collector = models.CharField(max_length=255, blank=True, null=True, verbose_name='collected by') # Calculated values diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 1c6d6b5e..f6565b3f 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -125,7 +125,6 @@ class EventDuplicate(EventUpdate): old = super(EventDuplicate, self).get_object(queryset) # Get the object (the event you're duplicating) new = copy.copy(old) # Make a copy of the object in memory new.based_on = old # Make the new event based on the old event - new.purchase_order = None if self.request.method in ( 'POST', 'PUT'): # This only happens on save (otherwise items won't display in editor) diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index 22f8aa4a..3493a421 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -148,11 +148,6 @@ {% endif %} {% if event.is_rig %} - {% if object.purchase_order %} -
PO
-
{{ object.purchase_order }}
- {% endif %} -
 
Authorised
diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index c0a2afa1..56f75e68 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -34,14 +34,6 @@ {{ invoice.invoice_date|date:"d/m/Y" }} - {% if object.purchase_order %} - - PO Number - - {{ object.purchase_order|default_if_none:"" }} - - - {% endif %}
{% elif quote %} diff --git a/RIGS/templates/RIGS/invoice_detail.html b/RIGS/templates/RIGS/invoice_detail.html index d45deeda..d8da9182 100644 --- a/RIGS/templates/RIGS/invoice_detail.html +++ b/RIGS/templates/RIGS/invoice_detail.html @@ -76,11 +76,6 @@
{{ object.checked_in_by.name }}
{% endif %} - {% if object.event.purchase_order %} -
PO
-
{{ object.event.purchase_order }}
- {% endif %} -
 
Authorised
diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index bda1ffc6..f12bb7c7 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -443,7 +443,7 @@ class EventTest(LiveServerTestCase): def testEventDuplicate(self): testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), - description="start future no end", purchase_order="TESTPO") + description="start future no end") item1 = models.EventItem( event=testEvent, @@ -509,8 +509,6 @@ class EventTest(LiveServerTestCase): infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') self.assertIn("N0000%d" % testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) - # Check the PO hasn't carried through - self.assertNotIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) self.browser.get(self.live_server_url + '/event/' + str(testEvent.pk)) # Go back to the old event @@ -518,8 +516,6 @@ class EventTest(LiveServerTestCase): infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') self.assertNotIn("N0000%d" % testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) - # Check the PO remains on the old event - self.assertIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) # Check the items are as they were table = self.browser.find_element_by_id('item-table') # ID number is known, see above From 5be3842aea8f2e01da7d31a8cf3aa9a7b25dd186 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 23:28:00 +0100 Subject: [PATCH 14/44] Fix test client login because we are still using Django 1.8 --- RIGS/test_functional.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index f12bb7c7..5d3bf245 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -1042,11 +1042,15 @@ class ClientEventAuthorisationTest(TestCase): class TECEventAuthorisationTest(TestCase): @classmethod def setUpTestData(cls): - cls.profile = models.Profile.objects.create( - name='Test TEC User', + cls.profile = models.Profile.objects.get_or_create( + first_name='Test', + last_name='TEC User', + username='eventauthtest', email='teccie@functional.test', is_superuser=True # lazily grant all permissions - ) + )[0] + cls.profile.set_password('eventauthtest123') + cls.profile.save() def setUp(self): venue = models.Venue.objects.create(name='Authorisation Test Venue') @@ -1062,15 +1066,15 @@ class TECEventAuthorisationTest(TestCase): self.url = reverse('event_authorise_request', kwargs={'pk': self.event.pk}) def test_request_send(self): - self.client.force_login(self.profile) + self.assertTrue(self.client.login(username=self.profile.username, password='eventauthtest123')) response = self.client.post(self.url) self.assertContains(response, 'This field is required.') mail.outbox = [] response = self.client.post(self.url, {'email': 'client@functional.test'}) - self.assertEqual(response.status_code, 301) + self.assertEqual(response.status_code, 302) self.assertEqual(len(mail.outbox), 1) - email = mail.outbox[1] - self.assertIn(email.to, 'client@functional.test') - self.assertIn(email.body, '/event/%d/'.format(self.event.pk)) + email = mail.outbox[0] + self.assertIn('client@functional.test', email.to) + self.assertIn('/event/%d/' % (self.event.pk), email.body) From 82b6f1cbf81b91ce8a1446ac7ae4f0b9d1eb1a90 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Mon, 10 Apr 2017 23:43:42 +0100 Subject: [PATCH 15/44] Fix string formatting issue. I used python 3 syntax, we aren't yet using python 3... --- RIGS/rigboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index f6565b3f..61ebc3a7 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -317,7 +317,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix } msg = EmailMessage( - "N%05d | %s - Event Authorisation Request".format(self.object.pk, self.object.name), + "N%05d | %s - Event Authorisation Request" % (self.object.pk, self.object.name), get_template("RIGS/eventauthorisation_client_request.txt").render(context), to=[email], ) From 6e78f16c33f06bc2375061b56e592dfb41c00056 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 11:45:08 +0100 Subject: [PATCH 16/44] Add changes suggested by DT --- RIGS/forms.py | 2 +- RIGS/rigboard.py | 13 ++++++++----- RIGS/signals.py | 3 ++- RIGS/templates/RIGS/eventauthorisation_request.html | 12 +++++++++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/RIGS/forms.py b/RIGS/forms.py index 77e504eb..752b7fb8 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -149,7 +149,7 @@ class BaseClientEventAuthorisationForm(forms.ModelForm): def clean(self): if self.cleaned_data.get('amount') != self.instance.event.total: - self.add_error('amount', 'The amount authorised must equal the total for the event.') + self.add_error('amount', 'The amount authorised must equal the total for the event (inc VAT).') return super(BaseClientEventAuthorisationForm, self).clean() class Meta: diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 61ebc3a7..ecd15842 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -147,7 +147,7 @@ class EventPrint(generic.View): merger = PdfFileMerger() - context = RequestContext(request, { # this should be outside the loop, but bug in 1.8.2 prevents this + context = RequestContext(request, { 'object': object, 'fonts': { 'opensans': { @@ -227,7 +227,8 @@ class EventAuthorise(generic.UpdateView): self.template_name = self.success_template messages.add_message(self.request, messages.SUCCESS, - 'Success! Your event has been authorised. You will also receive email confirmation.') + 'Success! Your event has been authorised. ' + + 'You will also receive email confirmation to %s.' % (self.object.email)) return self.render_to_response(self.get_context_data()) @property @@ -259,10 +260,11 @@ class EventAuthorise(generic.UpdateView): if self.get_object() is not None and self.get_object().pk is not None: if self.event.authorised: messages.add_message(self.request, messages.WARNING, - "This event has already been authorised. Please confirm you wish to reauthorise") + "This event has already been authorised. " + "Reauthorising is not necessary at this time.") else: messages.add_message(self.request, messages.WARNING, - "This event has already been authorised, but the amount has changed." + + "This event has already been authorised, but the amount has changed. " + "Please check the amount and reauthorise.") return super(EventAuthorise, self).get(request, *args, **kwargs) @@ -280,7 +282,7 @@ class EventAuthorise(generic.UpdateView): request.email = data['email'] except (signing.BadSignature, AssertionError, KeyError): raise SuspiciousOperation( - "The security integrity of that URL is invalid. Please contact your event MIC to obtain a new URL") + "This URL is invalid. Please ask your TEC contact for a new URL") return super(EventAuthorise, self).dispatch(request, *args, **kwargs) @@ -320,6 +322,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix "N%05d | %s - Event Authorisation Request" % (self.object.pk, self.object.name), get_template("RIGS/eventauthorisation_client_request.txt").render(context), to=[email], + reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS], ) msg.send() diff --git a/RIGS/signals.py b/RIGS/signals.py index 42bb6504..6aac2388 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -52,7 +52,8 @@ def send_eventauthorisation_success_email(instance): client_email = EmailMessage( subject, get_template("RIGS/eventauthorisation_client_success.txt").render(context), - to=[instance.email] + to=[instance.email], + reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS], ) escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', instance.event.name) diff --git a/RIGS/templates/RIGS/eventauthorisation_request.html b/RIGS/templates/RIGS/eventauthorisation_request.html index 87880331..10a443bf 100644 --- a/RIGS/templates/RIGS/eventauthorisation_request.html +++ b/RIGS/templates/RIGS/eventauthorisation_request.html @@ -5,17 +5,23 @@ {% block content %}
-
+
+
+

Send authorisation request email.

+

Pressing send will email the address provided. Please triple check everything before continuing.

+
+
+
{% csrf_token %}
{% include 'form_errors.html' %}
- -
+
{% render_field form.email type="email" class+="form-control" %}
From a0440e158702b9028d69830fd4f016368c5f2dda Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 13:48:51 +0100 Subject: [PATCH 17/44] Add useful email addresses for reference. --- RIGS/templates/RIGS/eventauthorisation_request.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/RIGS/templates/RIGS/eventauthorisation_request.html b/RIGS/templates/RIGS/eventauthorisation_request.html index 10a443bf..9c31f1fe 100644 --- a/RIGS/templates/RIGS/eventauthorisation_request.html +++ b/RIGS/templates/RIGS/eventauthorisation_request.html @@ -10,6 +10,16 @@

Send authorisation request email.

Pressing send will email the address provided. Please triple check everything before continuing.

+ +
+
+
Person Email
+
{{ object.person.email }}
+ +
Organisation Email
+
{{ object.organisation.email }}
+
+
{% csrf_token %} From 36638e4df6c7fb43a85096f8d9f8373232da5bd2 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 14:02:58 +0100 Subject: [PATCH 18/44] Add some more disclaimers explaining things better to internal clients. --- .../RIGS/eventauthorisation_form.html | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html index b4a75d72..0809b29a 100644 --- a/RIGS/templates/RIGS/eventauthorisation_form.html +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -47,6 +47,18 @@ {% csrf_token %} {% include 'form_errors.html' %}
+ {% if internal %} +
+

+ I agree that I am authorised to approve this event. I agree that I am the + President/Treasurer or account holder of the hirer, or that I + have the written permission of the + President/Treasurer or account holder of the hirer stating that + I can authorise this event. +

+
+ {% endif %} +
@@ -60,7 +72,7 @@ {% if internal %}
+ title="Your Student ID or Staff username as the person authorising the event.">
@@ -102,19 +114,19 @@
-
- -
+
From c0f48842426d8c0a4cd419cc8bcf137f20b8e244 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 14:10:00 +0100 Subject: [PATCH 19/44] Add missing PO field. Noticed in testing, that could have gone badly. --- RIGS/migrations/0028_migrate_purchase_order.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RIGS/migrations/0028_migrate_purchase_order.py b/RIGS/migrations/0028_migrate_purchase_order.py index 05275d03..8a268208 100644 --- a/RIGS/migrations/0028_migrate_purchase_order.py +++ b/RIGS/migrations/0028_migrate_purchase_order.py @@ -25,7 +25,8 @@ def POs_forward(apps, schema_editor): EventAuthorisation.objects.using(db_alias).create(event=event, name='LEGACY', email='treasurer@nottinghamtec.co.uk', - amount=total) + amount=total, + po=event.purchase_order) def POs_reverse(apps, schema_editor): From e12367bde71a694a782b297a56ec8192e54ce60c Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 14:32:06 +0100 Subject: [PATCH 20/44] Few tweaks to printed documents following a conversation with Marilyn from treasury --- RIGS/templates/RIGS/event_print_page.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 56f75e68..422c3c5e 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -51,7 +51,7 @@ {% elif receipt %} - RECEIPT + CONFIRMATION {% endif %} @@ -199,7 +199,7 @@ - {% if not invoice %} + {% if quote %} The full hire fee is payable at least 10 days before the event. @@ -225,7 +225,7 @@ - {% if not invoice %} + {% if quote %} Bookings will From 430862b24d14800805ca50d3a2059213cdc48528 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 15:52:38 +0100 Subject: [PATCH 21/44] Add tracking of who sent the link --- .../0029_eventauthorisation_sent_by.py | 21 +++++++++++++++++++ RIGS/models.py | 1 + RIGS/rigboard.py | 7 +++++-- RIGS/templates/RIGS/event_detail.html | 3 +++ RIGS/templates/RIGS/invoice_detail.html | 3 +++ RIGS/test_functional.py | 12 +++++++++-- 6 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 RIGS/migrations/0029_eventauthorisation_sent_by.py diff --git a/RIGS/migrations/0029_eventauthorisation_sent_by.py b/RIGS/migrations/0029_eventauthorisation_sent_by.py new file mode 100644 index 00000000..80c86299 --- /dev/null +++ b/RIGS/migrations/0029_eventauthorisation_sent_by.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0028_migrate_purchase_order'), + ] + + operations = [ + migrations.AddField( + model_name='eventauthorisation', + name='sent_by', + field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/RIGS/models.py b/RIGS/models.py index 9b73a1ab..31496ed0 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -513,6 +513,7 @@ class EventAuthorisation(models.Model, RevisionMixin): account_code = models.CharField(max_length=50, blank=True, null=True) po = models.CharField(max_length=255, blank=True, null=True, verbose_name="purchase order") amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") + sent_by = models.ForeignKey('RIGS.Profile') @python_2_unicode_compatible diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index ecd15842..9213e900 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -272,6 +272,7 @@ class EventAuthorise(generic.UpdateView): form = super(EventAuthorise, self).get_form(**kwargs) form.instance.event = self.event form.instance.email = self.request.email + form.instance.sent_by = self.request.sent_by return form def dispatch(self, request, *args, **kwargs): @@ -280,7 +281,8 @@ class EventAuthorise(generic.UpdateView): data = signing.loads(kwargs.get('hmac')) assert int(kwargs.get('pk')) == int(data.get('pk')) request.email = data['email'] - except (signing.BadSignature, AssertionError, KeyError): + request.sent_by = models.Profile.objects.get(pk=data['sent_by']) + except (signing.BadSignature, AssertionError, KeyError, models.Profile.DoesNotExist): raise SuspiciousOperation( "This URL is invalid. Please ask your TEC contact for a new URL") return super(EventAuthorise, self).dispatch(request, *args, **kwargs) @@ -314,7 +316,8 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix 'request': self.request, 'hmac': signing.dumps({ 'pk': self.object.pk, - 'email': email + 'email': email, + 'sent_by': self.request.user.pk, }), } diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index 3493a421..4a803e1a 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -170,6 +170,9 @@ £ {{ object.authorisation.amount|floatformat:"2" }} {% endif %} + +
Authorsation request sent by
+
{{ object.authorisation.sent_by }}
{% endif %}
diff --git a/RIGS/templates/RIGS/invoice_detail.html b/RIGS/templates/RIGS/invoice_detail.html index d8da9182..a55927f8 100644 --- a/RIGS/templates/RIGS/invoice_detail.html +++ b/RIGS/templates/RIGS/invoice_detail.html @@ -110,6 +110,9 @@ £ {{ object.event.authorisation.amount|floatformat:"2" }} {% endif %} + +
Authorsation request sent by
+
{{ object.authorisation.sent_by }}
diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 5d3bf245..c854a91b 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -952,6 +952,13 @@ class ClientEventAuthorisationTest(TestCase): } def setUp(self): + self.profile = models.Profile.objects.get_or_create( + first_name='Test', + last_name='TEC User', + username='eventauthtest', + email='teccie@functional.test', + is_superuser=True # lazily grant all permissions + )[0] venue = models.Venue.objects.create(name='Authorisation Test Venue') client = models.Person.objects.create(name='Authorisation Test Person', email='authorisation@functional.test') organisation = models.Organisation.objects.create(name='Authorisation Test Organisation', union_account=False) @@ -962,7 +969,8 @@ class ClientEventAuthorisationTest(TestCase): person=client, organisation=organisation, ) - self.hmac = signing.dumps({'pk': self.event.pk, 'email': 'authemail@function.test'}) + self.hmac = signing.dumps({'pk': self.event.pk, 'email': 'authemail@function.test', + 'sent_by': self.profile.pk}) self.url = reverse('event_authorise', kwargs={'pk': self.event.pk, 'hmac': self.hmac}) def test_requires_valid_hmac(self): @@ -1015,7 +1023,7 @@ class ClientEventAuthorisationTest(TestCase): def test_duplicate_warning(self): auth = models.EventAuthorisation.objects.create(event=self.event, name='Test ABC', email='dupe@functional.test', - po='ABC12345', amount=self.event.total) + po='ABC12345', amount=self.event.total, sent_by=self.profile) response = self.client.get(self.url) self.assertContains(response, 'This event has already been authorised.') From 56d4e438b6ad98818975c6e67389e501bcc56711 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 11 Apr 2017 16:22:54 +0100 Subject: [PATCH 22/44] Fix test missing EventAuthorisation.sent_by --- RIGS/test_models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/RIGS/test_models.py b/RIGS/test_models.py index 08f1dd69..1f36be43 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -333,6 +333,13 @@ class EventPricingTestCase(TestCase): class EventAuthorisationTestCase(TestCase): def setUp(self): + self.profile = models.Profile.objects.get_or_create( + first_name='Test', + last_name='TEC User', + username='eventauthtest', + email='teccie@functional.test', + is_superuser=True # lazily grant all permissions + )[0] self.person = models.Person.objects.create(name='Authorisation Test Person') self.organisation = models.Organisation.objects.create(name='Authorisation Test Organisation') self.event = models.Event.objects.create(name="AuthorisationTestCase", person=self.person, @@ -343,7 +350,8 @@ class EventAuthorisationTestCase(TestCase): def test_event_property(self): auth1 = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", - name="Test Auth 1", amount=self.event.total - 1) + name="Test Auth 1", amount=self.event.total - 1, + sent_by=self.profile) self.assertFalse(self.event.authorised) auth1.amount = self.event.total auth1.save() @@ -352,5 +360,5 @@ class EventAuthorisationTestCase(TestCase): def test_last_edited(self): with reversion.create_revision(): auth = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", - name="Test Auth", amount=self.event.total) + name="Test Auth", amount=self.event.total, sent_by=self.profile) self.assertIsNotNone(auth.last_edited_at) From d9076a4f5f5bbe280821facccf953b226227c149 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Wed, 19 Apr 2017 15:27:12 +0100 Subject: [PATCH 23/44] Quantize event totals to prevent issues with mixed precision on client authorisation form. --- RIGS/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RIGS/models.py b/RIGS/models.py index 31496ed0..4a5f6988 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -366,7 +366,7 @@ class Event(models.Model, RevisionMixin): @property def vat(self): - return self.sum_total * self.vat_rate.rate + return Decimal(self.sum_total * self.vat_rate.rate).quantize(Decimal('.01')) """ Inc VAT @@ -374,7 +374,7 @@ class Event(models.Model, RevisionMixin): @property def total(self): - return self.sum_total + self.vat + return Decimal(self.sum_total + self.vat).quantize(Decimal('.01')) @property def cancelled(self): From 331dab20f74f4b827a3dc50d89ed1bd36f9c67cc Mon Sep 17 00:00:00 2001 From: Tom Price Date: Wed, 19 Apr 2017 18:14:36 +0100 Subject: [PATCH 24/44] Add basic tracking of when an event authorisation request was sent. Designed and requested by Ross because he can't remember if he's push a button... --- RIGS/migrations/0030_auth_request_sending.py | 30 +++++++++ RIGS/models.py | 5 ++ RIGS/rigboard.py | 7 ++- RIGS/templates/RIGS/event_detail.html | 61 +++++++++++++------ RIGS/templates/RIGS/event_detail_buttons.html | 14 ++++- RIGS/test_functional.py | 6 ++ 6 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 RIGS/migrations/0030_auth_request_sending.py diff --git a/RIGS/migrations/0030_auth_request_sending.py b/RIGS/migrations/0030_auth_request_sending.py new file mode 100644 index 00000000..7243e9a7 --- /dev/null +++ b/RIGS/migrations/0030_auth_request_sending.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0029_eventauthorisation_sent_by'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='auth_request_at', + field=models.DateTimeField(null=True, blank=True), + ), + migrations.AddField( + model_name='event', + name='auth_request_by', + field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True), + ), + migrations.AddField( + model_name='event', + name='auth_request_to', + field=models.EmailField(max_length=254, null=True, blank=True), + ), + ] diff --git a/RIGS/models.py b/RIGS/models.py index 4a5f6988..89ec852b 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -334,6 +334,11 @@ class Event(models.Model, RevisionMixin): payment_received = models.CharField(max_length=255, blank=True, null=True) collector = models.CharField(max_length=255, blank=True, null=True, verbose_name='collected by') + # Authorisation request details + auth_request_by = models.ForeignKey('Profile', null=True, blank=True) + auth_request_at = models.DateTimeField(null=True, blank=True) + auth_request_to = models.EmailField(null=True, blank=True) + # Calculated values """ EX Vat diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 9213e900..06ab03d1 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -300,7 +300,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix def get_success_url(self): if self.request.is_ajax(): url = reverse_lazy('closemodal') - messages.info(self.request, "$('.event-authorise-request').addClass('btn-success')") + messages.info(self.request, "location.reload()") else: url = reverse_lazy('event_detail', kwargs={ 'pk': self.object.pk, @@ -310,6 +310,11 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix def form_valid(self, form): email = form.cleaned_data['email'] + event = self.object + event.auth_request_by = self.request.user + event.auth_request_at = datetime.datetime.now() + event.auth_request_to = email + event.save() context = { 'object': self.object, diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index 4a803e1a..051aec4f 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -70,6 +70,39 @@
{% endif %} + + {% if event.is_rig %} +
+
Client Authorisation
+
+
+
Authorised
+
{{ object.authorised|yesno:"Yes,No" }}
+ +
Authorised by
+
+ {% if object.authorisation %} + {{ object.authorisation.name }} + ({{ object.authorisation.email }}) + {% endif %} +
+ +
Authorised at
+
{{ object.authorisation.last_edited_at }}
+ +
Authorised amount
+
+ {% if object.authorisation %} + £ {{ object.authorisation.amount|floatformat:"2" }} + {% endif %} +
+ +
Requested by
+
{{ object.authorisation.sent_by }}
+
+
+
+ {% endif %}
{% endif %}
@@ -150,29 +183,17 @@ {% if event.is_rig %}
 
-
Authorised
-
{{ object.authorised|yesno:"Yes,No" }}
+
Authorisation Request
+
{{ object.auth_request_to|yesno:"Yes,No" }}
-
Authorised by
-
- {% if object.authorised %} - {{ object.authorisation.name }} - ({{ object.authorisation.email }}) - {% endif %} -
+
By
+
{{ object.auth_request_by }}
-
Authorised at
-
{{ object.authorisation.last_edited_at }}
+
At
+
{{ object.auth_request_at|date:"D d M Y H:i"|default:"" }}
-
Authorised amount
-
- {% if object.authorised %} - £ {{ object.authorisation.amount|floatformat:"2" }} - {% endif %} -
- -
Authorsation request sent by
-
{{ object.authorisation.sent_by }}
+
To
+
{{ object.auth_request_to }}
{% endif %}
diff --git a/RIGS/templates/RIGS/event_detail_buttons.html b/RIGS/templates/RIGS/event_detail_buttons.html index 7f91db83..0c1adae3 100644 --- a/RIGS/templates/RIGS/event_detail_buttons.html +++ b/RIGS/templates/RIGS/event_detail_buttons.html @@ -11,10 +11,18 @@ class="glyphicon glyphicon-duplicate"> {% if event.is_rig %} - + - Authorisation Request + {% if perms.RIGS.add_invoice %} +{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_client_request.txt b/RIGS/templates/RIGS/eventauthorisation_client_request.txt index 959da694..7b1297b1 100644 --- a/RIGS/templates/RIGS/eventauthorisation_client_request.txt +++ b/RIGS/templates/RIGS/eventauthorisation_client_request.txt @@ -1,12 +1,16 @@ -Hi there, +Hi {{ to_name|default:"there" }}, -{{request.user.get_full_name}} has requested that you authorise N{{object.pk|stringformat:"05d"}} | {{object.name}}. +{{ request.user.get_full_name }} has requested that you authorise N{{ object.pk|stringformat:"05d" }}| {{ object.name }}{% if not to_name %} on behalf of {{ object.person.name }}{% endif %}. -Please find the link below to complete the event booking process. -{% if object.event.organisation and object.event.organisation.union_account %}{# internal #} -Remember that only Presidents or Treasurers are allowed to sign off payments. You may need to forward this email on. -{% endif %} + Please find the link below to complete the event booking process. + {% if object.event.organisation and object.event.organisation.union_account %}{# internal #} + Remember that only Presidents or Treasurers are allowed to sign off payments. You may need to forward + this + email on. + {% endif %} -{{request.scheme}}://{{request.get_host}}{% url 'event_authorise' object.pk hmac %} +{{ request.scheme }}://{{ request.get_host }}{% url 'event_authorise' object.pk hmac %} -The TEC Rig Information Gathering System +Please note you event will not be booked until you complete this form. + +TEC PA & Lighting diff --git a/RIGS/urls.py b/RIGS/urls.py index 2b4c716d..9c9f1628 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -155,6 +155,11 @@ urlpatterns = patterns('', rigboard.EventAuthorisationRequest.as_view() ), name='event_authorise_request'), + url(r'^event/(?P\d+)/auth/preview/$', + permission_required_with_403('RIGS.change_event')( + rigboard.EventAuthoriseRequestEmailPreview.as_view() + ), + name='event_authorise_preview'), url(r'^event/(?P\d+)/(?P[-:\w]+)/$', rigboard.EventAuthorise.as_view(), name='event_authorise'), diff --git a/requirements.txt b/requirements.txt index 995d2afd..4bfce216 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ gunicorn==19.3.0 icalendar==3.9.0 lxml==3.4.4 Pillow==2.8.1 +premailer==3.0.1 psycopg2==2.6 Pygments==2.0.2 PyPDF2==1.24 diff --git a/templates/base_client_email.html b/templates/base_client_email.html new file mode 100644 index 00000000..a60bc257 --- /dev/null +++ b/templates/base_client_email.html @@ -0,0 +1,49 @@ +{% load static from staticfiles %} +{% load raven %} + + + + + + + + {% block css %} + {% endblock %} + + + + + {% block preload_js %} + {% endblock %} + + {% block extra-head %}{% endblock %} + + + +
+ + + + + +
+ + + +
+
+ +
+
+ {% block content %}{% endblock %} +
+
+ +{% block js %} +{% endblock %} + + From 1710c3f01f614ac725435baec0ad3c09a418b3f9 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 9 May 2017 18:43:27 +0100 Subject: [PATCH 26/44] Send HTML confirmation emails. Also tidy up the PDF and some of the source. --- RIGS/rigboard.py | 1 - RIGS/signals.py | 16 ++++++++++--- RIGS/templates/RIGS/event_print_page.xml | 2 +- .../eventauthorisation_client_success.html | 23 +++++++++++++++++++ .../eventauthorisation_client_success.txt | 6 ++--- 5 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 RIGS/templates/RIGS/eventauthorisation_client_success.html diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index db95b34f..49fea2cb 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -341,7 +341,6 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix external_styles=css).transform() msg.attach_alternative(html, 'text/html') - msg.send() return super(EventAuthorisationRequest, self).form_valid(form) diff --git a/RIGS/signals.py b/RIGS/signals.py index 6aac2388..df5dc8fe 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -6,8 +6,10 @@ from io import BytesIO import reversion from PyPDF2 import PdfFileReader, PdfFileMerger from django.conf import settings -from django.core.mail import EmailMessage +from django.contrib.staticfiles.storage import staticfiles_storage +from django.core.mail import EmailMessage, EmailMultiAlternatives from django.template.loader import get_template +from premailer import Premailer from z3c.rml import rml2pdf from RIGS import models @@ -47,18 +49,26 @@ def send_eventauthorisation_success_email(instance): 'object': instance, } + if instance.email == instance.event.person.email: + context['to_name'] = instance.event.person.name + subject = "N%05d | %s - Event Authorised" % (instance.event.pk, instance.event.name) - client_email = EmailMessage( + client_email = EmailMultiAlternatives( subject, get_template("RIGS/eventauthorisation_client_success.txt").render(context), to=[instance.email], reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS], ) + css = staticfiles_storage.path('css/email.css') + html = Premailer(get_template("RIGS/eventauthorisation_client_success.html").render(context), + external_styles=css).transform() + client_email.attach_alternative(html, 'text/html') + escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', instance.event.name) - client_email.attach('N%05d - %s - RECEIPT.pdf' % (instance.event.pk, escapedEventName), + client_email.attach('N%05d - %s - CONFIRMATION.pdf' % (instance.event.pk, escapedEventName), merged.getvalue(), 'application/pdf' ) diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 422c3c5e..27317c57 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -193,7 +193,7 @@ - {% if not invoice %}VAT Registration Number: 170734807{% endif %} + {% if quote %}VAT Registration Number: 170734807{% endif %} Total (ex. VAT) £ {{ object.sum_total|floatformat:2 }} diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.html b/RIGS/templates/RIGS/eventauthorisation_client_success.html new file mode 100644 index 00000000..cccf8b4a --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.html @@ -0,0 +1,23 @@ +{% extends 'base_client_email.html' %} + +{% block content %} +
+

Hi {{ to_name|default:"there" }},

+ +

+ Your event N{{ object.event.pk|stringformat:"05d" }} has been successfully authorised + for {{ object.amount }} + by {{ object.name }} as of {{ object.last_edited_at }}. +

+ +

+ {% if object.event.organisation and object.event.organisation.union_account %}{# internal #} + Your event is now fully booked and payment will be processed by the finance department automatically. + {% else %}{# external #} + Your event is now fully booked and our finance department will be contact to arrange payment. + {% endif %} +

+ +

TEC PA & Lighting

+
+{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.txt b/RIGS/templates/RIGS/eventauthorisation_client_success.txt index 23e05786..693ba7ac 100644 --- a/RIGS/templates/RIGS/eventauthorisation_client_success.txt +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.txt @@ -1,6 +1,6 @@ -Hi there, +Hi {{ to_name|default:"there" }}, -Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. +Your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. {% if object.event.organisation and object.event.organisation.union_account %}{# internal #} Your event is now fully booked and payment will be processed by the finance department automatically. @@ -8,4 +8,4 @@ Your event is now fully booked and payment will be processed by the finance depa Your event is now fully booked and our finance department will be contact to arrange payment. {% endif %} -The TEC Rig Information Gathering System +TEC PA & Lighting From 602ba1d051f8b5098ed1d229db093df23396bfad Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 9 May 2017 18:48:03 +0100 Subject: [PATCH 27/44] Fix order of importing bootstrap variables. --- RIGS/static/css/email.css | 2 +- RIGS/static/scss/email.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RIGS/static/css/email.css b/RIGS/static/css/email.css index 006f196f..5a7e091e 100644 --- a/RIGS/static/css/email.css +++ b/RIGS/static/css/email.css @@ -1 +1 @@ -/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000 !important}.label{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #ddd !important}}@font-face{font-family:'Glyphicons Halflings';src:url(/static/fonts/bootstrap/glyphicons-halflings-regular.eot);src:url(/static/fonts/bootstrap/glyphicons-halflings-regular.eot?#iefix) format("embedded-opentype"),url(/static/fonts/bootstrap/glyphicons-halflings-regular.woff2) format("woff2"),url(/static/fonts/bootstrap/glyphicons-halflings-regular.woff) format("woff"),url(/static/fonts/bootstrap/glyphicons-halflings-regular.ttf) format("truetype"),url(/static/fonts/bootstrap/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format("svg")}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:transparent}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:hover,a:focus{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all 0.2s ease-in-out;-o-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h1 .small,h2 small,h2 .small,h3 small,h3 .small,h4 small,h4 .small,h5 small,h5 .small,h6 small,h6 .small,.h1 small,.h1 .small,.h2 small,.h2 .small,.h3 small,.h3 .small,.h4 small,.h4 .small,.h5 small,.h5 .small,.h6 small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:20px;margin-bottom:10px}h1 small,h1 .small,.h1 small,.h1 .small,h2 small,h2 .small,.h2 small,.h2 .small,h3 small,h3 .small,.h3 small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10px;margin-bottom:10px}h4 small,h4 .small,.h4 small,.h4 .small,h5 small,h5 .small,.h5 small,.h5 .small,h6 small,h6 .small,.h6 small,.h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width: 768px){.lead{font-size:21px}}small,.small{font-size:85%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase,.initialism{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:hover,a.text-primary:focus{color:#286090}.text-success{color:#3c763d}a.text-success:hover,a.text-success:focus{color:#2b542c}.text-info{color:#31708f}a.text-info:hover,a.text-info:focus{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:hover,a.text-warning:focus{color:#66512c}.text-danger{color:#a94442}a.text-danger:hover,a.text-danger:focus{color:#843534}.bg-primary{color:#fff}.bg-primary{background-color:#337ab7}a.bg-primary:hover,a.bg-primary:focus{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:hover,a.bg-success:focus{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover,a.bg-info:focus{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ul ol,ol ul,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.42857143}dt{font-weight:bold}dd{margin-left:0}.dl-horizontal dd:before,.dl-horizontal dd:after{content:" ";display:table}.dl-horizontal dd:after{clear:both}@media (min-width: 768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,.blockquote-reverse small:before,.blockquote-reverse .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,.blockquote-reverse small:after,.blockquote-reverse .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container:before,.container:after{content:" ";display:table}.container:after{clear:both}@media (min-width: 768px){.container{width:750px}}@media (min-width: 992px){.container{width:970px}}@media (min-width: 1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container-fluid:before,.container-fluid:after{content:" ";display:table}.container-fluid:after{clear:both}.row{margin-left:-15px;margin-right:-15px}.row:before,.row:after{content:" ";display:table}.row:after{clear:both}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-1{width:8.33333333%}.col-xs-2{width:16.66666667%}.col-xs-3{width:25%}.col-xs-4{width:33.33333333%}.col-xs-5{width:41.66666667%}.col-xs-6{width:50%}.col-xs-7{width:58.33333333%}.col-xs-8{width:66.66666667%}.col-xs-9{width:75%}.col-xs-10{width:83.33333333%}.col-xs-11{width:91.66666667%}.col-xs-12{width:100%}.col-xs-pull-0{right:auto}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-3{right:25%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-6{right:50%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-9{right:75%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-12{right:100%}.col-xs-push-0{left:auto}.col-xs-push-1{left:8.33333333%}.col-xs-push-2{left:16.66666667%}.col-xs-push-3{left:25%}.col-xs-push-4{left:33.33333333%}.col-xs-push-5{left:41.66666667%}.col-xs-push-6{left:50%}.col-xs-push-7{left:58.33333333%}.col-xs-push-8{left:66.66666667%}.col-xs-push-9{left:75%}.col-xs-push-10{left:83.33333333%}.col-xs-push-11{left:91.66666667%}.col-xs-push-12{left:100%}.col-xs-offset-0{margin-left:0%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-12{margin-left:100%}@media (min-width: 768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-1{width:8.33333333%}.col-sm-2{width:16.66666667%}.col-sm-3{width:25%}.col-sm-4{width:33.33333333%}.col-sm-5{width:41.66666667%}.col-sm-6{width:50%}.col-sm-7{width:58.33333333%}.col-sm-8{width:66.66666667%}.col-sm-9{width:75%}.col-sm-10{width:83.33333333%}.col-sm-11{width:91.66666667%}.col-sm-12{width:100%}.col-sm-pull-0{right:auto}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-3{right:25%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-6{right:50%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-9{right:75%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-12{right:100%}.col-sm-push-0{left:auto}.col-sm-push-1{left:8.33333333%}.col-sm-push-2{left:16.66666667%}.col-sm-push-3{left:25%}.col-sm-push-4{left:33.33333333%}.col-sm-push-5{left:41.66666667%}.col-sm-push-6{left:50%}.col-sm-push-7{left:58.33333333%}.col-sm-push-8{left:66.66666667%}.col-sm-push-9{left:75%}.col-sm-push-10{left:83.33333333%}.col-sm-push-11{left:91.66666667%}.col-sm-push-12{left:100%}.col-sm-offset-0{margin-left:0%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-12{margin-left:100%}}@media (min-width: 992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-1{width:8.33333333%}.col-md-2{width:16.66666667%}.col-md-3{width:25%}.col-md-4{width:33.33333333%}.col-md-5{width:41.66666667%}.col-md-6{width:50%}.col-md-7{width:58.33333333%}.col-md-8{width:66.66666667%}.col-md-9{width:75%}.col-md-10{width:83.33333333%}.col-md-11{width:91.66666667%}.col-md-12{width:100%}.col-md-pull-0{right:auto}.col-md-pull-1{right:8.33333333%}.col-md-pull-2{right:16.66666667%}.col-md-pull-3{right:25%}.col-md-pull-4{right:33.33333333%}.col-md-pull-5{right:41.66666667%}.col-md-pull-6{right:50%}.col-md-pull-7{right:58.33333333%}.col-md-pull-8{right:66.66666667%}.col-md-pull-9{right:75%}.col-md-pull-10{right:83.33333333%}.col-md-pull-11{right:91.66666667%}.col-md-pull-12{right:100%}.col-md-push-0{left:auto}.col-md-push-1{left:8.33333333%}.col-md-push-2{left:16.66666667%}.col-md-push-3{left:25%}.col-md-push-4{left:33.33333333%}.col-md-push-5{left:41.66666667%}.col-md-push-6{left:50%}.col-md-push-7{left:58.33333333%}.col-md-push-8{left:66.66666667%}.col-md-push-9{left:75%}.col-md-push-10{left:83.33333333%}.col-md-push-11{left:91.66666667%}.col-md-push-12{left:100%}.col-md-offset-0{margin-left:0%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-3{margin-left:25%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-6{margin-left:50%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-9{margin-left:75%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-12{margin-left:100%}}@media (min-width: 1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-1{width:8.33333333%}.col-lg-2{width:16.66666667%}.col-lg-3{width:25%}.col-lg-4{width:33.33333333%}.col-lg-5{width:41.66666667%}.col-lg-6{width:50%}.col-lg-7{width:58.33333333%}.col-lg-8{width:66.66666667%}.col-lg-9{width:75%}.col-lg-10{width:83.33333333%}.col-lg-11{width:91.66666667%}.col-lg-12{width:100%}.col-lg-pull-0{right:auto}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-3{right:25%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-6{right:50%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-9{right:75%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-12{right:100%}.col-lg-push-0{left:auto}.col-lg-push-1{left:8.33333333%}.col-lg-push-2{left:16.66666667%}.col-lg-push-3{left:25%}.col-lg-push-4{left:33.33333333%}.col-lg-push-5{left:41.66666667%}.col-lg-push-6{left:50%}.col-lg-push-7{left:58.33333333%}.col-lg-push-8{left:66.66666667%}.col-lg-push-9{left:75%}.col-lg-push-10{left:83.33333333%}.col-lg-push-11{left:91.66666667%}.col-lg-push-12{left:100%}.col-lg-offset-0{margin-left:0%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-12{margin-left:100%}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>thead>tr>td,.table>tbody>tr>th,.table>tbody>tr>td,.table>tfoot>tr>th,.table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>th,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>thead>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>thead>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>thead>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>thead>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>thead>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:0.01%}@media screen and (max-width: 767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;-o-transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{border:0;background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio: 0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:34px}input[type="date"].input-sm,.input-group-sm input[type="date"],input[type="time"].input-sm,.input-group-sm input[type="time"],input[type="datetime-local"].input-sm,.input-group-sm input[type="datetime-local"],input[type="month"].input-sm,.input-group-sm input[type="month"]{line-height:30px}input[type="date"].input-lg,.input-group-lg input[type="date"],input[type="time"].input-lg,.input-group-lg input[type="time"],input[type="datetime-local"].input-lg,.input-group-lg input[type="datetime-local"],input[type="month"].input-lg,.input-group-lg input[type="month"]{line-height:46px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="radio"].disabled,fieldset[disabled] input[type="radio"],input[type="checkbox"][disabled],input[type="checkbox"].disabled,fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,fieldset[disabled] .radio-inline,.checkbox-inline.disabled,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,fieldset[disabled] .radio label,.checkbox.disabled label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-success .form-control-feedback{color:#3c763d}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-error .form-control-feedback{color:#a94442}.has-feedback label ~ .form-control-feedback{top:25px}.has-feedback label.sr-only ~ .form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width: 768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}.form-horizontal .form-group:before,.form-horizontal .form-group:after{content:" ";display:table}.form-horizontal .form-group:after{clear:both}@media (min-width: 768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width: 768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width: 768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn.focus,.btn:active:focus,.btn:active.focus,.btn.active:focus,.btn.active.focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.btn-default.dropdown-toggle{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default:active:focus,.btn-default:active.focus,.btn-default.active:hover,.btn-default.active:focus,.btn-default.active.focus,.open>.btn-default.dropdown-toggle:hover,.open>.btn-default.dropdown-toggle:focus,.open>.btn-default.dropdown-toggle.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.btn-default.dropdown-toggle{background-image:none}.btn-default.disabled:hover,.btn-default.disabled:focus,.btn-default.disabled.focus,.btn-default[disabled]:hover,.btn-default[disabled]:focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default:hover,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default.focus{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active,.btn-primary.active,.open>.btn-primary.dropdown-toggle{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active:hover,.btn-primary:active:focus,.btn-primary:active.focus,.btn-primary.active:hover,.btn-primary.active:focus,.btn-primary.active.focus,.open>.btn-primary.dropdown-toggle:hover,.open>.btn-primary.dropdown-toggle:focus,.open>.btn-primary.dropdown-toggle.focus{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary:active,.btn-primary.active,.open>.btn-primary.dropdown-toggle{background-image:none}.btn-primary.disabled:hover,.btn-primary.disabled:focus,.btn-primary.disabled.focus,.btn-primary[disabled]:hover,.btn-primary[disabled]:focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary:hover,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary.focus{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active,.btn-success.active,.open>.btn-success.dropdown-toggle{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active:hover,.btn-success:active:focus,.btn-success:active.focus,.btn-success.active:hover,.btn-success.active:focus,.btn-success.active.focus,.open>.btn-success.dropdown-toggle:hover,.open>.btn-success.dropdown-toggle:focus,.open>.btn-success.dropdown-toggle.focus{color:#fff;background-color:#398439;border-color:#255625}.btn-success:active,.btn-success.active,.open>.btn-success.dropdown-toggle{background-image:none}.btn-success.disabled:hover,.btn-success.disabled:focus,.btn-success.disabled.focus,.btn-success[disabled]:hover,.btn-success[disabled]:focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success:hover,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success.focus{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active,.btn-info.active,.open>.btn-info.dropdown-toggle{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active:hover,.btn-info:active:focus,.btn-info:active.focus,.btn-info.active:hover,.btn-info.active:focus,.btn-info.active.focus,.open>.btn-info.dropdown-toggle:hover,.open>.btn-info.dropdown-toggle:focus,.open>.btn-info.dropdown-toggle.focus{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info:active,.btn-info.active,.open>.btn-info.dropdown-toggle{background-image:none}.btn-info.disabled:hover,.btn-info.disabled:focus,.btn-info.disabled.focus,.btn-info[disabled]:hover,.btn-info[disabled]:focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info:hover,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info.focus{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.btn-warning.dropdown-toggle{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning:active:focus,.btn-warning:active.focus,.btn-warning.active:hover,.btn-warning.active:focus,.btn-warning.active.focus,.open>.btn-warning.dropdown-toggle:hover,.open>.btn-warning.dropdown-toggle:focus,.open>.btn-warning.dropdown-toggle.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.btn-warning.dropdown-toggle{background-image:none}.btn-warning.disabled:hover,.btn-warning.disabled:focus,.btn-warning.disabled.focus,.btn-warning[disabled]:hover,.btn-warning[disabled]:focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning:hover,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning.focus{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.btn-danger.dropdown-toggle{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger:active:focus,.btn-danger:active.focus,.btn-danger.active:hover,.btn-danger.active:focus,.btn-danger.active.focus,.open>.btn-danger.dropdown-toggle:hover,.open>.btn-danger.dropdown-toggle:focus,.open>.btn-danger.dropdown-toggle.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.btn-danger.dropdown-toggle{background-image:none}.btn-danger.disabled:hover,.btn-danger.disabled:focus,.btn-danger.disabled.focus,.btn-danger[disabled]:hover,.btn-danger[disabled]:focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger:hover,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger.focus{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#337ab7;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:hover,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.client-header{background-image:url("https://www.nottinghamtec.co.uk/imgs/wof2014-1.jpg");background-color:#222;background-repeat:no-repeat;background-position:center;background-size:cover;margin-bottom:2em}.client-header img{height:8em;margin:2em} +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000 !important}.label{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #ddd !important}}@font-face{font-family:'Glyphicons Halflings';src:url(/static/fonts/glyphicons-halflings-regular.eot?1485195091);src:url(/static/fonts/glyphicons-halflings-regular.eot?&1485195091#iefix) format("embedded-opentype"),url(/static/fonts/glyphicons-halflings-regular.woff2?1485195091) format("woff2"),url(/static/fonts/glyphicons-halflings-regular.woff?1485195091) format("woff"),url(/static/fonts/glyphicons-halflings-regular.ttf?1485195091) format("truetype"),url(/static/fonts/glyphicons-halflings-regular.svg?1485195091#glyphicons_halflingsregular) format("svg")}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:transparent}body{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#428bca;text-decoration:none}a:hover,a:focus{color:#2a6496;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all 0.2s ease-in-out;-o-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h1 .small,h2 small,h2 .small,h3 small,h3 .small,h4 small,h4 .small,h5 small,h5 .small,h6 small,h6 .small,.h1 small,.h1 .small,.h2 small,.h2 .small,.h3 small,.h3 .small,.h4 small,.h4 .small,.h5 small,.h5 .small,.h6 small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:20px;margin-bottom:10px}h1 small,h1 .small,.h1 small,.h1 .small,h2 small,h2 .small,.h2 small,.h2 .small,h3 small,h3 .small,.h3 small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10px;margin-bottom:10px}h4 small,h4 .small,.h4 small,.h4 .small,h5 small,h5 .small,.h5 small,.h5 .small,h6 small,h6 .small,.h6 small,.h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width: 768px){.lead{font-size:21px}}small,.small{font-size:85%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase,.initialism{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#428bca}a.text-primary:hover,a.text-primary:focus{color:#3071a9}.text-success{color:#3c763d}a.text-success:hover,a.text-success:focus{color:#2b542c}.text-info{color:#31708f}a.text-info:hover,a.text-info:focus{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:hover,a.text-warning:focus{color:#66512c}.text-danger{color:#a94442}a.text-danger:hover,a.text-danger:focus{color:#843534}.bg-primary{color:#fff}.bg-primary{background-color:#428bca}a.bg-primary:hover,a.bg-primary:focus{background-color:#3071a9}.bg-success{background-color:#dff0d8}a.bg-success:hover,a.bg-success:focus{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover,a.bg-info:focus{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ul ol,ol ul,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.42857143}dt{font-weight:bold}dd{margin-left:0}.dl-horizontal dd:before,.dl-horizontal dd:after{content:" ";display:table}.dl-horizontal dd:after{clear:both}@media (min-width: 768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,.blockquote-reverse small:before,.blockquote-reverse .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,.blockquote-reverse small:after,.blockquote-reverse .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container:before,.container:after{content:" ";display:table}.container:after{clear:both}@media (min-width: 768px){.container{width:750px}}@media (min-width: 992px){.container{width:970px}}@media (min-width: 1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.container-fluid:before,.container-fluid:after{content:" ";display:table}.container-fluid:after{clear:both}.row{margin-left:-15px;margin-right:-15px}.row:before,.row:after{content:" ";display:table}.row:after{clear:both}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-1{width:8.33333333%}.col-xs-2{width:16.66666667%}.col-xs-3{width:25%}.col-xs-4{width:33.33333333%}.col-xs-5{width:41.66666667%}.col-xs-6{width:50%}.col-xs-7{width:58.33333333%}.col-xs-8{width:66.66666667%}.col-xs-9{width:75%}.col-xs-10{width:83.33333333%}.col-xs-11{width:91.66666667%}.col-xs-12{width:100%}.col-xs-pull-0{right:auto}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-3{right:25%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-6{right:50%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-9{right:75%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-12{right:100%}.col-xs-push-0{left:auto}.col-xs-push-1{left:8.33333333%}.col-xs-push-2{left:16.66666667%}.col-xs-push-3{left:25%}.col-xs-push-4{left:33.33333333%}.col-xs-push-5{left:41.66666667%}.col-xs-push-6{left:50%}.col-xs-push-7{left:58.33333333%}.col-xs-push-8{left:66.66666667%}.col-xs-push-9{left:75%}.col-xs-push-10{left:83.33333333%}.col-xs-push-11{left:91.66666667%}.col-xs-push-12{left:100%}.col-xs-offset-0{margin-left:0%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-12{margin-left:100%}@media (min-width: 768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-1{width:8.33333333%}.col-sm-2{width:16.66666667%}.col-sm-3{width:25%}.col-sm-4{width:33.33333333%}.col-sm-5{width:41.66666667%}.col-sm-6{width:50%}.col-sm-7{width:58.33333333%}.col-sm-8{width:66.66666667%}.col-sm-9{width:75%}.col-sm-10{width:83.33333333%}.col-sm-11{width:91.66666667%}.col-sm-12{width:100%}.col-sm-pull-0{right:auto}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-3{right:25%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-6{right:50%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-9{right:75%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-12{right:100%}.col-sm-push-0{left:auto}.col-sm-push-1{left:8.33333333%}.col-sm-push-2{left:16.66666667%}.col-sm-push-3{left:25%}.col-sm-push-4{left:33.33333333%}.col-sm-push-5{left:41.66666667%}.col-sm-push-6{left:50%}.col-sm-push-7{left:58.33333333%}.col-sm-push-8{left:66.66666667%}.col-sm-push-9{left:75%}.col-sm-push-10{left:83.33333333%}.col-sm-push-11{left:91.66666667%}.col-sm-push-12{left:100%}.col-sm-offset-0{margin-left:0%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-12{margin-left:100%}}@media (min-width: 992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-1{width:8.33333333%}.col-md-2{width:16.66666667%}.col-md-3{width:25%}.col-md-4{width:33.33333333%}.col-md-5{width:41.66666667%}.col-md-6{width:50%}.col-md-7{width:58.33333333%}.col-md-8{width:66.66666667%}.col-md-9{width:75%}.col-md-10{width:83.33333333%}.col-md-11{width:91.66666667%}.col-md-12{width:100%}.col-md-pull-0{right:auto}.col-md-pull-1{right:8.33333333%}.col-md-pull-2{right:16.66666667%}.col-md-pull-3{right:25%}.col-md-pull-4{right:33.33333333%}.col-md-pull-5{right:41.66666667%}.col-md-pull-6{right:50%}.col-md-pull-7{right:58.33333333%}.col-md-pull-8{right:66.66666667%}.col-md-pull-9{right:75%}.col-md-pull-10{right:83.33333333%}.col-md-pull-11{right:91.66666667%}.col-md-pull-12{right:100%}.col-md-push-0{left:auto}.col-md-push-1{left:8.33333333%}.col-md-push-2{left:16.66666667%}.col-md-push-3{left:25%}.col-md-push-4{left:33.33333333%}.col-md-push-5{left:41.66666667%}.col-md-push-6{left:50%}.col-md-push-7{left:58.33333333%}.col-md-push-8{left:66.66666667%}.col-md-push-9{left:75%}.col-md-push-10{left:83.33333333%}.col-md-push-11{left:91.66666667%}.col-md-push-12{left:100%}.col-md-offset-0{margin-left:0%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-3{margin-left:25%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-6{margin-left:50%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-9{margin-left:75%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-12{margin-left:100%}}@media (min-width: 1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-1{width:8.33333333%}.col-lg-2{width:16.66666667%}.col-lg-3{width:25%}.col-lg-4{width:33.33333333%}.col-lg-5{width:41.66666667%}.col-lg-6{width:50%}.col-lg-7{width:58.33333333%}.col-lg-8{width:66.66666667%}.col-lg-9{width:75%}.col-lg-10{width:83.33333333%}.col-lg-11{width:91.66666667%}.col-lg-12{width:100%}.col-lg-pull-0{right:auto}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-3{right:25%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-6{right:50%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-9{right:75%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-12{right:100%}.col-lg-push-0{left:auto}.col-lg-push-1{left:8.33333333%}.col-lg-push-2{left:16.66666667%}.col-lg-push-3{left:25%}.col-lg-push-4{left:33.33333333%}.col-lg-push-5{left:41.66666667%}.col-lg-push-6{left:50%}.col-lg-push-7{left:58.33333333%}.col-lg-push-8{left:66.66666667%}.col-lg-push-9{left:75%}.col-lg-push-10{left:83.33333333%}.col-lg-push-11{left:91.66666667%}.col-lg-push-12{left:100%}.col-lg-offset-0{margin-left:0%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-12{margin-left:100%}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>thead>tr>td,.table>tbody>tr>th,.table>tbody>tr>td,.table>tfoot>tr>th,.table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>th,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>thead>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>thead>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>thead>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>thead>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>thead>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:0.01%}@media screen and (max-width: 767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;-o-transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;transition:border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.form-control::-moz-placeholder{color:#777;opacity:1}.form-control:-ms-input-placeholder{color:#777}.form-control::-webkit-input-placeholder{color:#777}.form-control::-ms-expand{border:0;background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio: 0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:34px}input[type="date"].input-sm,.input-group-sm input[type="date"],input[type="time"].input-sm,.input-group-sm input[type="time"],input[type="datetime-local"].input-sm,.input-group-sm input[type="datetime-local"],input[type="month"].input-sm,.input-group-sm input[type="month"]{line-height:30px}input[type="date"].input-lg,.input-group-lg input[type="date"],input[type="time"].input-lg,.input-group-lg input[type="time"],input[type="datetime-local"].input-lg,.input-group-lg input[type="datetime-local"],input[type="month"].input-lg,.input-group-lg input[type="month"]{line-height:46px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="radio"].disabled,fieldset[disabled] input[type="radio"],input[type="checkbox"][disabled],input[type="checkbox"].disabled,fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,fieldset[disabled] .radio-inline,.checkbox-inline.disabled,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,fieldset[disabled] .radio label,.checkbox.disabled label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.33}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-success .form-control-feedback{color:#3c763d}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-error .form-control-feedback{color:#a94442}.has-feedback label ~ .form-control-feedback{top:25px}.has-feedback label.sr-only ~ .form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width: 768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}.form-horizontal .form-group:before,.form-horizontal .form-group:after{content:" ";display:table}.form-horizontal .form-group:after{clear:both}@media (min-width: 768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width: 768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width: 768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn.focus,.btn:active:focus,.btn:active.focus,.btn.active:focus,.btn.active.focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.btn-default.dropdown-toggle{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default:active:focus,.btn-default:active.focus,.btn-default.active:hover,.btn-default.active:focus,.btn-default.active.focus,.open>.btn-default.dropdown-toggle:hover,.open>.btn-default.dropdown-toggle:focus,.open>.btn-default.dropdown-toggle.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.btn-default.dropdown-toggle{background-image:none}.btn-default.disabled:hover,.btn-default.disabled:focus,.btn-default.disabled.focus,.btn-default[disabled]:hover,.btn-default[disabled]:focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default:hover,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default.focus{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#428bca;border-color:#357ebd}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#3071a9;border-color:#193c5a}.btn-primary:hover{color:#fff;background-color:#3071a9;border-color:#285e8e}.btn-primary:active,.btn-primary.active,.open>.btn-primary.dropdown-toggle{color:#fff;background-color:#3071a9;border-color:#285e8e}.btn-primary:active:hover,.btn-primary:active:focus,.btn-primary:active.focus,.btn-primary.active:hover,.btn-primary.active:focus,.btn-primary.active.focus,.open>.btn-primary.dropdown-toggle:hover,.open>.btn-primary.dropdown-toggle:focus,.open>.btn-primary.dropdown-toggle.focus{color:#fff;background-color:#285e8e;border-color:#193c5a}.btn-primary:active,.btn-primary.active,.open>.btn-primary.dropdown-toggle{background-image:none}.btn-primary.disabled:hover,.btn-primary.disabled:focus,.btn-primary.disabled.focus,.btn-primary[disabled]:hover,.btn-primary[disabled]:focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary:hover,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary.focus{background-color:#428bca;border-color:#357ebd}.btn-primary .badge{color:#428bca;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active,.btn-success.active,.open>.btn-success.dropdown-toggle{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active:hover,.btn-success:active:focus,.btn-success:active.focus,.btn-success.active:hover,.btn-success.active:focus,.btn-success.active.focus,.open>.btn-success.dropdown-toggle:hover,.open>.btn-success.dropdown-toggle:focus,.open>.btn-success.dropdown-toggle.focus{color:#fff;background-color:#398439;border-color:#255625}.btn-success:active,.btn-success.active,.open>.btn-success.dropdown-toggle{background-image:none}.btn-success.disabled:hover,.btn-success.disabled:focus,.btn-success.disabled.focus,.btn-success[disabled]:hover,.btn-success[disabled]:focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success:hover,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success.focus{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active,.btn-info.active,.open>.btn-info.dropdown-toggle{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active:hover,.btn-info:active:focus,.btn-info:active.focus,.btn-info.active:hover,.btn-info.active:focus,.btn-info.active.focus,.open>.btn-info.dropdown-toggle:hover,.open>.btn-info.dropdown-toggle:focus,.open>.btn-info.dropdown-toggle.focus{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info:active,.btn-info.active,.open>.btn-info.dropdown-toggle{background-image:none}.btn-info.disabled:hover,.btn-info.disabled:focus,.btn-info.disabled.focus,.btn-info[disabled]:hover,.btn-info[disabled]:focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info:hover,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info.focus{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.btn-warning.dropdown-toggle{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning:active:focus,.btn-warning:active.focus,.btn-warning.active:hover,.btn-warning.active:focus,.btn-warning.active.focus,.open>.btn-warning.dropdown-toggle:hover,.open>.btn-warning.dropdown-toggle:focus,.open>.btn-warning.dropdown-toggle.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.btn-warning.dropdown-toggle{background-image:none}.btn-warning.disabled:hover,.btn-warning.disabled:focus,.btn-warning.disabled.focus,.btn-warning[disabled]:hover,.btn-warning[disabled]:focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning:hover,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning.focus{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.btn-danger.dropdown-toggle{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger:active:focus,.btn-danger:active.focus,.btn-danger.active:hover,.btn-danger.active:focus,.btn-danger.active.focus,.open>.btn-danger.dropdown-toggle:hover,.open>.btn-danger.dropdown-toggle:focus,.open>.btn-danger.dropdown-toggle.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.btn-danger.dropdown-toggle{background-image:none}.btn-danger.disabled:hover,.btn-danger.disabled:focus,.btn-danger.disabled.focus,.btn-danger[disabled]:hover,.btn-danger[disabled]:focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger:hover,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger.focus{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#428bca;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#2a6496;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:hover,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.client-header{background-image:url("https://www.nottinghamtec.co.uk/imgs/wof2014-1.jpg");background-color:#222;background-repeat:no-repeat;background-position:center;background-size:cover;margin-bottom:2em}.client-header img{height:8em;margin:2em} diff --git a/RIGS/static/scss/email.scss b/RIGS/static/scss/email.scss index 9b627911..7c0b5740 100644 --- a/RIGS/static/scss/email.scss +++ b/RIGS/static/scss/email.scss @@ -1,7 +1,7 @@ @import "bootstrap-compass"; // Core variables and mixins -@import "bootstrap/variables"; @import "bootstrap-variables"; +@import "bootstrap/variables"; @import "bootstrap/mixins"; // Reset and dependencies From 6b0593895339db9cbad7c91ef153b3f57e010c79 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 9 May 2017 19:21:35 +0100 Subject: [PATCH 28/44] Set authorisation button text to be more verbose --- RIGS/templates/RIGS/event_detail_buttons.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/RIGS/templates/RIGS/event_detail_buttons.html b/RIGS/templates/RIGS/event_detail_buttons.html index 0c1adae3..9ad4b189 100644 --- a/RIGS/templates/RIGS/event_detail_buttons.html +++ b/RIGS/templates/RIGS/event_detail_buttons.html @@ -22,7 +22,17 @@ " href="{% url 'event_authorise_request' object.pk %}"> - + {% if perms.RIGS.add_invoice %} +
+
+

An error occured.

+

Your RIGS account must have an @nottinghamtec.co.uk email address before you can send emails to clients.

+
+
+
+{% endblock %} diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index bbee077c..96d074ca 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -1054,7 +1054,7 @@ class TECEventAuthorisationTest(TestCase): first_name='Test', last_name='TEC User', username='eventauthtest', - email='teccie@functional.test', + email='teccie@nottinghamtec.co.uk', is_superuser=True # lazily grant all permissions )[0] cls.profile.set_password('eventauthtest123') @@ -1073,6 +1073,16 @@ class TECEventAuthorisationTest(TestCase): ) self.url = reverse('event_authorise_request', kwargs={'pk': self.event.pk}) + def test_email_check(self): + self.profile.email = 'teccie@someotherdomain.com' + self.profile.save() + + self.assertTrue(self.client.login(username=self.profile.username, password='eventauthtest123')) + + response = self.client.post(self.url) + + self.assertContains(response, 'must have an @nottinghamtec.co.uk email address') + def test_request_send(self): self.assertTrue(self.client.login(username=self.profile.username, password='eventauthtest123')) response = self.client.post(self.url) From eb1e8935f4689c93182562d3d5bd1cecdc220e52 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 12 May 2017 20:56:01 +0100 Subject: [PATCH 30/44] Fix reversion in signals.py --- RIGS/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RIGS/signals.py b/RIGS/signals.py index df5dc8fe..71263681 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -95,4 +95,4 @@ def on_revision_commit(instances, **kwargs): send_eventauthorisation_success_email(instance) -reversion.post_revision_commit.connect(on_revision_commit) +reversion.revisions.post_revision_commit.connect(on_revision_commit) From 865bb131a517b7e38671c600ad358165b65f5a12 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 12 May 2017 21:02:48 +0100 Subject: [PATCH 31/44] Add merge migration --- RIGS/migrations/0031_merge_20170512_2102.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 RIGS/migrations/0031_merge_20170512_2102.py diff --git a/RIGS/migrations/0031_merge_20170512_2102.py b/RIGS/migrations/0031_merge_20170512_2102.py new file mode 100644 index 00000000..7056c46b --- /dev/null +++ b/RIGS/migrations/0031_merge_20170512_2102.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-12 20:02 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0030_auth_request_sending'), + ('RIGS', '0026_auto_20170510_1846'), + ] + + operations = [ + ] From 36d258253fd3283acaa4839863cccca2bdf35730 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 12 May 2017 21:32:17 +0100 Subject: [PATCH 32/44] Fix issues caused by dependency upgrades --- PyRIGS/decorators.py | 3 +-- RIGS/test_functional.py | 8 ++------ RIGS/test_models.py | 3 ++- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/PyRIGS/decorators.py b/PyRIGS/decorators.py index 4b897923..f9bb4c20 100644 --- a/PyRIGS/decorators.py +++ b/PyRIGS/decorators.py @@ -1,6 +1,5 @@ from django.contrib.auth import REDIRECT_FIELD_NAME from django.shortcuts import render -from django.template import RequestContext from django.http import HttpResponseRedirect from django.core.urlresolvers import reverse @@ -88,7 +87,7 @@ def nottinghamtec_address_required(function): def wrap(request, *args, **kwargs): # Fail if current user's email address isn't @nottinghamtec.co.uk if not request.user.email.endswith('@nottinghamtec.co.uk'): - error_resp = render_to_response('RIGS/eventauthorisation_request_error.html', context_instance=RequestContext(request)) + error_resp = render(request, 'RIGS/eventauthorisation_request_error.html') return error_resp return function(request, *args, **kwargs) diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 231ade20..38c7173a 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -520,9 +520,6 @@ class EventTest(LiveServerTestCase): self.assertIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) - # Check the PO hasn't carried through - self.assertNotIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) - self.browser.get(self.live_server_url + '/event/' + str(testEvent.pk)) #Go back to the old event #Check that based-on hasn't crept into the old event @@ -530,9 +527,6 @@ class EventTest(LiveServerTestCase): self.assertNotIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) - # Check the PO remains on the old event - self.assertIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) - # Check the items are as they were table = self.browser.find_element_by_id('item-table') # ID number is known, see above self.assertIn("Test Item 1", table.text) @@ -976,6 +970,7 @@ class ClientEventAuthorisationTest(TestCase): email='teccie@functional.test', is_superuser=True # lazily grant all permissions )[0] + self.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1') venue = models.Venue.objects.create(name='Authorisation Test Venue') client = models.Person.objects.create(name='Authorisation Test Person', email='authorisation@functional.test') organisation = models.Organisation.objects.create(name='Authorisation Test Organisation', union_account=False) @@ -1078,6 +1073,7 @@ class TECEventAuthorisationTest(TestCase): cls.profile.save() def setUp(self): + self.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1') venue = models.Venue.objects.create(name='Authorisation Test Venue') client = models.Person.objects.create(name='Authorisation Test Person', email='authorisation@functional.test') organisation = models.Organisation.objects.create(name='Authorisation Test Organisation', union_account=False) diff --git a/RIGS/test_models.py b/RIGS/test_models.py index 1fd8b4a0..b5dfd1e3 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -360,6 +360,7 @@ class EventPricingTestCase(TestCase): class EventAuthorisationTestCase(TestCase): def setUp(self): + models.VatRate.objects.create(rate=0.20, comment="TP V1", start_at='2013-01-01') self.profile = models.Profile.objects.get_or_create( first_name='Test', last_name='TEC User', @@ -385,7 +386,7 @@ class EventAuthorisationTestCase(TestCase): self.assertTrue(self.event.authorised) def test_last_edited(self): - with reversion.create_revision(): + with reversion.revisions.create_revision(): auth = models.EventAuthorisation.objects.create(event=self.event, email="authroisation@model.test.case", name="Test Auth", amount=self.event.total, sent_by=self.profile) self.assertIsNotNone(auth.last_edited_at) From 4b87b0a19696e95fdf115be09dfbd7f4c1bedc43 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Wed, 17 May 2017 18:59:06 +0100 Subject: [PATCH 33/44] Add some visual indicators that authorisations have been submitted. This will show teccies and clients that RIGS is processing emails which can take a short while. Should prevent duplicate sending. --- RIGS/static/js/interaction.js | 2 +- RIGS/templates/RIGS/eventauthorisation_form.html | 9 ++++++++- .../RIGS/eventauthorisation_request.html | 15 +++++++++++++-- templates/base_ajax.html | 2 +- templates/base_client.html | 16 ++++++++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/RIGS/static/js/interaction.js b/RIGS/static/js/interaction.js index e7d023e1..56f04dec 100644 --- a/RIGS/static/js/interaction.js +++ b/RIGS/static/js/interaction.js @@ -136,4 +136,4 @@ $("#item-table tbody").sortable({ }); } -}); \ No newline at end of file +}); diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html index 0809b29a..9182df41 100644 --- a/RIGS/templates/RIGS/eventauthorisation_form.html +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -7,7 +7,14 @@ {% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_request.html b/RIGS/templates/RIGS/eventauthorisation_request.html index 9c31f1fe..6067b3fe 100644 --- a/RIGS/templates/RIGS/eventauthorisation_request.html +++ b/RIGS/templates/RIGS/eventauthorisation_request.html @@ -22,7 +22,9 @@
- {% csrf_token %} + + {% csrf_token %}
{% include 'form_errors.html' %} @@ -38,11 +40,20 @@
- +
+ + {% endblock %} diff --git a/templates/base_ajax.html b/templates/base_ajax.html index 298ca314..79b934d3 100644 --- a/templates/base_ajax.html +++ b/templates/base_ajax.html @@ -22,4 +22,4 @@ });
-
\ No newline at end of file + diff --git a/templates/base_client.html b/templates/base_client.html index b8cd614b..824e9f88 100644 --- a/templates/base_client.html +++ b/templates/base_client.html @@ -71,6 +71,22 @@ + + - - - {% block preload_js %} - {% endblock %} + - {% block extra-head %}{% endblock %} - - - -
- +
+ + + - -
+ + + + + + +
+ + + +
+ +
- - - + + +
+ {% block content %}{% endblock %} +
+
-
-
-
- {% block content %}{% endblock %} -
-
+ -{% block js %} -{% endblock %} - + From 703fb8561a9673eb0ec88c54d98039951dafe0d1 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 15:32:54 +0100 Subject: [PATCH 35/44] =?UTF-8?q?Move=20font=20definition=20into=20div,=20?= =?UTF-8?q?doesn=E2=80=99t=20seem=20to=20be=20picked=20up=20in=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RIGS/static/css/email.css | 2 +- RIGS/static/scss/email.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RIGS/static/css/email.css b/RIGS/static/css/email.css index 937d6b42..915b52f3 100644 --- a/RIGS/static/css/email.css +++ b/RIGS/static/css/email.css @@ -1 +1 @@ -body{font-family:"Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;margin:0px}.main-table{width:100%;border-collapse:collapse}.client-header{background-image:url("https://www.nottinghamtec.co.uk/imgs/wof2014-1.jpg");background-color:#222;background-repeat:no-repeat;background-position:center;width:100%;margin-bottom:28px}.client-header .logos{width:100%;max-width:640px}.client-header img{height:110px}.content-container{width:100%}.content-container .content{width:100%;max-width:600px;padding:10px;text-align:left}.content-container .content .button-container{width:100%}.content-container .content .button-container .button{padding:6px 12px;background-color:#357ebf;border-radius:4px}.content-container .content .button-container .button a{color:#fff;text-decoration:none} +body{margin:0px}.main-table{width:100%;border-collapse:collapse}.client-header{background-image:url("https://www.nottinghamtec.co.uk/imgs/wof2014-1.jpg");background-color:#222;background-repeat:no-repeat;background-position:center;width:100%;margin-bottom:28px}.client-header .logos{width:100%;max-width:640px}.client-header img{height:110px}.content-container{width:100%}.content-container .content{font-family:"Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;width:100%;max-width:600px;padding:10px;text-align:left}.content-container .content .button-container{width:100%}.content-container .content .button-container .button{padding:6px 12px;background-color:#357ebf;border-radius:4px}.content-container .content .button-container .button a{color:#fff;text-decoration:none} diff --git a/RIGS/static/scss/email.scss b/RIGS/static/scss/email.scss index e94d09c5..73ffe478 100644 --- a/RIGS/static/scss/email.scss +++ b/RIGS/static/scss/email.scss @@ -1,9 +1,7 @@ $button_color: #357ebf; body{ - font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; margin: 0px; - } .main-table{ @@ -36,6 +34,8 @@ body{ width: 100%; .content { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + width: 100%; max-width: 600px; padding: 10px; From b4ab29393eca1065d9f0b966433603d82c45b091 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 16:21:44 +0100 Subject: [PATCH 36/44] Allow confirmation emails to fail without blocking the interface --- RIGS/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RIGS/signals.py b/RIGS/signals.py index 71263681..faf0f873 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -85,8 +85,8 @@ def send_eventauthorisation_success_email(instance): ) # Now we have both emails successfully generated, send them out - client_email.send() - mic_email.send() + client_email.send(fail_silently=True) + mic_email.send(fail_silently=True) def on_revision_commit(instances, **kwargs): From 4e79f00551138c191f2e3653b63e36a5e93fed3e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 17:22:59 +0100 Subject: [PATCH 37/44] Add pound signs to confirmation emails --- RIGS/templates/RIGS/eventauthorisation_client_success.html | 2 +- RIGS/templates/RIGS/eventauthorisation_client_success.txt | 2 +- RIGS/templates/RIGS/eventauthorisation_mic_success.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.html b/RIGS/templates/RIGS/eventauthorisation_client_success.html index 6226c469..dc6d0485 100644 --- a/RIGS/templates/RIGS/eventauthorisation_client_success.html +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.html @@ -5,7 +5,7 @@

Your event N{{ object.event.pk|stringformat:"05d" }} has been successfully authorised - for {{ object.amount }} + for £{{ object.amount }} by {{ object.name }} as of {{ object.last_edited_at }}.

diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.txt b/RIGS/templates/RIGS/eventauthorisation_client_success.txt index 693ba7ac..ff934c4d 100644 --- a/RIGS/templates/RIGS/eventauthorisation_client_success.txt +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.txt @@ -1,6 +1,6 @@ Hi {{ to_name|default:"there" }}, -Your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. +Your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. {% if object.event.organisation and object.event.organisation.union_account %}{# internal #} Your event is now fully booked and payment will be processed by the finance department automatically. diff --git a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt index 98e1cdb8..7548cad4 100644 --- a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt +++ b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt @@ -1,5 +1,5 @@ Hi {{object.event.mic.get_full_name|default_if_none:"somebody"}}, -Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for {{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. +Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.last_edited_at}}. The TEC Rig Information Gathering System From 0a45b047a29d709d7f42f2f81d8b35aaf685cffe Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 17:34:49 +0100 Subject: [PATCH 38/44] Add warnings when editing an event that has already been sent to a client --- RIGS/rigboard.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index dbe7e6c7..e9597ab5 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -118,6 +118,14 @@ class EventUpdate(generic.UpdateView): value = form[field].value() if value is not None and value != '': context[field] = model.objects.get(pk=value) + + # If this event has already been emailed to a client, show a warning + if self.object.auth_request_at is not None: + messages.info(self.request, 'This event has already been sent to the client for authorisation, any changes you make will be visible to them immediately.') + + if hasattr(self.object, 'authorised'): + messages.warning(self.request, 'This event has already been authorised by client, any changes to price will require reauthorisation.') + return context def get_success_url(self): From 75a3059c88c0e599ea187035d53bff9093ebc964 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 18:02:29 +0100 Subject: [PATCH 39/44] Add failing duplicate test --- RIGS/test_functional.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index d8ea81f6..4cd23526 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -495,7 +495,10 @@ class EventTest(LiveServerTestCase): def testEventDuplicate(self): testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), - description="start future no end") + description="start future no end", + auth_request_by=self.profile, + auth_request_at=self.create_datetime(2015, 06, 04, 10, 00), + auth_request_to="some@email.address") item1 = models.EventItem( event=testEvent, @@ -549,6 +552,13 @@ class EventTest(LiveServerTestCase): # Attempt to save save.click() + newEvent = models.Event.objects.latest('pk') + + self.assertEqual(newEvent.auth_request_to, None) + self.assertEqual(newEvent.auth_request_by, None) + self.assertEqual(newEvent.auth_request_at, None) + + self.assertFalse(hasattr(newEvent, 'authorised')) self.assertNotIn("N%05d"%testEvent.pk, self.browser.find_element_by_xpath('//h1').text) self.assertNotIn("Event data duplicated but not yet saved", self.browser.find_element_by_id('content').text) # Check info message not visible From 4d316c7a4a9857409fde0320d3142ac39ecfd0e5 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 18 May 2017 18:02:44 +0100 Subject: [PATCH 40/44] Stop authorisation information being duplicated with an event --- RIGS/rigboard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index e9597ab5..e4c124c2 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -138,6 +138,11 @@ class EventDuplicate(EventUpdate): new = copy.copy(old) # Make a copy of the object in memory new.based_on = old # Make the new event based on the old event + # Remove all the authorisation information from the new event + new.auth_request_to = None + new.auth_request_by = None + new.auth_request_at = None + if self.request.method in ( 'POST', 'PUT'): # This only happens on save (otherwise items won't display in editor) new.pk = None # This means a new event will be created on save, and all items will be re-created From c6b7bbc219da9fab3600e20bbdce921c5d16e4f6 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 23 May 2017 18:19:02 +0100 Subject: [PATCH 41/44] Change to just using online auth for internal clients. This effectively reverts 067e03b. --- RIGS/forms.py | 12 +--- RIGS/migrations/0025_eventauthorisation.py | 1 - .../migrations/0028_migrate_purchase_order.py | 48 ------------- .../0029_eventauthorisation_sent_by.py | 2 +- RIGS/models.py | 8 ++- RIGS/rigboard.py | 12 +--- RIGS/templates/RIGS/event_detail.html | 24 ++++--- RIGS/templates/RIGS/event_detail_buttons.html | 49 +++++++------ RIGS/templates/RIGS/event_form.html | 9 +++ RIGS/templates/RIGS/event_invoice.html | 20 +++--- RIGS/templates/RIGS/event_print_page.xml | 20 +++--- .../RIGS/eventauthorisation_form.html | 71 ++++++++----------- .../RIGS/eventauthorisation_success.html | 9 +-- RIGS/templates/RIGS/invoice_detail.html | 4 +- RIGS/templates/RIGS/invoice_list.html | 4 +- RIGS/test_functional.py | 13 +++- 16 files changed, 123 insertions(+), 183 deletions(-) delete mode 100644 RIGS/migrations/0028_migrate_purchase_order.py diff --git a/RIGS/forms.py b/RIGS/forms.py index c49f2433..95576ca3 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -144,7 +144,7 @@ class EventForm(forms.ModelForm): fields = ['is_rig', 'name', 'venue', 'start_time', 'end_date', 'start_date', 'end_time', 'meet_at', 'access_at', 'description', 'notes', 'mic', 'person', 'organisation', 'dry_hire', 'checked_in_by', 'status', - 'collector'] + 'purchase_order', 'collector'] class BaseClientEventAuthorisationForm(forms.ModelForm): @@ -171,15 +171,5 @@ class InternalClientEventAuthorisationForm(BaseClientEventAuthorisationForm): fields = ('tos', 'name', 'amount', 'uni_id', 'account_code') -class ExternalClientEventAuthorisationForm(BaseClientEventAuthorisationForm): - def __init__(self, **kwargs): - super(ExternalClientEventAuthorisationForm, self).__init__(**kwargs) - self.fields['po'].required = True - - class Meta: - model = models.EventAuthorisation - fields = ('tos', 'name', 'amount', 'po') - - class EventAuthorisationRequestForm(forms.Form): email = forms.EmailField(required=True, label='Authoriser Email') diff --git a/RIGS/migrations/0025_eventauthorisation.py b/RIGS/migrations/0025_eventauthorisation.py index 88f38ff4..2065c11d 100644 --- a/RIGS/migrations/0025_eventauthorisation.py +++ b/RIGS/migrations/0025_eventauthorisation.py @@ -19,7 +19,6 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=255)), ('uni_id', models.CharField(max_length=10, null=True, verbose_name=b'University ID', blank=True)), ('account_code', models.CharField(max_length=50, null=True, blank=True)), - ('po', models.CharField(max_length=255, null=True, verbose_name=b'purchase order', blank=True)), ('amount', models.DecimalField(verbose_name=b'authorisation amount', max_digits=10, decimal_places=2)), ('created_at', models.DateTimeField(auto_now_add=True)), ('event', models.ForeignKey(related_name='authroisations', to='RIGS.Event')), diff --git a/RIGS/migrations/0028_migrate_purchase_order.py b/RIGS/migrations/0028_migrate_purchase_order.py deleted file mode 100644 index 8a268208..00000000 --- a/RIGS/migrations/0028_migrate_purchase_order.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from django.db.models import F, Sum, DecimalField - - -def POs_forward(apps, schema_editor): - VatRate = apps.get_model('RIGS', 'VatRate') - Event = apps.get_model('RIGS', 'Event') - EventItem = apps.get_model('RIGS', 'EventItem') - EventAuthorisation = apps.get_model('RIGS', 'EventAuthorisation') - db_alias = schema_editor.connection.alias - for event in Event.objects.using(db_alias).filter(purchase_order__isnull=False): - sum_total = EventItem.objects.filter(event=event).aggregate( - sum_total=Sum(models.F('cost') * F('quantity'), - output_field=DecimalField( - max_digits=10, - decimal_places=2) - ) - )['sum_total'] - - vat = VatRate.objects.using(db_alias).filter(start_at__lte=event.start_date).latest() - total = sum_total + sum_total * vat.rate - - EventAuthorisation.objects.using(db_alias).create(event=event, name='LEGACY', - email='treasurer@nottinghamtec.co.uk', - amount=total, - po=event.purchase_order) - - -def POs_reverse(apps, schema_editor): - EventAuthorisation = apps.get_model('RIGS', 'EventAuthorisation') - db_alias = schema_editor.connection.alias - for auth in EventAuthorisation.objects.using(db_alias).filter(po__isnull=False): - auth.event.purchase_order = auth.po - auth.delete() - - -class Migration(migrations.Migration): - dependencies = [ - ('RIGS', '0027_eventauthorisation_event_singular'), - ] - - operations = [ - migrations.RunPython(POs_forward, POs_reverse), - migrations.RemoveField(model_name='event', name='purchase_order') - ] diff --git a/RIGS/migrations/0029_eventauthorisation_sent_by.py b/RIGS/migrations/0029_eventauthorisation_sent_by.py index 80c86299..592bc968 100644 --- a/RIGS/migrations/0029_eventauthorisation_sent_by.py +++ b/RIGS/migrations/0029_eventauthorisation_sent_by.py @@ -8,7 +8,7 @@ from django.conf import settings class Migration(migrations.Migration): dependencies = [ - ('RIGS', '0028_migrate_purchase_order'), + ('RIGS', '0027_eventauthorisation_event_singular'), ] operations = [ diff --git a/RIGS/models.py b/RIGS/models.py index ebb31ca6..c1a33eb8 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -329,6 +329,7 @@ class Event(models.Model, RevisionMixin): # Monies payment_method = models.CharField(max_length=255, blank=True, null=True) payment_received = models.CharField(max_length=255, blank=True, null=True) + purchase_order = models.CharField(max_length=255, blank=True, null=True, verbose_name='PO') collector = models.CharField(max_length=255, blank=True, null=True, verbose_name='collected by') # Authorisation request details @@ -388,7 +389,7 @@ class Event(models.Model, RevisionMixin): @property def authorised(self): - return self.authorisation.amount == self.total + return not self.internal and self.purchase_order or self.authorisation.amount == self.total @property def has_start_time(self): @@ -450,6 +451,10 @@ class Event(models.Model, RevisionMixin): else: return endDate + @property + def internal(self): + return self.organisation and self.organisation.union_account + objects = EventManager() def get_absolute_url(self): @@ -513,7 +518,6 @@ class EventAuthorisation(models.Model, RevisionMixin): name = models.CharField(max_length=255) uni_id = models.CharField(max_length=10, blank=True, null=True, verbose_name="University ID") account_code = models.CharField(max_length=50, blank=True, null=True) - po = models.CharField(max_length=255, blank=True, null=True, verbose_name="purchase order") amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") sent_by = models.ForeignKey('RIGS.Profile') diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index e4c124c2..4546b502 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -137,6 +137,7 @@ class EventDuplicate(EventUpdate): old = super(EventDuplicate, self).get_object(queryset) # Get the object (the event you're duplicating) new = copy.copy(old) # Make a copy of the object in memory new.based_on = old # Make the new event based on the old event + new.purchase_order = None # Remove all the authorisation information from the new event new.auth_request_to = None @@ -256,20 +257,12 @@ class EventAuthorise(generic.UpdateView): return getattr(self.event, 'authorisation', None) def get_form_class(self): - if self.event.organisation is not None and self.event.organisation.union_account: - return forms.InternalClientEventAuthorisationForm - else: - return forms.ExternalClientEventAuthorisationForm + return forms.InternalClientEventAuthorisationForm def get_context_data(self, **kwargs): context = super(EventAuthorise, self).get_context_data(**kwargs) context['event'] = self.event - if self.get_form_class() is forms.InternalClientEventAuthorisationForm: - context['internal'] = True - else: - context['internal'] = False - context['tos_url'] = settings.TERMS_OF_HIRE_URL return context @@ -304,6 +297,7 @@ class EventAuthorise(generic.UpdateView): "This URL is invalid. Please ask your TEC contact for a new URL") return super(EventAuthorise, self).dispatch(request, *args, **kwargs) + class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMixin): model = models.Event form_class = forms.EventAuthorisationRequestForm diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index 051aec4f..5f2885b5 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -71,7 +71,7 @@ {% endif %} - {% if event.is_rig %} + {% if event.is_rig and event.internal %}
Client Authorisation
@@ -183,17 +183,23 @@ {% if event.is_rig %}
 
-
Authorisation Request
-
{{ object.auth_request_to|yesno:"Yes,No" }}
+ {% if object.internal %} +
Authorisation Request
+
{{ object.auth_request_to|yesno:"Yes,No" }}
-
By
-
{{ object.auth_request_by }}
+
By
+
{{ object.auth_request_by }}
-
At
-
{{ object.auth_request_at|date:"D d M Y H:i"|default:"" }}
+
At
+
{{ object.auth_request_at|date:"D d M Y H:i"|default:"" }}
-
To
-
{{ object.auth_request_to }}
+
To
+
{{ object.auth_request_to }}
+ + {% else %} +
PO
+
{{ object.purchase_order }}
+ {% endif %} {% endif %}
diff --git a/RIGS/templates/RIGS/event_detail_buttons.html b/RIGS/templates/RIGS/event_detail_buttons.html index 9ad4b189..878684cc 100644 --- a/RIGS/templates/RIGS/event_detail_buttons.html +++ b/RIGS/templates/RIGS/event_detail_buttons.html @@ -11,29 +11,32 @@ class="glyphicon glyphicon-duplicate">
{% if event.is_rig %} - - - - + {% if event.internal %} + + + + + {% endif %} + {% if perms.RIGS.add_invoice %}
+ +
+ + +
+ {% render_field form.purchase_order class+="form-control" %} +
+
diff --git a/RIGS/templates/RIGS/event_invoice.html b/RIGS/templates/RIGS/event_invoice.html index 71136b35..ec9755c0 100644 --- a/RIGS/templates/RIGS/event_invoice.html +++ b/RIGS/templates/RIGS/event_invoice.html @@ -61,21 +61,14 @@ {% endif %} - {% if object.organisation %} - {{ object.organisation.name }} -
- {{ object.organisation.union_account|yesno:'Internal,External' }} - {% else %} - {{ object.person.name }} -
- External - {% endif %} - + {{ object.organisation.name }} +
+ {{ object.internal|yesno:'Internal,External' }} {{ object.sum_total|floatformat:2 }}
- {{ object.authorisation.po }} + {% if not object.internal %}{{ object.purchase_order }}{% endif %} {% if object.mic %} @@ -86,7 +79,10 @@ {% endif %} -
+ diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 27317c57..73510b68 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -249,13 +249,12 @@ - {% if object.authorised %} - - Event authorised online by {{ object.authorisation.name }} ({{ object.authorisation.email }}) at - {{ object.authorisation.last_edited_at }}. - + {% if object.internal and object.authorised %} + + Event authorised online by {{ object.authorisation.name }} ({{ object.authorisation.email }}) at + {{ object.authorisation.last_edited_at }}. + - {% if object.organisation.union_account %} University ID @@ -268,19 +267,18 @@ £ {{ object.authorisation.amount|floatformat:2 }} - {% else %} + {% elif not object.internal and object.purchase_order %} + Purchase Order - Authorised Amount - {{ object.authorisation.po }} - £ {{ object.authorisation.amount|floatformat:2 }} + + {{ object.purchase_order }} {% endif %} - {% endif %} diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html index 9182df41..06df386d 100644 --- a/RIGS/templates/RIGS/eventauthorisation_form.html +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -9,11 +9,11 @@ $('[data-toggle="tooltip"]').tooltip(); }); - $('form').on('submit', function() { - $('#loading-modal').modal({ - backdrop: 'static', - show: true - }); + $('form').on('submit', function () { + $('#loading-modal').modal({ + backdrop: 'static', + show: true + }); }); {% endblock %} @@ -54,17 +54,15 @@ {% csrf_token %} {% include 'form_errors.html' %}
- {% if internal %} -
-

- I agree that I am authorised to approve this event. I agree that I am the - President/Treasurer or account holder of the hirer, or that I - have the written permission of the - President/Treasurer or account holder of the hirer stating that - I can authorise this event. -

-
- {% endif %} +
+

+ I agree that I am authorised to approve this event. I agree that I am the + President/Treasurer or account holder of the hirer, or that I + have the written permission of the + President/Treasurer or account holder of the hirer stating that + I can authorise this event. +

+
- {% if internal %} -
- -
- {% render_field form.uni_id class+="form-control" %} -
+
+ +
+ {% render_field form.uni_id class+="form-control" %}
- {% endif %} +
- {% if internal %} -
- -
- {% render_field form.account_code class+="form-control" %} -
+
+ +
+ {% render_field form.account_code class+="form-control" %}
- {% else %} -
- -
- {% render_field form.po class+="form-control" %} -
-
- {% endif %} +
diff --git a/RIGS/templates/RIGS/eventauthorisation_success.html b/RIGS/templates/RIGS/eventauthorisation_success.html index 2b899076..cb24738a 100644 --- a/RIGS/templates/RIGS/eventauthorisation_success.html +++ b/RIGS/templates/RIGS/eventauthorisation_success.html @@ -49,13 +49,8 @@
- {% if internal %} -
Account code
-
{{ object.account_code }}
- {% else %} -
PO
-
{{ object.po }}
- {% endif %} +
Account code
+
{{ object.account_code }}
Authorised amount
£ {{ object.amount|floatformat:2 }}
diff --git a/RIGS/templates/RIGS/invoice_detail.html b/RIGS/templates/RIGS/invoice_detail.html index a55927f8..b305a04f 100644 --- a/RIGS/templates/RIGS/invoice_detail.html +++ b/RIGS/templates/RIGS/invoice_detail.html @@ -89,7 +89,7 @@ {% endif %} - {% if object.event.organisation.union_account %} + {% if object.event.internal %} {# internal #}
Uni ID
{{ object.event.authorisation.uni_id }}
@@ -98,7 +98,7 @@
{{ object.event.authorisation.account_code }}
{% else %}
PO
-
{{ object.event.authorisation.po }}
+
{{ object.event.purchase_order }}
{% endif %}
Authorised at
diff --git a/RIGS/templates/RIGS/invoice_list.html b/RIGS/templates/RIGS/invoice_list.html index 62012238..63e61e11 100644 --- a/RIGS/templates/RIGS/invoice_list.html +++ b/RIGS/templates/RIGS/invoice_list.html @@ -50,7 +50,7 @@ {% if object.event.organisation %} {{ object.event.organisation.name }}
- {{ object.event.organisation.union_account|yesno:'Internal,External' }} + {{ object.event.internal|yesno:'Internal,External' }} {% else %} {{ object.event.person.name }}
@@ -62,7 +62,7 @@ {{ object.balance|floatformat:2 }}
- {{ object.event.authorisation.po }} + {{ object.event.purchase_order }} diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 4cd23526..27675442 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -496,6 +496,7 @@ class EventTest(LiveServerTestCase): testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), description="start future no end", + purchase_order='TESTPO', auth_request_by=self.profile, auth_request_at=self.create_datetime(2015, 06, 04, 10, 00), auth_request_to="some@email.address") @@ -570,6 +571,10 @@ class EventTest(LiveServerTestCase): self.assertIn("Test Item 3", table.text) infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') + self.assertIn("N0000%d" % testEvent.pk, + infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) + # Check the PO hasn't carried through + self.assertNotIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) self.assertIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) @@ -578,6 +583,10 @@ class EventTest(LiveServerTestCase): #Check that based-on hasn't crept into the old event infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') + self.assertNotIn("N0000%d" % testEvent.pk, + infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) + # Check the PO remains on the old event + self.assertIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text) self.assertNotIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text) @@ -1108,7 +1117,7 @@ class ClientEventAuthorisationTest(TestCase): self.assertContains(response, "Terms of Hire") response = self.client.post(self.url) - self.assertContains(response, "This field is required.", 4) + self.assertContains(response, "This field is required.", 5) data = self.auth_data data['amount'] = self.event.total + 1 @@ -1142,7 +1151,7 @@ class ClientEventAuthorisationTest(TestCase): def test_duplicate_warning(self): auth = models.EventAuthorisation.objects.create(event=self.event, name='Test ABC', email='dupe@functional.test', - po='ABC12345', amount=self.event.total, sent_by=self.profile) + amount=self.event.total, sent_by=self.profile) response = self.client.get(self.url) self.assertContains(response, 'This event has already been authorised.') From d85ebb63a1ec90da2215cd7a90d608f4abd8c6c0 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 23 May 2017 18:41:06 +0100 Subject: [PATCH 42/44] Minor PDF styling fixes --- RIGS/templates/RIGS/event_print.xml | 5 ++--- RIGS/templates/RIGS/event_print_page.xml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/RIGS/templates/RIGS/event_print.xml b/RIGS/templates/RIGS/event_print.xml index ad441a21..0b5a44b1 100644 --- a/RIGS/templates/RIGS/event_print.xml +++ b/RIGS/templates/RIGS/event_print.xml @@ -103,7 +103,7 @@ [Page of ] - [Paperwork generated{% if current_user %}by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + [Paperwork generated{% if current_user %} by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] @@ -116,11 +116,10 @@ - {% if not invoice %}[{{ copy }} Copy]{% endif %} [Page of ] - [Paperwork generated{% if current_user %}by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + [Paperwork generated {% if current_user %}by{{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 73510b68..4e2d194f 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -268,7 +268,7 @@ {% elif not object.internal and object.purchase_order %} - + Purchase Order From 7fdafd854e77b9611fbb9e211ca329f154ca4527 Mon Sep 17 00:00:00 2001 From: Tom Price Date: Tue, 23 May 2017 19:18:19 +0100 Subject: [PATCH 43/44] Add payments to invoice PDF --- RIGS/templates/RIGS/event_print_page.xml | 97 +++++++++++++++++++----- 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 4e2d194f..9b8362eb 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -34,6 +34,13 @@ {{ invoice.invoice_date|date:"d/m/Y" }} + + {% if not object.internal %} + + PO + {{ object.purchase_order }} + + {% endif %} {% elif quote %} @@ -210,19 +217,78 @@ - - - Total - - - - - £ {{ object.total|floatformat:2 }} - - + {% if invoice %} + Total + £ {{ object.total|floatformat:2 }} + {% else %} + + + Total + + + + + £ {{ object.total|floatformat:2 }} + + + {% endif %} + +{% if invoice %} + + +

Payments

+ + + + + Method + + + + + Date + + + + + Amount + + + + {% for payment in object.invoice.payment_set.all %} + + {{ payment.get_method_display }} + {{ payment.date }} + £ {{ payment.amount|floatformat:2 }} + + {% endfor %} + + + + + Payment Total + £ {{ object.invoice.payment_total|floatformat:2 }} + + + + + + Balance (ex. VAT) + + + + + £ {{ object.invoice.balance|floatformat:2 }} + + + + +
+{% endif %} + {% if quote %} @@ -267,17 +333,6 @@ £ {{ object.authorisation.amount|floatformat:2 }} - {% elif not object.internal and object.purchase_order %} - - - - Purchase Order - - - - {{ object.purchase_order }} - - {% endif %} From 7cb9e97ecb776d4610dd3ef43e113b901632cbec Mon Sep 17 00:00:00 2001 From: Tom Price Date: Wed, 24 May 2017 16:27:31 +0100 Subject: [PATCH 44/44] Fix text on quote paperwork for external clients Actually finish fixing PDF footer formatting. --- RIGS/templates/RIGS/event_print.xml | 2 +- RIGS/templates/RIGS/event_print_page.xml | 32 +++++++++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/RIGS/templates/RIGS/event_print.xml b/RIGS/templates/RIGS/event_print.xml index 0b5a44b1..f91409ac 100644 --- a/RIGS/templates/RIGS/event_print.xml +++ b/RIGS/templates/RIGS/event_print.xml @@ -119,7 +119,7 @@ [Page of ] - [Paperwork generated {% if current_user %}by{{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] + [Paperwork generated{% if current_user %} by {{current_user.name}} |{% endif %} {% now "d/m/Y H:i" %} | {{object.current_version_id}}] diff --git a/RIGS/templates/RIGS/event_print_page.xml b/RIGS/templates/RIGS/event_print_page.xml index 9b8362eb..4b1ddfdf 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -207,16 +207,22 @@ {% if quote %} - - The full hire fee is payable at least 10 days before the event. - + + This quote is valid for 30 days unless otherwise arranged. + {% endif %} VAT @ {{ object.vat_rate.as_percent|floatformat:2 }}% £ {{ object.vat|floatformat:2 }} - + + {% if quote %} + + The full hire fee is payable at least 10 days before the event. + + {% endif %} + {% if invoice %} Total £ {{ object.total|floatformat:2 }} @@ -290,14 +296,22 @@ {% endif %} - + > {% if quote %} + - Bookings will - not - be confirmed until the event is authorised online. - + {% if object.internal %} + Bookings will + not + be confirmed until the event is authorised online. + + {% else %} + Bookings will + not + be confirmed until we have received written confirmation and a Purchase Order. + + {% endif %}