diff --git a/PyRIGS/decorators.py b/PyRIGS/decorators.py index 68549449..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 @@ -79,3 +78,17 @@ def api_key_required(function): return error_resp return function(request, *args, **kwargs) return wrap + + +def nottinghamtec_address_required(function): + """ + Checks that the current user has an email address ending @nottinghamtec.co.uk + """ + 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(request, 'RIGS/eventauthorisation_request_error.html') + return error_resp + + return function(request, *args, **kwargs) + return wrap diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index 5c010a23..21f36848 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -233,3 +233,4 @@ TEMPLATES = [ 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/__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/forms.py b/RIGS/forms.py index 85fd0394..95576ca3 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -144,4 +144,32 @@ 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'] + 'purchase_order', 'collector'] + + +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 (inc VAT).') + 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 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 new file mode 100644 index 00000000..2065c11d --- /dev/null +++ b/RIGS/migrations/0025_eventauthorisation.py @@ -0,0 +1,27 @@ +# -*- 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)), + ('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/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/migrations/0029_eventauthorisation_sent_by.py b/RIGS/migrations/0029_eventauthorisation_sent_by.py new file mode 100644 index 00000000..592bc968 --- /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', '0027_eventauthorisation_event_singular'), + ] + + 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/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/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 = [ + ] diff --git a/RIGS/models.py b/RIGS/models.py index b245f543..c1a33eb8 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -332,6 +332,11 @@ class Event(models.Model, RevisionMixin): 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 + 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 @@ -364,7 +369,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 @@ -372,7 +377,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): @@ -382,6 +387,10 @@ class Event(models.Model, RevisionMixin): def confirmed(self): return (self.status == self.BOOKED or self.status == self.CONFIRMED) + @property + def authorised(self): + return not self.internal and self.purchase_order or self.authorisation.amount == self.total + @property def has_start_time(self): return self.start_time is not None @@ -442,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): @@ -498,6 +511,17 @@ class EventCrew(models.Model): notes = models.TextField(blank=True, null=True) +@reversion.register +class EventAuthorisation(models.Model, RevisionMixin): + 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) + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") + sent_by = models.ForeignKey('RIGS.Profile') + + @python_2_unicode_compatible class Invoice(models.Model): event = models.OneToOneField('Event') diff --git a/RIGS/rigboard.py b/RIGS/rigboard.py index 0d2c6ec8..4546b502 100644 --- a/RIGS/rigboard.py +++ b/RIGS/rigboard.py @@ -1,8 +1,9 @@ -import os import cStringIO as StringIO from io import BytesIO import urllib2 +from django.contrib.staticfiles.storage import staticfiles_storage +from django.core.mail import EmailMessage, EmailMultiAlternatives from django.views import generic from django.core.urlresolvers import reverse_lazy from django.shortcuts import get_object_or_404 @@ -10,14 +11,19 @@ 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 django.utils.decorators import method_decorator from z3c.rml import rml2pdf from PyPDF2 import PdfFileMerger, PdfFileReader import simplejson +import premailer from RIGS import models, forms +from PyRIGS import decorators import datetime import re import copy @@ -36,15 +42,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 +61,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 +92,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() @@ -112,20 +118,35 @@ 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): 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 + # 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 else: messages.info(self.request, 'Event data duplicated but not yet saved. Click save to complete operation.') @@ -141,34 +162,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 thisCopy in copies: + context = { + '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 = { # 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': thisCopy, - 'current_user': request.user, - } + rml = template.render(context) - # context['copy'] = copy # this is the way to do it once we upgrade to Django 1.8.3 - - 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())) @@ -184,6 +197,7 @@ class EventPrint(generic.View): response.write(merged.getvalue()) return response + class EventArchive(generic.ArchiveIndexView): model = models.Event date_field = "start_date" @@ -220,3 +234,148 @@ 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): + self.object = form.save() + + 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 to %s.' % (self.object.email)) + 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 getattr(self.event, 'authorisation', None) + + def get_form_class(self): + return forms.InternalClientEventAuthorisationForm + + def get_context_data(self, **kwargs): + context = super(EventAuthorise, self).get_context_data(**kwargs) + context['event'] = self.event + + 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. " + "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. " + + "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 + form.instance.sent_by = self.request.sent_by + 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'] + 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) + + +class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMixin): + model = models.Event + form_class = forms.EventAuthorisationRequestForm + template_name = 'RIGS/eventauthorisation_request.html' + + @method_decorator(decorators.nottinghamtec_address_required) + def dispatch(self, *args, **kwargs): + return super(EventAuthorisationRequest, self).dispatch(*args, **kwargs) + + @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, "location.reload()") + 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'] + 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, + 'request': self.request, + 'hmac': signing.dumps({ + 'pk': self.object.pk, + 'email': email, + 'sent_by': self.request.user.pk, + }), + } + if email == event.person.email: + context['to_name'] = event.person.name + + msg = EmailMultiAlternatives( + "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=[self.request.user.email], + ) + css = staticfiles_storage.path('css/email.css') + html = premailer.Premailer(get_template("RIGS/eventauthorisation_client_request.html").render(context), + external_styles=css).transform() + msg.attach_alternative(html, 'text/html') + + msg.send() + + return super(EventAuthorisationRequest, self).form_valid(form) + + +class EventAuthoriseRequestEmailPreview(generic.DetailView): + template_name = "RIGS/eventauthorisation_client_request.html" + model = models.Event + + def render_to_response(self, context, **response_kwargs): + from django.contrib.staticfiles.storage import staticfiles_storage + css = staticfiles_storage.path('css/email.css') + response = super(EventAuthoriseRequestEmailPreview, self).render_to_response(context, **response_kwargs) + assert isinstance(response, HttpResponse) + response.content = premailer.Premailer(response.rendered_content, external_styles=css).transform() + return response + + def get_context_data(self, **kwargs): + context = super(EventAuthoriseRequestEmailPreview, self).get_context_data(**kwargs) + context['hmac'] = signing.dumps({ + 'pk': self.object.pk, + 'email': self.request.GET.get('email', 'hello@world.test'), + 'sent_by': self.request.user.pk, + }) + context['to_name'] = self.request.GET.get('to_name', None) + return context diff --git a/RIGS/signals.py b/RIGS/signals.py new file mode 100644 index 00000000..faf0f873 --- /dev/null +++ b/RIGS/signals.py @@ -0,0 +1,98 @@ +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.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 + + +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, + } + + 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 = 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 - CONFIRMATION.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( + 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(fail_silently=True) + mic_email.send(fail_silently=True) + + +def on_revision_commit(instances, **kwargs): + for instance in instances: + if isinstance(instance, models.EventAuthorisation): + send_eventauthorisation_success_email(instance) + + +reversion.revisions.post_revision_commit.connect(on_revision_commit) diff --git a/RIGS/static/css/email.css b/RIGS/static/css/email.css new file mode 100644 index 00000000..915b52f3 --- /dev/null +++ b/RIGS/static/css/email.css @@ -0,0 +1 @@ +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/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/static/scss/email.scss b/RIGS/static/scss/email.scss new file mode 100644 index 00000000..73ffe478 --- /dev/null +++ b/RIGS/static/scss/email.scss @@ -0,0 +1,64 @@ +$button_color: #357ebf; + +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; + + .logos{ + width: 100%; + max-width: 640px; + } + + img { + height: 110px; + } +} + +.content-container{ + width: 100%; + + .content { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + + width: 100%; + max-width: 600px; + padding: 10px; + text-align: left; + + .button-container{ + width: 100%; + + .button { + padding: 6px 12px; + background-color: $button_color; + border-radius: 4px; + + a { + color: #fff; + text-decoration: none; + } + + } + + } + + } +} + + 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/event_detail.html b/RIGS/templates/RIGS/event_detail.html index d4b089a9..5f2885b5 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -11,36 +11,9 @@
-
- - {% if event.is_rig %} - - {% endif %} - - {% if event.is_rig %} - {% if perms.RIGS.add_invoice %} - - - {% endif %} - {% endif %} -
+ {% include 'RIGS/event_detail_buttons.html' %}
- + {% endif %} {% if object.is_rig %} {# only need contact details for a rig #} @@ -97,6 +70,39 @@ {% endif %} + + {% if event.is_rig and event.internal %} +
+
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 %}
@@ -175,8 +181,25 @@ {% endif %} {% if event.is_rig %} -
PO
-
{{ object.purchase_order }}
+
 
