From e65e97b1a36e7b6311ceb3828ead79c70e72f48c Mon Sep 17 00:00:00 2001 From: Tom Price Date: Thu, 6 Apr 2017 22:26:05 +0100 Subject: [PATCH] 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 %} + +