Compare commits

...

58 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
David Taylor
337dbd74fd Merge pull request #273 from nottinghamtec/hotfix/restore-pagination
Restore pagination to invoice waiting (requested by Emma)
2016-11-04 13:36:21 +00:00
David Taylor
caa55fe89a Restore pagination to invoice waiting (requested by Emma) 2016-11-04 13:31:06 +00:00
David Taylor
289b30e823 Merge pull request #271 from nottinghamtec/hotfix/duplicate-message
Do not display "not saved" message when the event has been saved. Closes #151
2016-10-23 14:15:08 +01:00
David Taylor
b939bc5a64 Do not display "not saved" message when the event has been saved 2016-10-23 14:05:25 +01:00
David Taylor
5e8f2312d3 Merge pull request #270 from nottinghamtec/po-duplicate
Remove duplicated PO numbers, closes #256
2016-10-23 13:06:29 +01:00
David Taylor
90c8b19915 Added tests for PO non-duplication 2016-10-23 12:45:27 +01:00
David Taylor
d82ef3f8d1 Merge branch 'master' into master 2016-10-23 12:36:29 +01:00
David Taylor
fc006dc53e Merge pull request #269 from johnathan99j/patch-1
Fix table display issue in README
2016-10-23 12:36:10 +01:00
Johnathan Graydon
3ce191aaf2 Update README.md
Small adjustment to make GitHub markdown display the table.
2016-10-23 12:26:35 +01:00
Johnathan Graydon
6fc89727f2 Stop PO number from duplicating when copying event
Would close #256
2016-10-21 15:48:54 +01:00
David Taylor
b235ac540f Merge pull request #265 from nottinghamtec/ra-link
Add link to pre-event RA
2016-10-19 10:35:58 +01:00
Sam Osborne
97decf8c52 Add link to pre-event RA
#accessible
2016-10-19 00:54:42 +01:00
David Taylor
97cdf34c18 Merge pull request #263 from nottinghamtec/feature/forum-embed
Forum embed
2016-10-11 18:56:02 +01:00
Tom Price
92c77c07e0 Fix tailing line breaks 2016-10-11 18:47:13 +01:00
David Taylor
0541a70cec Fixed event title link (_blank) 2016-10-09 11:30:13 +01:00
David Taylor
e0cb2f4925 Linked RIGS title 2016-10-09 11:26:32 +01:00
David Taylor
68a46af1a8 Fixed rounded corner fail 2016-10-09 11:22:34 +01:00
David Taylor
f61158b9c0 Rounded corners, transparent background 2016-10-09 11:20:43 +01:00
David Taylor
88954eca5c Removed weird background from embed 2016-10-09 10:40:18 +01:00
David Taylor
3fc04616b3 Added test for cookie warning 2016-10-09 10:36:30 +01:00
David Taylor
2d5f768523 Added cookie check with nice error message 2016-10-09 10:32:58 +01:00
David Taylor
5949ff74ec Added javascript cookie check, if blocked, login in new tab 2016-10-08 22:55:27 +01:00
David Taylor
879ecd1f6d Made font size smaller in embed 2016-10-08 21:49:03 +01:00
David Taylor
0e72c3f896 Made pretty, and made embedding accessible to non-keyholders 2016-10-08 21:38:12 +01:00
David Taylor
b93a716a3b Added unit tests 2016-10-08 20:37:01 +01:00
David Taylor
0d92c3812a Tidied up python 2016-10-08 19:56:56 +01:00
David Taylor
fc110a0bff Fixed padding 2016-10-08 19:55:31 +01:00
David Taylor
008edd8bee Lots of tidying up, moved inline CSS into SCSS 2016-10-08 19:32:45 +01:00
David Taylor
ac7e85c24a PEP8 and comments 2016-10-08 17:30:23 +01:00
David Taylor
73b8ce4add Revert "Added decorator for X-Frame header"
This reverts commit 8a838aa4bd.
2016-10-08 17:19:35 +01:00
David Taylor
511ce554b1 Revert "Try allow-from header (limited browser support)"
This reverts commit 3f4c362bfa.
2016-10-08 17:19:27 +01:00
David Taylor
536842971d Revert "Try just removing the header, this should work in all browsers"
This reverts commit 3e224a33a7.
2016-10-08 17:19:18 +01:00
David Taylor
3e224a33a7 Try just removing the header, this should work in all browsers 2016-10-08 17:14:29 +01:00
David Taylor
3f4c362bfa Try allow-from header (limited browser support) 2016-10-08 17:01:37 +01:00
David Taylor
8a838aa4bd Added decorator for X-Frame header 2016-10-07 02:51:08 +01:00
David Taylor
7e379b33db Fixed login autofocus and error messages 2016-10-07 02:24:24 +01:00
David Taylor
5e9f7e2c63 More prettying 2016-10-06 17:00:45 +01:00
David Taylor
3f752cd7b7 Made embed prettier 2016-10-06 16:48:19 +01:00
David Taylor
25a3ef3f0c Don't login in new window 2016-10-06 16:15:53 +01:00
David Taylor
1b28efb6af Allow the embedded login to be embedded (useful feature) 2016-10-06 16:10:51 +01:00
David Taylor
441a2be0b8 Added embedded login, and all iframe links open in new tab 2016-10-06 16:08:01 +01:00
David Taylor
1bdc4bd293 Fixed description = none in embed 2016-10-06 13:22:47 +01:00
David Taylor
f0bb4c5b02 Move exemption to urls.py (cleaner) 2016-10-06 13:13:09 +01:00
David Taylor
4660322964 Remove hardcoded URL 2016-10-06 13:04:33 +01:00
David Taylor
59efc2c485 Fixed JSON 2016-10-06 12:59:37 +01:00
David Taylor
69b0ff9fae Made embed page, with clickjacking protection turned off 2016-10-06 12:52:33 +01:00
David Taylor
4b94ea7ef2 Made login redirect JS for event detail 2016-10-06 12:02:44 +01:00
David Taylor
0244f5cfca Restored login security to events 2016-10-05 10:42:49 +01:00
David Taylor
17c7a3c524 Made embed tag use absolute URL 2016-10-05 10:39:50 +01:00
David Taylor
a02087bf2a Fixed fail 2016-10-04 21:11:43 +01:00
David Taylor
585f909d3f Escape JSON 2016-10-04 21:05:07 +01:00
David Taylor
eb10c8e21f Add meta to detail page 2016-10-03 23:13:25 +01:00
David Taylor
f7ea0cb834 Remove security from event detail (for testing in staging) 2016-10-03 23:09:57 +01:00
David Taylor
64f3842a13 Added iframe to embed 2016-10-03 23:02:19 +01:00
David Taylor
6370679b62 Initial proof of concept 2016-10-03 22:45:57 +01:00
David Taylor
e77728c52c Merge pull request #260 from nottinghamtec/subhire-form
Looks good, merging
2016-09-22 13:40:05 +01:00
Sam Osborne
92f4e26883 Added link to subhire form
Until such a point that subhire on RIGS actually happens, it's useful to have this link here.
2016-09-22 12:58:43 +01:00
33 changed files with 934 additions and 36 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