+ + {% if object.internal %} +
Authorisation Request
+
{{ object.auth_request_to|yesno:"Yes,No" }}
+ +
By
+
{{ object.auth_request_by }}
+ +
At
+
{{ object.auth_request_at|date:"D d M Y H:i"|default:"" }}
+ +
To
+
{{ object.auth_request_to }}
+ + {% else %} +
PO
+
{{ object.purchase_order }}
+ {% endif %} {% endif %}
@@ -184,34 +207,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 +225,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..878684cc --- /dev/null +++ b/RIGS/templates/RIGS/event_detail_buttons.html @@ -0,0 +1,54 @@ +
+ + {% if event.is_rig %} + + {% endif %} + + {% if event.is_rig %} + {% if event.internal %} + + + + + {% endif %} + + {% if perms.RIGS.add_invoice %} + + + {% endif %} + {% endif %} +
diff --git a/RIGS/templates/RIGS/event_form.html b/RIGS/templates/RIGS/event_form.html index 6832d571..d7ba9591 100644 --- a/RIGS/templates/RIGS/event_form.html +++ b/RIGS/templates/RIGS/event_form.html @@ -398,6 +398,7 @@ {% render_field form.collector class+="form-control" %} +
diff --git a/RIGS/templates/RIGS/event_invoice.html b/RIGS/templates/RIGS/event_invoice.html index fcbe5e87..ec9755c0 100644 --- a/RIGS/templates/RIGS/event_invoice.html +++ b/RIGS/templates/RIGS/event_invoice.html @@ -54,20 +54,22 @@ N{{ object.pk|stringformat:"05d" }}
{{ object.get_status_display }} {{ object.start_date }} - {{ object.name }} - {% if object.organisation %} - {{ object.organisation.name }} -
- {{ object.organisation.union_account|yesno:'Internal,External' }} - {% else %} - {{ object.person.name }} -
- External + {{ object.name }} + {% if object.is_rig and perms.RIGS.view_event and object.authorised %} + {% endif %} - - {{ object.sum_total|floatformat:2 }} + + {{ object.organisation.name }} +
+ {{ object.internal|yesno:'Internal,External' }} + + + {{ object.sum_total|floatformat:2 }} +
+ {% if not object.internal %}{{ object.purchase_order }}{% endif %} + {% if object.mic %} {{ object.mic.initials }}
@@ -77,7 +79,10 @@ {% endif %} - + @@ -92,4 +97,4 @@
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/RIGS/templates/RIGS/event_print.xml b/RIGS/templates/RIGS/event_print.xml index 6d693117..f91409ac 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}}] + @@ -115,10 +116,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}}] + @@ -128,4 +130,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..4b1ddfdf 100644 --- a/RIGS/templates/RIGS/event_print_page.xml +++ b/RIGS/templates/RIGS/event_print_page.xml @@ -1,59 +1,70 @@ -{% 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 not object.internal %} + + PO + {{ object.purchase_order }} + + {% endif %} + + + {% elif quote %} + + QUOTE + + + + Quote Date + + {% now "d/m/Y" %} + + + + + {% elif receipt %} + + CONFIRMATION + + {% endif %}
- -{% endif %} - @@ -189,16 +200,16 @@ - {% if not invoice %}VAT Registration Number: 170734807{% endif %} + {% if quote %}VAT Registration Number: 170734807{% endif %} Total (ex. VAT) £ {{ object.sum_total|floatformat:2 }} - {% if not invoice %} - - The full hire fee is payable at least 10 days before the event. - + {% if quote %} + + This quote is valid for 30 days unless otherwise arranged. + {% endif %} VAT @ {{ object.vat_rate.as_percent|floatformat:2 }}% @@ -206,113 +217,140 @@ - - - {% if invoice %} - VAT Registration Number: 170734807 + {% if quote %} + + The full hire fee is payable at least 10 days before the event. + + {% endif %} + + {% 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 %} + + + + {% if object.internal %} + Bookings will + not + be confirmed until the event is authorised online. + {% else %} - This contract is not an invoice. + Bookings will + not + be confirmed until we have received written confirmation and a Purchase Order. + {% endif %} - - - + + + + 24 Hour Emergency Contacts: 07825 065681 and 07825 065678 + + {% else %} + - - Total - + VAT Registration Number: 170734807 + + {% endif %} + + + + - - £ {{ object.total|floatformat:2 }} - + {% if object.internal and object.authorised %} + + Event authorised online by {{ object.authorisation.name }} ({{ object.authorisation.email }}) at + {{ object.authorisation.last_edited_at }}. + + + + + University ID + Account Code + Authorised Amount + + + {{ object.authorisation.uni_id }} + {{ object.authorisation.account_code }} + £ {{ object.authorisation.amount|floatformat:2 }} + + + {% endif %} -{% if not invoice %} - - - - - Bookings will - not - be confirmed until payment is received and the contract is signed. - - - - - 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/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/eventauthorisation_client_request.html b/RIGS/templates/RIGS/eventauthorisation_client_request.html new file mode 100644 index 00000000..a6bb7bb9 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_request.html @@ -0,0 +1,41 @@ +{% extends 'base_client_email.html' %} + +{% block content %} + +

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

