Compare commits

..

1 Commits

Author SHA1 Message Date
David Taylor
6b7889d80e Add copy of Discourse-Auth app,
Probably should split into separate repo at some point, but committing here so I don't lose it
2017-04-11 00:28:35 +01:00
19 changed files with 494 additions and 38 deletions

View File

5
DiscourseAuth/admin.py Normal file
View File

@@ -0,0 +1,5 @@
from django.contrib import admin
from models import AuthAttempt, DiscourseUserLink
admin.site.register(AuthAttempt)
admin.site.register(DiscourseUserLink)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import DiscourseAuth.models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='AuthAttempt',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('nonce', models.CharField(default=DiscourseAuth.models.gen_nonce, max_length=25)),
('created', models.DateTimeField(auto_now=True)),
],
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('DiscourseAuth', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='authattempt',
old_name='created',
new_name='created_at',
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('DiscourseAuth', '0002_auto_20170126_1513'),
]
operations = [
migrations.RenameField(
model_name='authattempt',
old_name='created_at',
new_name='created',
),
]

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('DiscourseAuth', '0003_auto_20170126_1621'),
]
operations = [
migrations.CreateModel(
name='DiscourseUserLink',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('discourse_user_id', models.IntegerField()),
('django_user', models.OneToOneField(to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('DiscourseAuth', '0004_discourseuserlink'),
]
operations = [
migrations.AlterField(
model_name='discourseuserlink',
name='discourse_user_id',
field=models.IntegerField(unique=True),
),
]

View File

47
DiscourseAuth/models.py Normal file
View File

@@ -0,0 +1,47 @@
import uuid
from django.db import models
from datetime import datetime, timedelta
from django.conf import settings
class AuthAttemptManager(models.Manager):
expiryMinutes = 10
def get_acceptable(self):
oldestAcceptableNonce = datetime.now() - timedelta(minutes=self.expiryMinutes)
return super(AuthAttemptManager, self).get_queryset().filter(created__gte=oldestAcceptableNonce)
def purge_unacceptable(self):
oldestAcceptableNonce = datetime.now() - timedelta(minutes=self.expiryMinutes)
super(AuthAttemptManager, self).get_queryset().filter(created__lt=oldestAcceptableNonce).delete()
def gen_nonce():
# return "THISISANONCETHATWEWILLREUSE"
return uuid.uuid4()
class AuthAttempt(models.Model):
nonce = models.CharField(max_length=25, default=gen_nonce)
created = models.DateTimeField(auto_now=True)
objects = AuthAttemptManager()
def __str__(self):
return "AuthAttempt at " + str(self.created)
class DiscourseUserLink(models.Model):
django_user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
discourse_user_id = models.IntegerField(unique=True)
def __str__(self):
return "{} - {}".format(self.discourse_user_id, str(self.django_user))

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Associate User" %}{% endblock %}
{% block content %}
<form method="post" action="">
{% csrf_token %}
<p>You are currently logged in to django as "{{ djangouser }}". If this isn't you please log out</p>
<p>If you would like to link Discourse account "{{ discourseuser }}" to your django account, click below. This will remove any existing links.</p>
<input type="submit" value="{% trans 'Link my accounts' %}" />
</form>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Associate User" %}{% endblock %}
{% block content %}
{% if haslink %}
<form method="post" action="">
{% csrf_token %}
<p>Your account is currently linked to a Discourse account. To remove this link, click below. You will no longer be able to login using Discourse.</p>
<input type="submit" value="{% trans 'Un-Link my accounts' %}" />
</form>
{% else %}
<p>Your account is not currently linked to Discourse.</p>
{% endif %}
{% endblock %}

11
DiscourseAuth/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.conf.urls import patterns, url
from views import StartDiscourseAuth, ContinueDiscourseAuth, NewDiscourseUser, AssociateDiscourseUser, DisassociateDiscourseUser
urlpatterns = patterns('DiscourseAuth',
url(r'^start/$', StartDiscourseAuth.as_view(), name='start-auth'),
url(r'^continue/$', ContinueDiscourseAuth.as_view(), name='continue-auth'),
url(r'^new/$', NewDiscourseUser.as_view(), name='new-user'),
url(r'^associate/$', AssociateDiscourseUser.as_view(), name='associate-user'),
url(r'^disassociate/$', DisassociateDiscourseUser.as_view(), name='disassociate-user')
)

253
DiscourseAuth/views.py Normal file
View File

@@ -0,0 +1,253 @@
from django.views.generic import View, FormView, TemplateView
from django.http import HttpResponseRedirect
from django.contrib.auth import login
from django.contrib.auth.backends import ModelBackend
from django.core.urlresolvers import reverse
from django.core.exceptions import PermissionDenied
from django.contrib.auth import get_user_model
import urllib
from hashlib import sha256
import hmac
from base64 import b64decode, b64encode
from django.db.models import Q
from django.conf import settings
from django.core.exceptions import ValidationError
from django.contrib import messages
from models import AuthAttempt, DiscourseUserLink
import time
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from registration.forms import RegistrationForm
class StartDiscourseAuth(View):
http_method_names = ['get']
def get(self, request, *args, **kwargs):
# Where do we want to go once authentication is complete?
request.session['discourse_next'] = request.GET.get('next', "/")
# Generate random 'nonce'
attempt = AuthAttempt.objects.create()
nonce = attempt.nonce
# Where do we want discourse to send authenticated users?
redirect_uri = reverse('continue-auth')
redirect_uri = request.build_absolute_uri(redirect_uri)
# Data to sent to Discourse (payload)
data = {
'nonce': nonce,
'return_sso_url': redirect_uri
}
payload = urllib.urlencode(data)
b64payload = b64encode(payload.encode())
sig = hmac.new(settings.DISCOURSE_SECRET_KEY.encode(), b64payload, sha256).hexdigest()
return HttpResponseRedirect(settings.DISCOURSE_BASE_URL + '/session/sso_provider?' + urllib.urlencode({'sso': b64payload, 'sig': sig}))
class ContinueDiscourseAuth(View):
http_method_names = ['get']
def get(self, request, *args, **kwargs):
# Where do we want to go once authentication is complete?
nextUrl = request.session.get('discourse_next', "/")
rawSig = request.GET['sig']
rawSSO = request.GET['sso']
payload = urllib.unquote(rawSSO)
computed_sig = hmac.new(
settings.DISCOURSE_SECRET_KEY.encode(),
payload.encode(),
sha256
).hexdigest()
successful = hmac.compare_digest(computed_sig, rawSig.encode())
if not successful: # The signature doesn't match, not legit
raise ValidationError("Signature does not match, data has been manipulated")
decodedPayload = urllib.unquote_plus(b64decode(urllib.unquote(payload)).decode())
data = dict(data.split("=") for data in decodedPayload.split('&'))
# Get the nonce that's been returned by discourse
returnedNonce = data['nonce']
try: # See if it's in the database
storedAttempt = AuthAttempt.objects.get_acceptable().get(nonce=returnedNonce)
except AuthAttempt.DoesNotExist: # If it's not, this attempt is not valid
raise ValidationError("Nonce does not exist in database")
# Delete the nonce from the database - don't allow it to be reused
storedAttempt.delete()
# While we're at it, delete all the other expired attempts
AuthAttempt.objects.purge_unacceptable()
# If we've got this far, the attempt is valid, so let's load user information
external_id = int(data['external_id'])
# See if the user is already linked to a django user
try:
userLink = DiscourseUserLink.objects.get(discourse_user_id=external_id)
except DiscourseUserLink.DoesNotExist:
return self.linkNewUser(request, data)
# Load the user
user = userLink.django_user
# Slightly hacky way to login user without calling authenticate()
user.backend = "%s.%s" % (ModelBackend.__module__, ModelBackend.__name__)
# Login the user
login(request, user)
return HttpResponseRedirect(nextUrl)
def linkNewUser(self, request, data):
# Great, let's save the new user info in the session
request.session['discourse_data'] = data
request.session['discourse_started_registration'] = time.time()
if request.user is not None:
return HttpResponseRedirect(reverse('associate-user'))
else:
return HttpResponseRedirect(reverse('new-user'))
class SocialRegisterForm(RegistrationForm):
def __init__(self, *args, **kwargs):
super(SocialRegisterForm, self).__init__(*args, **kwargs)
self.fields.pop('password1')
self.fields.pop('password2')
self.fields['email'].widget.attrs['readonly'] = True
def clean_email(self):
initial = getattr(self, 'initial', None)
if(initial['email'] != self.cleaned_data['email']):
raise ValidationError("You cannot change the email")
return initial['email']
class AssociateDiscourseUser(TemplateView):
template_name = "DiscourseAuth/associate_user.html"
@method_decorator(login_required) # Require user is logged in for associating their account
def dispatch(self, request, *args, **kwargs):
self.data = self.request.session.get('discourse_data', None)
timeStarted = self.request.session.get('discourse_started_registration', 0)
max_reg_time = 20 * 60 # Seconds
if timeStarted < (time.time() - max_reg_time):
raise PermissionDenied('The Discourse authentication has expired, please try again')
if self.data is None:
raise PermissionDenied('Discourse authentication data is not present in this session')
return super(AssociateDiscourseUser, self).dispatch(request, *args, **kwargs)
def get(self, request, **kwargs):
return super(AssociateDiscourseUser, self).get(self, request, **kwargs)
def get_context_data(self, *args, **kwargs):
c = super(AssociateDiscourseUser, self).get_context_data()
c['discourseuser'] = self.data['username']
c['djangouser'] = self.request.user.username
return c
def post(self, request, **kwargs):
DiscourseUserLink.objects.filter(Q(django_user=request.user) | Q(discourse_user_id=self.data['external_id'])).delete()
DiscourseUserLink.objects.create(django_user=request.user, discourse_user_id=self.data['external_id'])
messages.success(self.request, 'Accounts successfully linked, you are now logged in.')
# Redirect them to the discourse login URL
nextUrl = "{}?next={}".format(reverse('start-auth'), request.session.get('discourse_next', "/"))
return HttpResponseRedirect(nextUrl)
class NewDiscourseUser(FormView):
template_name = 'registration/registration_form.html'
def dispatch(self, request, *args, **kwargs):
self.data = self.request.session.get('discourse_data', None)
timeStarted = self.request.session.get('discourse_started_registration', 0)
max_reg_time = 20 * 60 # Seconds
if timeStarted < (time.time() - max_reg_time):
raise PermissionDenied('The Discourse authentication has expired, please try again')
if self.data is None:
raise PermissionDenied('Discourse authentication data is not present in this session')
return super(NewDiscourseUser, self).dispatch(request, *args, **kwargs)
def get_initial(self):
data = self.data
initialForm = {
'username': data['username'],
'email': data['email'],
'name': data['name']
}
return initialForm
def get_form_class(self):
if settings.DISCOURSE_REGISTRATION_FORM:
return settings.DISCOURSE_REGISTRATION_FORM
else:
return SocialRegisterForm
def form_valid(self, form):
# This method is called when valid form data has been POSTed.
user = get_user_model().objects.create_user(**form.cleaned_data)
# Link the user to Discourse account
DiscourseUserLink.objects.filter(discourse_user_id=self.data['external_id']).delete()
DiscourseUserLink.objects.create(django_user=user, discourse_user_id=self.data['external_id'])
messages.success(self.request, 'Account successfully created, you are now logged in.')
# Redirect them to the discourse login URL
nextUrl = "{}?next={}".format(reverse('start-auth'), self.request.session.get('discourse_next', "/"))
return HttpResponseRedirect(nextUrl)
class DisassociateDiscourseUser(TemplateView):
template_name = "DiscourseAuth/disassociate_user.html"
@method_decorator(login_required) # Require user is logged in for associating their account
def dispatch(self, request, *args, **kwargs):
return super(DisassociateDiscourseUser, self).dispatch(request, *args, **kwargs)
def get_context_data(self, *args, **kwargs):
c = super(DisassociateDiscourseUser, self).get_context_data()
links = DiscourseUserLink.objects.filter(django_user=self.request.user)
c['haslink'] = links.count() > 0
return c
def post(self, request, **kwargs):
DiscourseUserLink.objects.filter(django_user=request.user).delete()
return self.get(self, request, **kwargs)

View File

@@ -51,6 +51,7 @@ INSTALLED_APPS = (
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'RIGS', 'RIGS',
'DiscourseAuth',
'debug_toolbar', 'debug_toolbar',
'registration', 'registration',
@@ -70,7 +71,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
) )
ROOT_URLCONF = 'PyRIGS.urls' ROOT_URLCONF = 'PyRIGS.urls'
@@ -221,3 +222,7 @@ TEMPLATE_DIRS = (
USE_GRAVATAR=True USE_GRAVATAR=True
TERMS_OF_HIRE_URL = "http://www.nottinghamtec.co.uk/terms.pdf" TERMS_OF_HIRE_URL = "http://www.nottinghamtec.co.uk/terms.pdf"
DISCOURSE_SECRET_KEY = 'pB1f94Mfky1Y0eOrk2UjB1VqnAZ52P7v'
DISCOURSE_BASE_URL = 'https://forum.nottinghamtec.co.uk'

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from registration.backends.default.views import RegistrationView from registration.backends.default.views import RegistrationView
import RIGS import RIGS
from RIGS import regbackend from RIGS import regbackend
import DiscourseAuth.urls
urlpatterns = patterns('', urlpatterns = patterns('',
# Examples: # Examples:
@@ -18,6 +19,7 @@ urlpatterns = patterns('',
url('^user/', include('registration.backends.default.urls')), url('^user/', include('registration.backends.default.urls')),
url(r'^admin/', include(admin.site.urls)), url(r'^admin/', include(admin.site.urls)),
url(r'^discourse-auth/', include(DiscourseAuth.urls)),
) )
if settings.DEBUG: if settings.DEBUG:

View File

@@ -1,17 +1,16 @@
import cStringIO as StringIO import cStringIO as StringIO
import datetime import datetime
import re import re
from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.db.models import Count, Sum, F, FloatField, Q, Value
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template import RequestContext from django.template import RequestContext
from django.template.loader import get_template from django.template.loader import get_template
from django.views import generic from django.views import generic
from django.db.models import Q
from z3c.rml import rml2pdf from z3c.rml import rml2pdf
from RIGS import models from RIGS import models
@@ -31,8 +30,19 @@ class InvoiceIndex(generic.ListView):
return context return context
def get_queryset(self): def get_queryset(self):
query = self.model.objects.outstanding().select_related('event', 'event__organisation', 'event__person', # Manual query is the only way I have found to do this efficiently. Not ideal but needs must
'event__venue', 'event__mic') sql = "SELECT * FROM " \
"(SELECT " \
"(SELECT COUNT(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payment_count\", " \
"(SELECT SUM(ei.cost * ei.quantity) FROM \"RIGS_eventitem\" AS ei WHERE ei.event_id=\"RIGS_invoice\".event_id) AS \"cost\", " \
"(SELECT SUM(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payments\", " \
"\"RIGS_invoice\".\"id\", \"RIGS_invoice\".\"event_id\", \"RIGS_invoice\".\"invoice_date\", \"RIGS_invoice\".\"void\" FROM \"RIGS_invoice\") " \
"AS sub " \
"WHERE (((cost > 0.0) AND (payment_count=0)) OR (cost - payments) <> 0.0) AND void = '0'" \
"ORDER BY invoice_date"
query = self.model.objects.raw(sql)
return query return query
@@ -84,7 +94,6 @@ class InvoiceVoid(generic.View):
return HttpResponseRedirect(reverse_lazy('invoice_list')) return HttpResponseRedirect(reverse_lazy('invoice_list'))
return HttpResponseRedirect(reverse_lazy('invoice_detail', kwargs={'pk': object.pk})) return HttpResponseRedirect(reverse_lazy('invoice_detail', kwargs={'pk': object.pk}))
class InvoiceDelete(generic.DeleteView): class InvoiceDelete(generic.DeleteView):
model = models.Invoice model = models.Invoice
@@ -105,7 +114,6 @@ class InvoiceDelete(generic.DeleteView):
def get_success_url(self): def get_success_url(self):
return self.request.POST.get('next') return self.request.POST.get('next')
class InvoiceArchive(generic.ListView): class InvoiceArchive(generic.ListView):
model = models.Invoice model = models.Invoice
template_name = 'RIGS/invoice_list_archive.html' template_name = 'RIGS/invoice_list_archive.html'
@@ -134,11 +142,11 @@ class InvoiceWaiting(generic.ListView):
events = self.model.objects.filter( events = self.model.objects.filter(
( (
Q(start_date__lte=datetime.date.today(), end_date__isnull=True) | # Starts before with no end Q(start_date__lte=datetime.date.today(), end_date__isnull=True) | # Starts before with no end
Q(end_date__lte=datetime.date.today()) # Has end date, finishes before Q(end_date__lte=datetime.date.today()) # Has end date, finishes before
) & Q(invoice__isnull=True) # Has not already been invoiced ) & Q(invoice__isnull=True) # Has not already been invoiced
& Q(is_rig=True) # Is a rig (not non-rig) & Q(is_rig=True) # Is a rig (not non-rig)
).order_by('start_date') \ ).order_by('start_date') \
.select_related('person', .select_related('person',
'organisation', 'organisation',
'venue', 'mic') \ 'venue', 'mic') \

View File

@@ -28,6 +28,22 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail):
return self.cleaned_data['initials'] return self.cleaned_data['initials']
class SocialRegisterForm(ProfileRegistrationFormUniqueEmail):
def __init__(self, *args, **kwargs):
super(SocialRegisterForm, self).__init__(*args, **kwargs)
self.fields.pop('password1')
self.fields.pop('password2')
self.fields['email'].widget.attrs['readonly'] = True
def clean_email(self):
initial = getattr(self, 'initial', None)
if(initial['email'] != self.cleaned_data['email']):
raise ValidationError("You cannot change the email")
return initial['email']
# Login form # Login form
class PasswordReset(PasswordResetForm): class PasswordReset(PasswordResetForm):
captcha = ReCaptchaField(label='Captcha') captcha = ReCaptchaField(label='Captcha')

View File

@@ -1,11 +1,11 @@
import datetime import datetime
import hashlib import hashlib
import pytz
import random import random
import string import string
from collections import Counter from collections import Counter
from decimal import Decimal from decimal import Decimal
import pytz
import reversion import reversion
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
@@ -501,34 +501,14 @@ class EventCrew(models.Model):
notes = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True)
class InvoiceManager(models.Manager):
def outstanding(self):
return self.annotate(
_payment_total=models.Sum(models.F('payment__amount'))
).annotate(
_sum_total=models.Sum(models.F('event__items__cost') * models.F('event__items__quantity'),
output_field=models.DecimalField(decimal_places=2))
# ).annotate(
# _balance=models.ExpressionWrapper(models.F('_sum_total') - models.F('_payment_total'),
# models.DecimalField(decimal_places=2))
# ).filter(
# models.Q(_balance__isnull=True) |
# ~models.Q(_sum_total=models.F('_payment_total'))
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Invoice(models.Model): class Invoice(models.Model):
event = models.OneToOneField('Event') event = models.OneToOneField('Event')
invoice_date = models.DateField(auto_now_add=True) invoice_date = models.DateField(auto_now_add=True)
void = models.BooleanField(default=False) void = models.BooleanField(default=False)
objects = InvoiceManager()
@property @property
def sum_total(self): def sum_total(self):
if getattr(self, '_sum_total', None):
return Decimal(getattr(self, '_sum_total'))
return self.event.sum_total return self.event.sum_total
@property @property
@@ -537,10 +517,6 @@ class Invoice(models.Model):
@property @property
def payment_total(self): def payment_total(self):
if hasattr(self, '_payment_total') and hasattr(self, '_payment_count'):
if getattr(self, '_payment_count') == 0:
return Decimal(0.00)
return Decimal(getattr(self, '_payment_total', 0.00))
total = self.payment_set.aggregate(total=models.Sum('amount'))['total'] total = self.payment_set.aggregate(total=models.Sum('amount'))['total']
if total: if total:
return total return total

View File

@@ -23,7 +23,7 @@
<div class="list-group-item default"></div> <div class="list-group-item default"></div>
<a class="list-group-item" href="https://forum.nottinghamtec.co.uk" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Forum</a> <a class="list-group-item" href="//members.nottinghamtec.co.uk/forum" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Forum</a>
<a class="list-group-item" href="//members.nottinghamtec.co.uk/wiki" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Wiki</a> <a class="list-group-item" href="//members.nottinghamtec.co.uk/wiki" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Wiki</a>
<a class="list-group-item" href="http://members.nottinghamtec.co.uk/wiki/images/2/22/Event_Risk_Assesment.pdf" target="_blank"><span class="glyphicon glyphicon-link"></span> Pre-Event Risk Assessment</a> <a class="list-group-item" href="http://members.nottinghamtec.co.uk/wiki/images/2/22/Event_Risk_Assesment.pdf" target="_blank"><span class="glyphicon glyphicon-link"></span> Pre-Event Risk Assessment</a>
<a class="list-group-item" href="//members.nottinghamtec.co.uk/price" target="_blank"><span class="glyphicon glyphicon-link"></span> Price List</a> <a class="list-group-item" href="//members.nottinghamtec.co.uk/price" target="_blank"><span class="glyphicon glyphicon-link"></span> Price List</a>