@@ -2,22 +2,36 @@ from django.contrib.auth import REDIRECT_FIELD_NAME
from django.shortcuts import render_to_response
from django.template import RequestContext
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
def user_passes_test_with_403(test_func, login_url=None):
from RIGS import models
def user_passes_test_with_403(test_func, login_url=None, oembed_view=None):
"""
Decorator for views that checks that the user passes the given test.
Anonymous users will be redirected to login_url, while users that fail
the test will be given a 403 error.
If embed_view is set, then a JS redirect will be used, and a application/json+oembed
meta tag set with the url of oembed_view
(oembed_view will be passed the kwargs from the main function)
"""
if not login_url:
from django.conf import settings
login_url = settings.LOGIN_URL
def _dec(view_func):
def _checklogin(request, *args, **kwargs):
if test_func(request.user):
return view_func(request, *args, **kwargs)
elif not request.user.is_authenticated():
if oembed_view is not None:
extra_context = {}
extra_context['oembed_url'] = "{0}://{1}{2}".format(request.scheme, request.META['HTTP_HOST'], reverse(oembed_view, kwargs=kwargs))
extra_context['login_url'] = "{0}?{1}={2}".format(login_url, REDIRECT_FIELD_NAME, request.get_full_path())
resp = render_to_response('login_redirect.html', extra_context, context_instance=RequestContext(request))
return resp
else:
return HttpResponseRedirect('%s?%s=%s' % (login_url, REDIRECT_FIELD_NAME, request.get_full_path()))
else:
resp = render_to_response('403.html', context_instance=RequestContext(request))
@@ -28,14 +42,14 @@ def user_passes_test_with_403(test_func, login_url=None):
return _checklogin
return _dec
def permission_required_with_403(perm, login_url=None):
def permission_required_with_403(perm, login_url=None, oembed_view=None):
"""
Decorator for views that checks whether a user has a particular permission
enabled, redirecting to the log-in page or rendering a 403 as necessary.
"""
return user_passes_test_with_403(lambda u: u.has_perm(perm), login_url=login_url)
return user_passes_test_with_403(lambda u: u.has_perm(perm), login_url=login_url, oembed_view=oembed_view)
from RIGS import models
def api_key_required(function):
"""

View File

@@ -51,6 +51,7 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'RIGS',
'DiscourseAuth',
'debug_toolbar',
'registration',
@@ -70,7 +71,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
ROOT_URLCONF = 'PyRIGS.urls'
@@ -221,3 +222,7 @@ TEMPLATE_DIRS = (
USE_GRAVATAR=True
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
import RIGS
from RIGS import regbackend
import DiscourseAuth.urls
urlpatterns = patterns('',
# Examples:
@@ -18,6 +19,7 @@ urlpatterns = patterns('',
url('^user/', include('registration.backends.default.urls')),
url(r'^admin/', include(admin.site.urls)),
url(r'^discourse-auth/', include(DiscourseAuth.urls)),
)
if settings.DEBUG:

View File

@@ -85,6 +85,7 @@ Then load the sample data using the command:
python manage.py generateSampleData
```
4 user accounts are created for convenience:
|Username |Password |
|---------|---------|
|superuser|superuser|

View File

@@ -122,7 +122,7 @@ class InvoiceArchive(generic.ListView):
class InvoiceWaiting(generic.ListView):
model = models.Event
# paginate_by = 25
paginate_by = 25
template_name = 'RIGS/event_invoice.html'
def get_context_data(self, **kwargs):

View File

@@ -28,6 +28,22 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail):
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
class PasswordReset(PasswordResetForm):
captcha = ReCaptchaField(label='Captcha')

View File

@@ -9,11 +9,13 @@ from django.shortcuts import get_object_or_404
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.http import HttpResponse
from django.db.models import Q
from django.contrib import messages
from z3c.rml import rml2pdf
from PyPDF2 import PdfFileMerger, PdfFileReader
import simplejson
from RIGS import models, forms
import datetime
@@ -47,6 +49,28 @@ class EventDetail(generic.DetailView):
model = models.Event
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)
data = {
'html': '<iframe src="{0}" frameborder="0" width="100%" height="250"></iframe>'.format(full_url),
'version': '1.0',
'type': 'rich',
}
json = simplejson.JSONEncoderForHTML().encode(data)
return HttpResponse(json, content_type="application/json")
class EventEmbed(EventDetail):
template_name = 'RIGS/event_embed.html'
class EventCreate(generic.CreateView):
model = models.Event
form_class = forms.EventForm
@@ -97,10 +121,11 @@ class EventDuplicate(EventUpdate):
old = super(EventDuplicate, self).get_object(queryset) # Get the object (the event you're duplicating)
new = copy.copy(old) # Make a copy of the object in memory
new.based_on = old # Make the new event based on the old event
new.purchase_order = None
if self.request.method in ('POST', 'PUT'): # This only happens on save (otherwise items won't display in editor)
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.')
return new

File diff suppressed because one or more lines are too long

View File

@@ -147,3 +147,45 @@ ins {
};
}
}
html.embedded{
min-height:100%;
display: table;
width: 100%;
body{
padding:0;
display: table-cell;
vertical-align: middle;
width:100%;
background:none;
}
.embed_container{
border:5px solid #e9e9e9;
padding:12px 0px;
min-height:100%;
width:100%;
}
.source{
background: url('/static/imgs/pyrigs-avatar.png') no-repeat;
background-size: 16px 16px;
padding-left: 20px;
color: #000;
}
h3{
margin-top:10px;
margin-bottom:5px;
}
p{
margin-bottom:2px;
font-size: 11px;
}
.event-mic-photo{
max-width: 3em;
}
}

View File

@@ -0,0 +1,106 @@
{% extends 'base_embed.html' %}
{% load static from staticfiles %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<a href="/">
<span class="source"> R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></span>
</a>
</div>
<div class="col-sm-12">
<span class="pull-right">
{% if object.mic %}
<div class="text-center">
<img src="{{ object.mic.profile_picture }}" class="event-mic-photo img-rounded"/>
</div>
{% elif object.is_rig %}
<span class="glyphicon glyphicon-exclamation-sign"></span>
{% endif %}
</span>
<h3>
<a {% if perms.RIGS.view_event %}href="{% url 'event_detail' object.pk %}"{% endif %}>
{% if object.is_rig %}N{{ object.pk|stringformat:"05d" }}{% else %}{{ object.pk }}{% endif %}
| {{ object.name }} </a>
{% if object.venue %}
<small>at {{ object.venue }}</small>
{% endif %}
<br/><small>
{{ object.start_date|date:"D d/m/Y" }}
{% if object.has_start_time %}
{{ object.start_time|date:"H:i" }}
{% endif %}
{% if object.end_date or object.has_end_time %}
&ndash;
{% endif %}
{% if object.end_date and object.end_date != object.start_date %}
{{ object.end_date|date:"D d/m/Y" }}
{% endif %}
{% if object.has_end_time %}
{{ object.end_time|date:"H:i" }}
{% endif %}
</small>
</h3>
<div class="row">
<div class="col-xs-6">
<p>
<strong>Status:</strong>
{{ object.get_status_display }}
</p>
<p>
{% if object.is_rig %}
<strong>Client:</strong> {{ object.person.name }}
{% if object.organisation %}
for {{ object.organisation.name }}
{% endif %}
{% if object.dry_hire %}(Dry Hire){% endif %}
{% else %}
<strong>Non-Rig</strong>
{% endif %}
</p>
<p>
<strong>MIC:</strong>
{% if object.mic %}
{{object.mic.name}}
{% else %}
None
{% endif %}
</p>
</div>
<div class="col-xs-6">
{% if object.meet_at %}
<p>
<strong>Crew meet:</strong>
{{ object.meet_at|date:"H:i" }} {{ object.meet_at|date:"(Y-m-d)" }}
</p>
{% endif %}
{% if object.access_at %}
<p>
<strong>Access at:</strong>
{{ object.access_at|date:"H:i" }} {{ object.access_at|date:"(Y-m-d)" }}
</p>
{% endif %}
<p>
<strong>Last updated:</strong>
{{ object.last_edited_at }} by "{{ object.last_edited_by.initials }}"
</p>
</div>
</div>
{% if object.description %}
<p>
<strong>Description: </strong>
{{ object.description|linebreaksbr }}
</p>
{% endif %}
</table>
</div>
</div>
{% endblock %}

View File

@@ -25,7 +25,9 @@
<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="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="https://form.jotformeu.com/62203600438344" target="_blank"><span class="glyphicon glyphicon-link"></span> Subhire Insurance Form</a>
</div>
</div>

View File

@@ -438,7 +438,7 @@ 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")
testEvent = models.Event.objects.create(name="TE E1", status=models.Event.PROVISIONAL, start_date=date.today() + timedelta(days=6), description="start future no end", purchase_order="TESTPO")
item1 = models.EventItem(
event=testEvent,
@@ -471,6 +471,9 @@ class EventTest(LiveServerTestCase):
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)
# Add item
form.find_element_by_xpath('//button[contains(@class, "item-add")]').click()
wait.until(animation_is_finished())
@@ -489,6 +492,7 @@ class EventTest(LiveServerTestCase):
save.click()
self.assertNotIn("N0000%d"%testEvent.pk, self.browser.find_element_by_xpath('//h1').text)
self.assertNotIn("Event data duplicated but not yet saved", self.browser.find_element_by_id('content').text) # Check info message not visible
# Check the new items are visible
table = self.browser.find_element_by_id('item-table') # ID number is known, see above
@@ -498,6 +502,8 @@ class EventTest(LiveServerTestCase):
infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..')
self.assertIn("N0000%d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text)
# Check the PO hasn't carried through
self.assertNotIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text)
@@ -506,6 +512,8 @@ class EventTest(LiveServerTestCase):
#Check that based-on hasn't crept into the old event
infoPanel = self.browser.find_element_by_xpath('//div[contains(text(), "Event Info")]/..')
self.assertNotIn("N0000%d"%testEvent.pk, infoPanel.find_element_by_xpath('//dt[text()="Based On"]/following-sibling::dd[1]').text)
# Check the PO remains on the old event
self.assertIn("TESTPO", infoPanel.find_element_by_xpath('//dt[text()="PO"]/following-sibling::dd[1]').text)
# Check the items are as they were
table = self.browser.find_element_by_id('item-table') # ID number is known, see above

View File

@@ -213,6 +213,88 @@ class TestInvoiceDelete(TestCase):
# Check this didn't work
self.assertTrue(models.Invoice.objects.get(pk=self.invoices[1].pk))
class TestEmbeddedViews(TestCase):
@classmethod
def setUpTestData(cls):
cls.profile = models.Profile.objects.create(username="testuser1", email="1@test.com", is_superuser=True, is_active=True, is_staff=True)
cls.events = {
1: models.Event.objects.create(name="TE E1", start_date=date.today()),
2: models.Event.objects.create(name="TE E2", start_date=date.today())
}
cls.invoices = {
1: models.Invoice.objects.create(event=cls.events[1]),
2: models.Invoice.objects.create(event=cls.events[2])
}
cls.payments = {
1: models.Payment.objects.create(invoice=cls.invoices[1], date=date.today(), amount=12.34, method=models.Payment.CASH)
}
def setUp(self):
self.profile.set_password('testuser')
self.profile.save()
def testLoginRedirect(self):
request_url = reverse('event_embed', kwargs={'pk': 1})
expected_url = "{0}?next={1}".format(reverse('login_embed'), request_url)
# Request the page and check it redirects
response = self.client.get(request_url, follow=True)
self.assertRedirects(response, expected_url, status_code=302, target_status_code=200)
# Now login
self.assertTrue(self.client.login(username=self.profile.username, password='testuser'))
# And check that it no longer redirects
response = self.client.get(request_url, follow=True)
self.assertEqual(len(response.redirect_chain), 0)
def testLoginCookieWarning(self):
login_url = reverse('login_embed')
response = self.client.post(login_url, follow=True)
self.assertContains(response, "Cookies do not seem to be enabled")
def testXFrameHeaders(self):
event_url = reverse('event_embed', kwargs={'pk': 1})
login_url = reverse('login_embed')
self.assertTrue(self.client.login(username=self.profile.username, password='testuser'))
response = self.client.get(event_url, follow=True)
with self.assertRaises(KeyError):
response._headers["X-Frame-Options"]
response = self.client.get(login_url, follow=True)
with self.assertRaises(KeyError):
response._headers["X-Frame-Options"]
def testOEmbed(self):
event_url = reverse('event_detail', kwargs={'pk': 1})
event_embed_url = reverse('event_embed', kwargs={'pk': 1})
oembed_url = reverse('event_oembed', kwargs={'pk': 1})
alt_oembed_url = reverse('event_oembed', kwargs={'pk': 999})
alt_event_embed_url = reverse('event_embed', kwargs={'pk': 999})
# Test the meta tag is in place
response = self.client.get(event_url, follow=True, HTTP_HOST='example.com')
self.assertContains(response, '<link rel="alternate" type="application/json+oembed"')
self.assertContains(response, oembed_url)
# Test that the JSON exists
response = self.client.get(oembed_url, follow=True, HTTP_HOST='example.com')
self.assertEqual(response.status_code, 200)
self.assertContains(response, event_embed_url)
# Should also work for non-existant events
response = self.client.get(alt_oembed_url, follow=True, HTTP_HOST='example.com')
self.assertEqual(response.status_code, 200)
self.assertContains(response, alt_event_embed_url)
class TestSampleDataGenerator(TestCase):
@override_settings(DEBUG=True)
def test_generate_sample_data(self):

View File

@@ -1,7 +1,8 @@
from django.conf.urls import patterns, include, url
from django.conf.urls import patterns, url
from django.contrib.auth.decorators import login_required
from RIGS import models, views, rigboard, finance, ical, versioning, forms
from django.views.generic import RedirectView
from django.views.decorators.clickjacking import xframe_options_exempt
from PyRIGS.decorators import permission_required_with_403
from PyRIGS.decorators import api_key_required
@@ -14,6 +15,7 @@ urlpatterns = patterns('',
url(r'^closemodal/$', views.CloseModal.as_view(), name='closemodal'),
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}),
# People
@@ -80,8 +82,14 @@ urlpatterns = patterns('',
name='activity_feed'),
url(r'^event/(?P<pk>\d+)/$',
permission_required_with_403('RIGS.view_event')(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<pk>\d+)/embed/$',
xframe_options_exempt(login_required(login_url='/user/login/embed/')(rigboard.EventEmbed.as_view())),
name='event_embed'),
url(r'^event/(?P<pk>\d+)/oembed_json/$',
rigboard.EventOembed.as_view(),
name='event_oembed'),
url(r'^event/(?P<pk>\d+)/print/$',
permission_required_with_403('RIGS.view_event')(rigboard.EventPrint.as_view()),
name='event_print'),
@@ -158,4 +166,3 @@ urlpatterns = patterns('',
url(r'^bookings/$', RedirectView.as_view(permanent=True, pattern_name='rigboard')),
url(r'^bookings/past/$', RedirectView.as_view(permanent=True, pattern_name='event_archive')),
)

View File

@@ -12,6 +12,8 @@ from django.contrib import messages
import datetime, pytz
import operator
from registration.views import RegistrationView
from django.views.decorators.csrf import csrf_exempt
from RIGS import models, forms
@@ -29,12 +31,37 @@ class Index(generic.TemplateView):
def login(request, **kwargs):
if request.user.is_authenticated():
next = request.REQUEST.get('next', '/')
return HttpResponseRedirect(request.REQUEST.get('next', '/'))
return HttpResponseRedirect(next)
else:
from django.contrib.auth.views import login
return login(request)
# This view should be exempt from requiring CSRF token.
# Then we can check for it and show a nice error
# Don't worry, django.contrib.auth.views.login will
# check for it before logging the user in
@csrf_exempt
def login_embed(request, **kwargs):
print("Running LOGIN")
if request.user.is_authenticated():
next = request.REQUEST.get('next', '/')
return HttpResponseRedirect(next)
else:
from django.contrib.auth.views import login
if request.method == "POST":
csrf_cookie = request.COOKIES.get('csrftoken', None)
if csrf_cookie is None:
messages.warning(request, 'Cookies do not seem to be enabled. Try logging in using a new tab.')
request.method = 'GET' # Render the page without trying to login
return login(request, template_name="registration/login_embed.html")
"""
Called from a modal window (e.g. when an item is submitted to an event/invoice).
May optionally also include some javascript in a success message to cause a load of

49
templates/base_embed.html Normal file
View File

@@ -0,0 +1,49 @@
{% load static from staticfiles %}
{% load raven %}
<!DOCTYPE html>
<html
dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}"
xml:lang="{% firstof LANGUAGE_CODE 'en' %}"
lang="{% firstof LANGUAGE_CODE 'en' %}"
class="embedded">
<head>
<base target="_blank" />
<!-- Open all links in a new tab, not in the iframe -->
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400italic,700,300,400' rel='stylesheet'
type='text/css'>
<link rel="stylesheet" type="text/css" href="{% static "css/screen.css" %}">
<script src="https://code.jquery.com/jquery-1.8.3.min.js"
integrity="sha256-YcbK69I5IXQftf/mYD8WY0/KmEDCv1asggHpJk1trM8=" crossorigin="anonymous"></script>
<script src="https://cdn.ravenjs.com/1.3.0/jquery,native/raven.min.js"></script>
<script>Raven.config('{% sentry_public_dsn %}').install()</script>
</head>
<body>
{% include "analytics.html" %}
<div class="embed_container">
<div class="container-fluid">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.level_tag }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span
aria-hidden="true">&times;</span></button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
</div>
</div>
{% block js %}
{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load staticfiles %}
{% block title %}Login Required{% endblock %}
{% block js %}
<script>
document.location = "{{login_url}}"
</script>
{% endblock %}
{% block extra-head %}
{% if oembed_url %}
<link rel="alternate" type="application/json+oembed"
href="{{oembed_url}}"
title="RIGS Embed" />
{% endif %}
{% endblock %}
{% block content %}
<div class="text-center">
<h2>Login is required for this page</h2>
<a href="{{login_url}}" class="btn btn-primary">Login</a>
</div>
{% endblock %}

View File

@@ -3,5 +3,8 @@
{% block title %}Login{% endblock %}
{% block content %}
<div class="text-center">
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
</div>
{% include 'registration/loginform.html' %}
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends 'base_embed.html' %}
{% load widget_tweaks %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="text-center">
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
</div>
{% include 'form_errors.html' %}
<div class="col-sm-6 col-sm-offset-3 col-lg-4 col-lg-offset-4">
<form id="loginForm" action="" method="post" role="form" target="_self">{% csrf_token %}
<div class="form-group">
<label for="id_username">{{ form.username.label }}</label>
{% render_field form.username class+="form-control" placeholder=form.username.label %}
</div>
<div class="form-group">
<label for="{{ form.password.id_for_label }}">{{ form.password.label }}</label>
{% render_field form.password class+="form-control" placeholder=form.password.label %}
</div>
<div class="text-right">
<input type="submit" value="Login" class="btn btn-primary"/>
</div>
<input type="hidden" name="next" value="{{ next }}"/>
</form>
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% include 'form_errors.html' %}
<div class="col-sm-6 col-sm-offset-3 col-lg-4 col-lg-offset-4">
<form action="{% url 'login' %}" method="post" role="form">{% csrf_token %}
<form action="{% url 'login' %}" method="post" role="form" target="_self">{% csrf_token %}
<div class="form-group">
<label for="id_username">{{ form.username.label }}</label>
{% render_field form.username class+="form-control" placeholder=form.username.label autofocus="" %}
@@ -12,9 +12,11 @@
<label for="{{ form.password.id_for_label }}">{{ form.password.label }}</label>
{% render_field form.password class+="form-control" placeholder=form.password.label %}
</div>
<div class="text-right">
<a href="{% url 'registration_register' %}" class="btn">Register</a>
<a href="{% url 'password_reset' %}" class="btn">Forgotten Password</a>
<input type="submit" value="Login" class="btn btn-primary"/>
<input type="hidden" name="next" value="{{ next }}"/>
</div>
</form>
</div>