+ +

{{ 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 %} +

+ + + + + + +
+ + + + +
+ + Complete Authorisation Form + +
+
+ + +

Your event will not be booked until you complete this form.

+ +

TEC PA & Lighting
+ +{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_client_request.txt b/RIGS/templates/RIGS/eventauthorisation_client_request.txt new file mode 100644 index 00000000..7b1297b1 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_request.txt @@ -0,0 +1,16 @@ +Hi {{ to_name|default:"there" }}, + +{{ 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 %} + +{{ request.scheme }}://{{ request.get_host }}{% url 'event_authorise' object.pk hmac %} + +Please note you event will not be booked until you complete this form. + +TEC PA & Lighting diff --git a/RIGS/templates/RIGS/eventauthorisation_client_success.html b/RIGS/templates/RIGS/eventauthorisation_client_success.html new file mode 100644 index 00000000..dc6d0485 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.html @@ -0,0 +1,21 @@ +{% 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 new file mode 100644 index 00000000..ff934c4d --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_client_success.txt @@ -0,0 +1,11 @@ +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 diff --git a/RIGS/templates/RIGS/eventauthorisation_form.html b/RIGS/templates/RIGS/eventauthorisation_form.html new file mode 100644 index 00000000..06df386d --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_form.html @@ -0,0 +1,133 @@ +{% 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 }} +{% 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' %} +
+
+

+ 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. +

+
+ +
+
+ + +
+ {% render_field form.name class+="form-control" %} +
+
+ +
+ +
+ {% render_field form.uni_id class+="form-control" %} +
+
+
+ +
+
+ +
+ {% render_field form.account_code class+="form-control" %} +
+
+ +
+ +
+ {% render_field form.amount class+="form-control" %} +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_mic_success.txt b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt new file mode 100644 index 00000000..7548cad4 --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_mic_success.txt @@ -0,0 +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}}. + +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..6067b3fe --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_request.html @@ -0,0 +1,59 @@ +{% extends request.is_ajax|yesno:'base_ajax.html,base.html' %} +{% load widget_tweaks %} + +{% block title %}Request Authorisation{% endblock %} + +{% block content %} +
+
+
+

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 %} + +
+ {% include 'form_errors.html' %} + +
+ + +
+ {% render_field form.email type="email" class+="form-control" %} +
+
+ +
+
+ +
+
+
+
+
+
+ + +{% endblock %} diff --git a/RIGS/templates/RIGS/eventauthorisation_request_error.html b/RIGS/templates/RIGS/eventauthorisation_request_error.html new file mode 100644 index 00000000..b366622d --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_request_error.html @@ -0,0 +1,15 @@ +{% extends request.is_ajax|yesno:'base_ajax.html,base.html' %} +{% load widget_tweaks %} + +{% block title %}NottinghamTEC Email Address Required{% endblock %} + +{% block content %} +
+
+
+

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/templates/RIGS/eventauthorisation_success.html b/RIGS/templates/RIGS/eventauthorisation_success.html new file mode 100644 index 00000000..cb24738a --- /dev/null +++ b/RIGS/templates/RIGS/eventauthorisation_success.html @@ -0,0 +1,64 @@ +{% 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 %} +
+
+ +
+
+
Account code
+
{{ object.account_code }}
+ +
Authorised amount
+
£ {{ object.amount|floatformat:2 }}
+
+
+
+
+
+
+
+{% endblock %} diff --git a/RIGS/templates/RIGS/invoice_detail.html b/RIGS/templates/RIGS/invoice_detail.html index cfcfcfe9..b305a04f 100644 --- a/RIGS/templates/RIGS/invoice_detail.html +++ b/RIGS/templates/RIGS/invoice_detail.html @@ -76,8 +76,43 @@
{{ object.checked_in_by.name }}
{% endif %} -
PO
-
{{ object.event.purchase_order }}
+
 
+ +
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.internal %} + {# internal #} +
Uni ID
+
{{ object.event.authorisation.uni_id }}
+ +
Account code
+
{{ object.event.authorisation.account_code }}
+ {% else %} +
PO
+
{{ object.event.purchase_order }}
+ {% endif %} + +
Authorised at
+
{{ object.event.authorisation.last_edited_at }}
+ +
Authorised amount
+
+ {% if object.event.authorised %} + £ {{ object.event.authorisation.amount|floatformat:"2" }} + {% endif %} +
+ +
Authorsation request sent by
+
{{ object.authorisation.sent_by }}
@@ -139,4 +174,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..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 }}
@@ -59,7 +59,11 @@ {{ object.event.start_date }} {{ object.invoice_date }} - {{ object.balance|floatformat:2 }} + + {{ object.balance|floatformat:2 }} +
+ {{ object.event.purchase_order }} + @@ -76,4 +80,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 7e9595cb..27675442 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -4,9 +4,11 @@ import re import pytz from datetime import date, time, datetime, timedelta + from django.core import mail 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 @@ -16,6 +18,9 @@ from selenium.webdriver.support.ui import WebDriverWait from RIGS import models from reversion import revisions as reversion +from django.core.urlresolvers import reverse +from django.core import mail, signing + from django.conf import settings @@ -63,9 +68,9 @@ def create_browser(test_name, desired_capabilities): @on_platforms(browsers) class UserRegistrationTest(LiveServerTestCase): - def setUp(self): self.browser = create_browser(self.id(), self.desired_capabilities) + self.browser.implicitly_wait(3) # Set implicit wait session wide os.environ['RECAPTCHA_TESTING'] = 'True' @@ -195,18 +200,19 @@ class UserRegistrationTest(LiveServerTestCase): @on_platforms(browsers) 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.browser = create_browser(self.id(), self.desired_capabilities) self.browser.implicitly_wait(10) # Set implicit wait session wide # self.browser.maximize_window() + os.environ['RECAPTCHA_TESTING'] = 'True' def tearDown(self): @@ -416,26 +422,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) @@ -477,6 +485,7 @@ 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("N%05d | Test Event Name"%event.pk, successTitle) except WebDriverException: # This is a dirty workaround for wercker being a bit funny and not running it correctly. @@ -484,91 +493,105 @@ class EventTest(LiveServerTestCase): 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', + 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, - 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, 3) #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() + 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 # 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("N%05d"%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.assertIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/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 infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..') - - self.assertNotIn("N%05d"%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) + self.assertNotIn("N%05d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/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) @@ -578,6 +601,7 @@ class EventTest(LiveServerTestCase): # Gets redirected to login and back self.authenticate('/event/create/') + wait = WebDriverWait(self.browser, 3) #setup WebDriverWait to use later (to wait for animations) wait.until(animation_is_finished()) @@ -604,7 +628,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]') @@ -624,7 +647,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]') @@ -638,7 +660,6 @@ class EventTest(LiveServerTestCase): form.find_element_by_id('id_end_time').send_keys(Keys.DELETE) 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]') @@ -658,22 +679,23 @@ 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]') self.browser.execute_script("document.getElementById('id_start_date').value='3015-04-24'") self.browser.execute_script("document.getElementById('id_end_date').value='3015-04-26'") + self.browser.execute_script("document.getElementById('id_start_time').value=''") self.browser.execute_script("document.getElementById('id_end_time').value=''") # 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("N%05d | Test Event Name"%event.pk, successTitle) def testRigNonRig(self): @@ -681,6 +703,7 @@ class EventTest(LiveServerTestCase): # Gets redirected to login and back self.authenticate('/event/create/') + wait = WebDriverWait(self.browser, 3) #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) @@ -713,7 +736,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(): @@ -743,18 +767,21 @@ 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")]/..') + def testEventEdit(self): person = models.Person(name="Event Edit Person", email="eventdetail@person.tests.rigs", phone="123 123").save() organisation = models.Organisation(name="Event Edit Organisation", email="eventdetail@organisation.tests.rigs", phone="123 456").save() @@ -816,43 +843,66 @@ class EventTest(LiveServerTestCase): @on_platforms(browsers) 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 = create_browser(self.id(), self.desired_capabilities) self.browser.implicitly_wait(3) # Set implicit wait session wide @@ -886,14 +936,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() @@ -903,7 +954,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 @@ -916,27 +969,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() @@ -946,13 +996,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() @@ -963,12 +1012,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() @@ -980,12 +1029,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() @@ -995,17 +1044,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 @@ -1016,3 +1067,167 @@ 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): + 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.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) + 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', + 'sent_by': self.profile.pk}) + 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.", 5) + + 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', + amount=self.event.total, sent_by=self.profile) + 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') + + 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]) + + +class TECEventAuthorisationTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = models.Profile.objects.get_or_create( + first_name='Test', + last_name='TEC User', + username='eventauthtest', + email='teccie@nottinghamtec.co.uk', + is_superuser=True # lazily grant all permissions + )[0] + cls.profile.set_password('eventauthtest123') + 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) + 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_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) + self.assertContains(response, 'This field is required.') + + mail.outbox = [] + + response = self.client.post(self.url, {'email': 'client@functional.test'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn('client@functional.test', email.to) + self.assertIn('/event/%d/' % (self.event.pk), email.body) + + # Check sent by details are populated + self.event.refresh_from_db() + self.assertEqual(self.event.auth_request_by, self.profile) + self.assertEqual(self.event.auth_request_to, 'client@functional.test') + self.assertIsNotNone(self.event.auth_request_at) diff --git a/RIGS/test_models.py b/RIGS/test_models.py index 1809c73b..b5dfd1e3 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -1,5 +1,7 @@ import pytz +import reversion 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 @@ -354,3 +356,37 @@ 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): + 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', + 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, + start_date=date.today()) + # Add some items + models.EventItem.objects.create(event=self.event, name="Authorisation test item", quantity=2, cost=123.45, + order=1) + + 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, + sent_by=self.profile) + self.assertFalse(self.event.authorised) + auth1.amount = self.event.total + auth1.save() + self.assertTrue(self.event.authorised) + + def test_last_edited(self): + 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) diff --git a/RIGS/urls.py b/RIGS/urls.py index 5af3fd9a..4d1c67ec 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ url('^user/login/$', views.login, name='login'), url('^user/login/embed/$', xframe_options_exempt(views.login_embed), name='login_embed'), + url(r'^user/password_reset/$', password_reset, {'password_reset_form': forms.PasswordReset}), # People @@ -72,9 +73,12 @@ urlpatterns = [ # 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()), @@ -84,10 +88,12 @@ urlpatterns = [ 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(), @@ -111,8 +117,6 @@ urlpatterns = [ permission_required_with_403('RIGS.view_event')(versioning.VersionHistory.as_view()), name='event_history', kwargs={'model': models.Event}), - - # Finance url(r'^invoice/$', permission_required_with_403('RIGS.view_invoice')(finance.InvoiceIndex.as_view()), @@ -147,6 +151,20 @@ urlpatterns = [ 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+)/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'), + # User editing url(r'^user/$', login_required(views.ProfileDetail.as_view()), name='profile_detail'), url(r'^user/(?P\d+)/$', @@ -154,17 +172,22 @@ urlpatterns = [ 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/requirements.txt b/requirements.txt index 3e7d8d17..1c38b09c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ django-recaptcha==1.3.0 django-registration-redux==1.6 django-reversion==1.10.2 django-toolbelt==0.0.1 +premailer==3.0.1 django-widget-tweaks==1.4.1 gunicorn==19.7.1 icalendar==3.11.4 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 new file mode 100644 index 00000000..824e9f88 --- /dev/null +++ b/templates/base_client.html @@ -0,0 +1,125 @@ +{% 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 %} + + diff --git a/templates/base_client_email.html b/templates/base_client_email.html new file mode 100644 index 00000000..f6f5e255 --- /dev/null +++ b/templates/base_client_email.html @@ -0,0 +1,58 @@ +{% load static from staticfiles %} +{% load raven %} + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ + + + +