mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-03-07 12:38:23 +00:00
Compare commits
61 Commits
hotfix/for
...
feature/di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc94820bb | ||
|
|
f3947d89ca | ||
|
|
0ad3aa7d3f | ||
|
|
01f754ad53 | ||
|
|
793f1d4e05 | ||
|
|
f5bf40bd9b | ||
|
|
289b30e823 | ||
|
|
b939bc5a64 | ||
|
|
5e8f2312d3 | ||
|
|
90c8b19915 | ||
|
|
d82ef3f8d1 | ||
|
|
fc006dc53e | ||
|
|
3ce191aaf2 | ||
|
|
6fc89727f2 | ||
|
|
b235ac540f | ||
|
|
97decf8c52 | ||
|
|
97cdf34c18 | ||
|
|
92c77c07e0 | ||
|
|
0541a70cec | ||
|
|
e0cb2f4925 | ||
|
|
68a46af1a8 | ||
|
|
f61158b9c0 | ||
|
|
88954eca5c | ||
|
|
3fc04616b3 | ||
|
|
2d5f768523 | ||
|
|
5949ff74ec | ||
|
|
879ecd1f6d | ||
|
|
0e72c3f896 | ||
|
|
b93a716a3b | ||
|
|
0d92c3812a | ||
|
|
fc110a0bff | ||
|
|
008edd8bee | ||
|
|
ac7e85c24a | ||
|
|
73b8ce4add | ||
|
|
511ce554b1 | ||
|
|
536842971d | ||
|
|
3e224a33a7 | ||
|
|
3f4c362bfa | ||
|
|
8a838aa4bd | ||
|
|
7e379b33db | ||
|
|
5e9f7e2c63 | ||
|
|
3f752cd7b7 | ||
|
|
25a3ef3f0c | ||
|
|
1b28efb6af | ||
|
|
441a2be0b8 | ||
|
|
1bdc4bd293 | ||
|
|
f0bb4c5b02 | ||
|
|
4660322964 | ||
|
|
59efc2c485 | ||
|
|
69b0ff9fae | ||
|
|
4b94ea7ef2 | ||
|
|
0244f5cfca | ||
|
|
17c7a3c524 | ||
|
|
a02087bf2a | ||
|
|
585f909d3f | ||
|
|
eb10c8e21f | ||
|
|
f7ea0cb834 | ||
|
|
64f3842a13 | ||
|
|
6370679b62 | ||
|
|
e77728c52c | ||
|
|
92f4e26883 |
@@ -2,23 +2,37 @@ 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():
|
||||
return HttpResponseRedirect('%s?%s=%s' % (login_url, REDIRECT_FIELD_NAME, request.get_full_path()))
|
||||
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))
|
||||
resp.status_code = 403
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -64,4 +78,4 @@ def api_key_required(function):
|
||||
if user_object.api_key != key:
|
||||
return error_resp
|
||||
return function(request, *args, **kwargs)
|
||||
return wrap
|
||||
return wrap
|
||||
|
||||
@@ -58,6 +58,7 @@ INSTALLED_APPS = (
|
||||
'captcha',
|
||||
'widget_tweaks',
|
||||
'raven.contrib.django.raven_compat',
|
||||
'social.apps.django_app.default',
|
||||
)
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
@@ -73,6 +74,31 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'RIGS.discourse.discourse.DiscourseAuth',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
)
|
||||
|
||||
SOCIAL_AUTH_PIPELINE = (
|
||||
'social.pipeline.social_auth.social_details', # Load remote details
|
||||
'social.pipeline.social_auth.social_uid', # Load remote ID
|
||||
'social.pipeline.social_auth.auth_allowed', # Check not blacklisted
|
||||
'social.pipeline.social_auth.social_user', # If already associated, login
|
||||
'RIGS.discourse.pipeline.new_connection', # Choose a user account, much UI
|
||||
'social.pipeline.social_auth.associate_user', # Associate the social auth with the user
|
||||
'social.pipeline.social_auth.load_extra_data', # Save all the social info we have on this user
|
||||
'RIGS.discourse.pipeline.update_avatar', # Load the avatar URL from the API, and save to user model
|
||||
'social.pipeline.user.user_details', # Save any details that changed
|
||||
)
|
||||
|
||||
DISCOURSE_HOST = os.environ.get('DISCOURSE_HOST') if os.environ.get('DISCOURSE_HOST') else 'http://localhost:4000'
|
||||
DISCOURSE_SSO_SECRET = os.environ.get('DISCOURSE_SSO_SECRET') if os.environ.get('DISCOURSE_SSO_SECRET') else 'ABCDEFGHIJKLMNOP'
|
||||
|
||||
DISCOURSE_API_KEY = os.environ.get('DISCOURSE_API_KEY') if os.environ.get('DISCOURSE_HOST') else None
|
||||
DISCOURSE_API_USER = os.environ.get('DISCOURSE_API_USER') if os.environ.get('DISCOURSE_HOST') else 'system'
|
||||
|
||||
REGISTRATION_OPEN = False # Disable built-in django registration - must register using forum
|
||||
|
||||
ROOT_URLCONF = 'PyRIGS.urls'
|
||||
|
||||
WSGI_APPLICATION = 'PyRIGS.wsgi.application'
|
||||
@@ -202,6 +228,8 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
"django.core.context_processors.tz",
|
||||
"django.core.context_processors.request",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
'social.apps.django_app.context_processors.backends',
|
||||
'social.apps.django_app.context_processors.login_redirect',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ urlpatterns = patterns('',
|
||||
url('^user/', include('registration.backends.default.urls')),
|
||||
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
url('', include('social.apps.django_app.urls', namespace='social'))
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
@@ -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|
|
||||
|
||||
0
RIGS/discourse/__init__.py
Normal file
0
RIGS/discourse/__init__.py
Normal file
91
RIGS/discourse/discourse.py
Normal file
91
RIGS/discourse/discourse.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import unicode_literals
|
||||
from social.backends.base import BaseAuth
|
||||
from django.conf import settings
|
||||
|
||||
from .sso import DiscourseSSO
|
||||
|
||||
|
||||
class DiscourseAssociation(object):
|
||||
""" Use Association model to save the nonce by force. """
|
||||
|
||||
def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''):
|
||||
self.handle = handle # as nonce
|
||||
self.secret = secret.encode() # not use
|
||||
self.issued = issued # not use
|
||||
self.lifetime = lifetime # not use
|
||||
self.assoc_type = assoc_type # as state
|
||||
|
||||
|
||||
class DiscourseAuth(BaseAuth):
|
||||
"""Discourse authentication backend"""
|
||||
name = 'discourse'
|
||||
secret = settings.DISCOURSE_SSO_SECRET
|
||||
host = settings.DISCOURSE_HOST
|
||||
|
||||
EXTRA_DATA = [
|
||||
('username', 'username'),
|
||||
('email', 'email'),
|
||||
('external_id', 'external_id')
|
||||
]
|
||||
|
||||
sso = DiscourseSSO(secret)
|
||||
|
||||
def get_and_store_nonce(self, url):
|
||||
# Create a nonce
|
||||
nonce = self.strategy.random_string(64)
|
||||
# Store the nonce
|
||||
association = DiscourseAssociation(nonce)
|
||||
self.strategy.storage.association.store(url, association)
|
||||
return nonce
|
||||
|
||||
def get_nonce(self, nonce):
|
||||
try:
|
||||
return self.strategy.storage.association.get(
|
||||
server_url=self.host,
|
||||
handle=nonce
|
||||
)[0]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def remove_nonce(self, nonce_id):
|
||||
self.strategy.storage.association.remove([nonce_id])
|
||||
|
||||
def get_user_id(self, details, response):
|
||||
"""Return current user id."""
|
||||
|
||||
return int(response['external_id'])
|
||||
|
||||
def get_user_details(self, response):
|
||||
"""Return user basic information (id and email only)."""
|
||||
|
||||
return {'username': response['username'],
|
||||
'email': response['email'],
|
||||
'fullname': response['name'].replace('+', ' ') if 'name' in response else '',
|
||||
'first_name': '',
|
||||
'last_name': ''}
|
||||
|
||||
def auth_url(self):
|
||||
"""Build and return complete URL."""
|
||||
nonce = self.get_and_store_nonce(self.host)
|
||||
|
||||
return self.host + self.sso.build_login_URL(nonce, self.redirect_uri)
|
||||
|
||||
def auth_complete(self, *args, **kwargs):
|
||||
"""Completes login process, must return user instance."""
|
||||
|
||||
try:
|
||||
if not self.sso.validate(self.data['sso'], self.data['sig']):
|
||||
raise Exception("Someone wants to hack us!")
|
||||
except KeyError:
|
||||
raise Exception("SSO Error, please try again")
|
||||
|
||||
nonce = self.sso.get_nonce(self.data['sso'])
|
||||
nonce_obj = self.get_nonce(nonce)
|
||||
if nonce_obj:
|
||||
self.remove_nonce(nonce_obj.id)
|
||||
else:
|
||||
raise Exception("Nonce does not match!")
|
||||
|
||||
kwargs.update({'response': self.sso.get_data(
|
||||
self.data['sso']), 'backend': self})
|
||||
return self.strategy.authenticate(*args, **kwargs)
|
||||
88
RIGS/discourse/pipeline.py
Normal file
88
RIGS/discourse/pipeline.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||
from django.shortcuts import render_to_response
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
import json
|
||||
import requests
|
||||
|
||||
from social.pipeline.partial import partial
|
||||
|
||||
from RIGS.models import Profile
|
||||
from RIGS import forms
|
||||
|
||||
|
||||
class SocialRegisterForm(forms.ProfileRegistrationFormUniqueEmail):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SocialRegisterForm, self).__init__(*args, **kwargs)
|
||||
self.fields.pop('password1')
|
||||
self.fields.pop('password2')
|
||||
self.fields.pop('captcha')
|
||||
|
||||
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']
|
||||
|
||||
|
||||
@partial
|
||||
def new_connection(backend, details, response, user=None, is_new=False, social=None, request=None, *args, **kwargs):
|
||||
if social is not None:
|
||||
return
|
||||
|
||||
data = backend.strategy.request_data()
|
||||
|
||||
if data.get('UseCurrentAccount') is not None:
|
||||
return
|
||||
|
||||
alreadyLoggedIn = user is not None
|
||||
|
||||
context = {
|
||||
'details': details,
|
||||
'alreadyLoggedIn': alreadyLoggedIn,
|
||||
'loggedInUser': user,
|
||||
}
|
||||
|
||||
if not alreadyLoggedIn:
|
||||
completeUrl = reverse('social:complete', kwargs={'backend': backend.name})
|
||||
context['login_url'] = "{0}?{1}={2}".format(reverse('login'), REDIRECT_FIELD_NAME, completeUrl)
|
||||
|
||||
if data.get('username') is None:
|
||||
form = SocialRegisterForm(initial=details)
|
||||
else:
|
||||
form = SocialRegisterForm(data, initial=details)
|
||||
|
||||
if form.is_valid():
|
||||
new_user = Profile.objects.create_user(**form.cleaned_data)
|
||||
return {'user': new_user}
|
||||
|
||||
context['form'] = form
|
||||
|
||||
return render_to_response('RIGS/social-associate.html', context)
|
||||
|
||||
|
||||
def update_avatar(backend, details, response, user=None, social=None, *args, **kwargs):
|
||||
host = settings.DISCOURSE_HOST
|
||||
api_key = settings.DISCOURSE_API_KEY
|
||||
api_user = settings.DISCOURSE_API_USER
|
||||
if social is not None:
|
||||
url = "{}/users/{}.json".format(host, details['username'])
|
||||
params = {
|
||||
'api_key': api_key,
|
||||
'api_username': api_user
|
||||
}
|
||||
resp = requests.get(url=url, params=params)
|
||||
extraData = json.loads(resp.text)
|
||||
|
||||
avatar_template = extraData['user']['avatar_template']
|
||||
|
||||
if avatar_template and user.avatar_template != avatar_template:
|
||||
user.avatar_template = avatar_template
|
||||
user.save()
|
||||
|
||||
return
|
||||
46
RIGS/discourse/sso.py
Normal file
46
RIGS/discourse/sso.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import urllib
|
||||
from hashlib import sha256
|
||||
import hmac
|
||||
from base64 import b64decode, b64encode
|
||||
|
||||
|
||||
class DiscourseSSO:
|
||||
def __init__(self, secret_key):
|
||||
self.__secret_key = secret_key
|
||||
|
||||
def validate(self, payload, sig):
|
||||
payload = urllib.unquote(payload)
|
||||
computed_sig = hmac.new(
|
||||
self.__secret_key.encode(),
|
||||
payload.encode(),
|
||||
sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(unicode(computed_sig), sig)
|
||||
|
||||
def get_nonce(self, payload):
|
||||
payload = b64decode(urllib.unquote(payload)).decode()
|
||||
d = dict(nonce.split("=") for nonce in payload.split('&'))
|
||||
|
||||
if 'nonce' in d and d['nonce'] != '':
|
||||
return d['nonce']
|
||||
else:
|
||||
raise Exception("Nonce could not be found in payload")
|
||||
|
||||
def get_data(self, payload):
|
||||
payload = urllib.unquote(b64decode(urllib.unquote(payload)).decode())
|
||||
d = dict(data.split("=") for data in payload.split('&'))
|
||||
|
||||
return d
|
||||
|
||||
def build_login_URL(self, nonce, redirect_uri):
|
||||
data = {
|
||||
'nonce': nonce,
|
||||
'return_sso_url': redirect_uri
|
||||
}
|
||||
|
||||
payload = urllib.urlencode(data)
|
||||
payload = b64encode(payload.encode())
|
||||
sig = hmac.new(self.__secret_key.encode(), payload, sha256).hexdigest()
|
||||
|
||||
return '/session/sso_provider?' + urllib.urlencode({'sso': payload, 'sig': sig})
|
||||
@@ -3,6 +3,7 @@ from django import forms
|
||||
from django.utils import formats
|
||||
from django.conf import settings
|
||||
from django.core import serializers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm, PasswordResetForm
|
||||
from registration.forms import RegistrationFormUniqueEmail
|
||||
from captcha.fields import ReCaptchaField
|
||||
@@ -27,6 +28,16 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail):
|
||||
raise forms.ValidationError("These initials are already in use. Please supply different initials.")
|
||||
return self.cleaned_data['initials']
|
||||
|
||||
def clean_first_name(self):
|
||||
if self.cleaned_data["first_name"].strip() == '':
|
||||
raise ValidationError("First name is required.")
|
||||
return self.cleaned_data["first_name"]
|
||||
|
||||
def clean_last_name(self):
|
||||
if self.cleaned_data["last_name"].strip() == '':
|
||||
raise ValidationError("Last name is required.")
|
||||
return self.cleaned_data["last_name"]
|
||||
|
||||
|
||||
# Login form
|
||||
class PasswordReset(PasswordResetForm):
|
||||
|
||||
19
RIGS/migrations/0025_profile_avatar_template.py
Normal file
19
RIGS/migrations/0025_profile_avatar_template.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- 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.AddField(
|
||||
model_name='profile',
|
||||
name='avatar_template',
|
||||
field=models.CharField(max_length=255, null=True, editable=False, blank=True),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,7 @@ class Profile(AbstractUser):
|
||||
initials = models.CharField(max_length=5, unique=True, null=True, blank=False)
|
||||
phone = models.CharField(max_length=13, null=True, blank=True)
|
||||
api_key = models.CharField(max_length=40, blank=True, editable=False, null=True)
|
||||
avatar_template = models.CharField(max_length=255, blank=True, editable=False, null=True)
|
||||
|
||||
@classmethod
|
||||
def make_api_key(cls):
|
||||
@@ -33,8 +34,13 @@ class Profile(AbstractUser):
|
||||
@property
|
||||
def profile_picture(self):
|
||||
url = ""
|
||||
if settings.DISCOURSE_API_KEY is not None:
|
||||
if self.avatar_template:
|
||||
return settings.DISCOURSE_HOST+self.avatar_template.format(size=500)
|
||||
|
||||
if settings.USE_GRAVATAR or settings.USE_GRAVATAR is None:
|
||||
url = "https://www.gravatar.com/avatar/" + hashlib.md5(self.email).hexdigest() + "?d=wavatar&s=500"
|
||||
|
||||
return url
|
||||
|
||||
@property
|
||||
|
||||
@@ -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
|
||||
@@ -59,7 +83,7 @@ class EventCreate(generic.CreateView):
|
||||
form = context['form']
|
||||
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():
|
||||
@@ -97,11 +121,12 @@ 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
|
||||
|
||||
messages.info(self.request, 'Event data duplicated but not yet saved. Click save to complete operation.')
|
||||
else:
|
||||
messages.info(self.request, 'Event data duplicated but not yet saved. Click save to complete operation.')
|
||||
|
||||
return new
|
||||
|
||||
@@ -192,4 +217,4 @@ class EventArchive(generic.ArchiveIndexView):
|
||||
if len(qs) == 0:
|
||||
messages.add_message(self.request, messages.WARNING, "No events have been found matching those criteria.")
|
||||
|
||||
return qs
|
||||
return qs
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
RIGS/static/imgs/forum-logo.gif
Normal file
BIN
RIGS/static/imgs/forum-logo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
106
RIGS/templates/RIGS/event_embed.html
Normal file
106
RIGS/templates/RIGS/event_embed.html
Normal 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 %}
|
||||
–
|
||||
{% 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 %}
|
||||
@@ -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>
|
||||
@@ -75,4 +77,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -133,6 +133,20 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h4>Linked to {{object.social_auth.count}} Forum Account(s)</h4>
|
||||
|
||||
{% if object.social_auth.count > 0 %}
|
||||
|
||||
<a href="{% url 'unlink_forum' %}" class="btn btn-danger">
|
||||
Unlink Forum Account(s) <span class="glyphicon glyphicon-pencil"></span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url "social:begin" "discourse" %}" class="btn btn-success">
|
||||
Link Forum Account <span class="glyphicon glyphicon-pencil"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
55
RIGS/templates/RIGS/social-associate.html
Normal file
55
RIGS/templates/RIGS/social-associate.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load widget_tweaks %}
|
||||
{% block title %}Associate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-sm-10 col-sm-offset-1">
|
||||
|
||||
<div class="text-center">
|
||||
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
|
||||
</div>
|
||||
<h2 class="text-center">Welcome <strong>{{details.username}}</strong></h2>
|
||||
<h4 class="text-center">This is the first time you've visited RIGS with your forum account, so we need a few details to get you set up</h4>
|
||||
<hr/>
|
||||
|
||||
|
||||
{% if alreadyLoggedIn %}
|
||||
<h2 class="text-center">You are logged in to RIGS as <strong>{{loggedInUser.username}}</strong></h2>
|
||||
<div class="col-sm-8 col-sm-offset-2">
|
||||
<form action="", method="post">{% csrf_token %}
|
||||
<input type="hidden" name="UseCurrentAccount" value="1"/>
|
||||
<button type="submit" class="btn btn-lg btn-primary center btn-block">Link Forum account to RIGS Account</button>
|
||||
</form>
|
||||
<a class="btn btn-lg btn-warning center btn-block" href="{% url 'logout' %}" role="button">Logout</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<h4 class="text-center"><a class="btn btn-info" href="{{login_url}}" role="button">I already have a RIGS account</a></h4>
|
||||
{% if form.errors or supplement_form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{{form.errors}}
|
||||
{{supplement_form.errors}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-sm-8 col-sm-offset-2">
|
||||
<form action="" method="post" class="form-horizontal" role="form">{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div class="form-group">
|
||||
<label for="{{ field.id_for_label }}" class="control-label col-sm-4">{{ field.label }}</label>
|
||||
<div class="controls col-sm-8">
|
||||
{% render_field field class+="form-control" placeholder=field.label %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p><input type="submit" value="Register" class="btn btn-primary pull-right"></p>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -16,136 +16,136 @@ from selenium.webdriver.support.ui import WebDriverWait
|
||||
from RIGS import models
|
||||
|
||||
|
||||
class UserRegistrationTest(LiveServerTestCase):
|
||||
# class UserRegistrationTest(LiveServerTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.browser = webdriver.Firefox()
|
||||
self.browser.implicitly_wait(3) # Set implicit wait session wide
|
||||
os.environ['RECAPTCHA_TESTING'] = 'True'
|
||||
# def setUp(self):
|
||||
# self.browser = webdriver.Firefox()
|
||||
# self.browser.implicitly_wait(3) # Set implicit wait session wide
|
||||
# os.environ['RECAPTCHA_TESTING'] = 'True'
|
||||
|
||||
def tearDown(self):
|
||||
self.browser.quit()
|
||||
os.environ['RECAPTCHA_TESTING'] = 'False'
|
||||
# def tearDown(self):
|
||||
# self.browser.quit()
|
||||
# os.environ['RECAPTCHA_TESTING'] = 'False'
|
||||
|
||||
def test_registration(self):
|
||||
# Navigate to the registration page
|
||||
self.browser.get(self.live_server_url + '/user/register/')
|
||||
title_text = self.browser.find_element_by_tag_name('h3').text
|
||||
self.assertIn("User Registration", title_text)
|
||||
# def test_registration(self):
|
||||
# # Navigate to the registration page
|
||||
# self.browser.get(self.live_server_url + '/user/register/')
|
||||
# title_text = self.browser.find_element_by_tag_name('h3').text
|
||||
# self.assertIn("User Registration", title_text)
|
||||
|
||||
# Check the form invites correctly
|
||||
username = self.browser.find_element_by_id('id_username')
|
||||
self.assertEqual(username.get_attribute('placeholder'), 'Username')
|
||||
email = self.browser.find_element_by_id('id_email')
|
||||
self.assertEqual(email.get_attribute('placeholder'), 'E-mail')
|
||||
# If this is correct we don't need to test it later
|
||||
self.assertEqual(email.get_attribute('type'), 'email')
|
||||
password1 = self.browser.find_element_by_id('id_password1')
|
||||
self.assertEqual(password1.get_attribute('placeholder'), 'Password')
|
||||
self.assertEqual(password1.get_attribute('type'), 'password')
|
||||
password2 = self.browser.find_element_by_id('id_password2')
|
||||
self.assertEqual(
|
||||
password2.get_attribute('placeholder'), 'Password confirmation')
|
||||
self.assertEqual(password2.get_attribute('type'), 'password')
|
||||
first_name = self.browser.find_element_by_id('id_first_name')
|
||||
self.assertEqual(first_name.get_attribute('placeholder'), 'First name')
|
||||
last_name = self.browser.find_element_by_id('id_last_name')
|
||||
self.assertEqual(last_name.get_attribute('placeholder'), 'Last name')
|
||||
initials = self.browser.find_element_by_id('id_initials')
|
||||
self.assertEqual(initials.get_attribute('placeholder'), 'Initials')
|
||||
phone = self.browser.find_element_by_id('id_phone')
|
||||
self.assertEqual(phone.get_attribute('placeholder'), 'Phone')
|
||||
# # Check the form invites correctly
|
||||
# username = self.browser.find_element_by_id('id_username')
|
||||
# self.assertEqual(username.get_attribute('placeholder'), 'Username')
|
||||
# email = self.browser.find_element_by_id('id_email')
|
||||
# self.assertEqual(email.get_attribute('placeholder'), 'E-mail')
|
||||
# # If this is correct we don't need to test it later
|
||||
# self.assertEqual(email.get_attribute('type'), 'email')
|
||||
# password1 = self.browser.find_element_by_id('id_password1')
|
||||
# self.assertEqual(password1.get_attribute('placeholder'), 'Password')
|
||||
# self.assertEqual(password1.get_attribute('type'), 'password')
|
||||
# password2 = self.browser.find_element_by_id('id_password2')
|
||||
# self.assertEqual(
|
||||
# password2.get_attribute('placeholder'), 'Password confirmation')
|
||||
# self.assertEqual(password2.get_attribute('type'), 'password')
|
||||
# first_name = self.browser.find_element_by_id('id_first_name')
|
||||
# self.assertEqual(first_name.get_attribute('placeholder'), 'First name')
|
||||
# last_name = self.browser.find_element_by_id('id_last_name')
|
||||
# self.assertEqual(last_name.get_attribute('placeholder'), 'Last name')
|
||||
# initials = self.browser.find_element_by_id('id_initials')
|
||||
# self.assertEqual(initials.get_attribute('placeholder'), 'Initials')
|
||||
# phone = self.browser.find_element_by_id('id_phone')
|
||||
# self.assertEqual(phone.get_attribute('placeholder'), 'Phone')
|
||||
|
||||
# Fill the form out incorrectly
|
||||
username.send_keys('TestUsername')
|
||||
email.send_keys('test@example.com')
|
||||
password1.send_keys('correcthorsebatterystaple')
|
||||
# deliberate mistake
|
||||
password2.send_keys('correcthorsebatterystapleerror')
|
||||
first_name.send_keys('John')
|
||||
last_name.send_keys('Smith')
|
||||
initials.send_keys('JS')
|
||||
phone.send_keys('0123456789')
|
||||
self.browser.execute_script(
|
||||
"return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
# # Fill the form out incorrectly
|
||||
# username.send_keys('TestUsername')
|
||||
# email.send_keys('test@example.com')
|
||||
# password1.send_keys('correcthorsebatterystaple')
|
||||
# # deliberate mistake
|
||||
# password2.send_keys('correcthorsebatterystapleerror')
|
||||
# first_name.send_keys('John')
|
||||
# last_name.send_keys('Smith')
|
||||
# initials.send_keys('JS')
|
||||
# phone.send_keys('0123456789')
|
||||
# self.browser.execute_script(
|
||||
# "return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
|
||||
# Submit incorrect form
|
||||
submit = self.browser.find_element_by_xpath("//input[@type='submit']")
|
||||
submit.click()
|
||||
# # Submit incorrect form
|
||||
# submit = self.browser.find_element_by_xpath("//input[@type='submit']")
|
||||
# submit.click()
|
||||
|
||||
# Restablish error fields
|
||||
password1 = self.browser.find_element_by_id('id_password1')
|
||||
password2 = self.browser.find_element_by_id('id_password2')
|
||||
# # Restablish error fields
|
||||
# password1 = self.browser.find_element_by_id('id_password1')
|
||||
# password2 = self.browser.find_element_by_id('id_password2')
|
||||
|
||||
# Read what the error is
|
||||
alert = self.browser.find_element_by_css_selector(
|
||||
'div.alert-danger').text
|
||||
self.assertIn("password fields didn't match", alert)
|
||||
# # Read what the error is
|
||||
# alert = self.browser.find_element_by_css_selector(
|
||||
# 'div.alert-danger').text
|
||||
# self.assertIn("password fields didn't match", alert)
|
||||
|
||||
# Passwords should be empty
|
||||
self.assertEqual(password1.get_attribute('value'), '')
|
||||
self.assertEqual(password2.get_attribute('value'), '')
|
||||
# # Passwords should be empty
|
||||
# self.assertEqual(password1.get_attribute('value'), '')
|
||||
# self.assertEqual(password2.get_attribute('value'), '')
|
||||
|
||||
# Correct error
|
||||
password1.send_keys('correcthorsebatterystaple')
|
||||
password2.send_keys('correcthorsebatterystaple')
|
||||
self.browser.execute_script(
|
||||
"return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
# # Correct error
|
||||
# password1.send_keys('correcthorsebatterystaple')
|
||||
# password2.send_keys('correcthorsebatterystaple')
|
||||
# self.browser.execute_script(
|
||||
# "return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
|
||||
# Submit again
|
||||
password2.send_keys(Keys.ENTER)
|
||||
# # Submit again
|
||||
# password2.send_keys(Keys.ENTER)
|
||||
|
||||
# Check we have a success message
|
||||
alert = self.browser.find_element_by_css_selector(
|
||||
'div.alert-success').text
|
||||
self.assertIn('register', alert)
|
||||
self.assertIn('email', alert)
|
||||
# # Check we have a success message
|
||||
# alert = self.browser.find_element_by_css_selector(
|
||||
# 'div.alert-success').text
|
||||
# self.assertIn('register', alert)
|
||||
# self.assertIn('email', alert)
|
||||
|
||||
# Check Email
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
email = mail.outbox[0]
|
||||
self.assertIn('John Smith "JS" activation required', email.subject)
|
||||
urls = re.findall(
|
||||
'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', email.body)
|
||||
self.assertEqual(len(urls), 1)
|
||||
# # Check Email
|
||||
# self.assertEqual(len(mail.outbox), 1)
|
||||
# email = mail.outbox[0]
|
||||
# self.assertIn('John Smith "JS" activation required', email.subject)
|
||||
# urls = re.findall(
|
||||
# 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', email.body)
|
||||
# self.assertEqual(len(urls), 1)
|
||||
|
||||
mail.outbox = [] # empty this for later
|
||||
# mail.outbox = [] # empty this for later
|
||||
|
||||
# Follow link
|
||||
self.browser.get(urls[0]) # go to the first link
|
||||
# # Follow link
|
||||
# self.browser.get(urls[0]) # go to the first link
|
||||
|
||||
# Complete registration
|
||||
title_text = self.browser.find_element_by_tag_name('h2').text
|
||||
self.assertIn('Complete', title_text)
|
||||
# # Complete registration
|
||||
# title_text = self.browser.find_element_by_tag_name('h2').text
|
||||
# self.assertIn('Complete', title_text)
|
||||
|
||||
# Test login
|
||||
self.browser.get(self.live_server_url + '/user/login')
|
||||
username = self.browser.find_element_by_id('id_username')
|
||||
self.assertEqual(username.get_attribute('placeholder'), 'Username')
|
||||
password = self.browser.find_element_by_id('id_password')
|
||||
self.assertEqual(password.get_attribute('placeholder'), 'Password')
|
||||
self.assertEqual(password.get_attribute('type'), 'password')
|
||||
# # Test login
|
||||
# self.browser.get(self.live_server_url + '/user/login')
|
||||
# username = self.browser.find_element_by_id('id_username')
|
||||
# self.assertEqual(username.get_attribute('placeholder'), 'Username')
|
||||
# password = self.browser.find_element_by_id('id_password')
|
||||
# self.assertEqual(password.get_attribute('placeholder'), 'Password')
|
||||
# self.assertEqual(password.get_attribute('type'), 'password')
|
||||
|
||||
username.send_keys('TestUsername')
|
||||
password.send_keys('correcthorsebatterystaple')
|
||||
self.browser.execute_script(
|
||||
"return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
password.send_keys(Keys.ENTER)
|
||||
# username.send_keys('TestUsername')
|
||||
# password.send_keys('correcthorsebatterystaple')
|
||||
# self.browser.execute_script(
|
||||
# "return jQuery('#g-recaptcha-response').val('PASSED')")
|
||||
# password.send_keys(Keys.ENTER)
|
||||
|
||||
# Check we are logged in
|
||||
udd = self.browser.find_element_by_class_name('navbar').text
|
||||
self.assertIn('Hi John', udd)
|
||||
# # Check we are logged in
|
||||
# udd = self.browser.find_element_by_class_name('navbar').text
|
||||
# self.assertIn('Hi John', udd)
|
||||
|
||||
# Check all the data actually got saved
|
||||
profileObject = models.Profile.objects.all()[0]
|
||||
self.assertEqual(profileObject.username, 'TestUsername')
|
||||
self.assertEqual(profileObject.first_name, 'John')
|
||||
self.assertEqual(profileObject.last_name, 'Smith')
|
||||
self.assertEqual(profileObject.initials, 'JS')
|
||||
self.assertEqual(profileObject.phone, '0123456789')
|
||||
self.assertEqual(profileObject.email, 'test@example.com')
|
||||
# # Check all the data actually got saved
|
||||
# profileObject = models.Profile.objects.all()[0]
|
||||
# self.assertEqual(profileObject.username, 'TestUsername')
|
||||
# self.assertEqual(profileObject.first_name, 'John')
|
||||
# self.assertEqual(profileObject.last_name, 'Smith')
|
||||
# self.assertEqual(profileObject.initials, 'JS')
|
||||
# self.assertEqual(profileObject.phone, '0123456789')
|
||||
# self.assertEqual(profileObject.email, 'test@example.com')
|
||||
|
||||
# All is well
|
||||
# # All is well
|
||||
|
||||
|
||||
class EventTest(LiveServerTestCase):
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
34
RIGS/urls.py
34
RIGS/urls.py
@@ -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,7 +15,8 @@ urlpatterns = patterns('',
|
||||
url(r'^closemodal/$', views.CloseModal.as_view(), name='closemodal'),
|
||||
|
||||
url('^user/login/$', 'RIGS.views.login', name='login'),
|
||||
url(r'^user/password_reset/$', 'django.contrib.auth.views.password_reset', {'password_reset_form':forms.PasswordReset}),
|
||||
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
|
||||
url(r'^people/$', permission_required_with_403('RIGS.view_person')(views.PersonList.as_view()),
|
||||
@@ -71,7 +73,7 @@ urlpatterns = patterns('',
|
||||
url(r'^rigboard/calendar/$', login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'),
|
||||
url(r'^rigboard/calendar/(?P<view>(month|week|day))/$', login_required()(rigboard.WebCalendar.as_view()), name='web_calendar'),
|
||||
url(r'^rigboard/calendar/(?P<view>(month|week|day))/(?P<date>(\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/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()),
|
||||
name='activity_table'),
|
||||
@@ -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'),
|
||||
@@ -101,7 +109,7 @@ urlpatterns = patterns('',
|
||||
permission_required_with_403('RIGS.view_event')(versioning.VersionHistory.as_view()),
|
||||
name='event_history', kwargs={'model': models.Event}),
|
||||
|
||||
|
||||
|
||||
|
||||
# Finance
|
||||
url(r'^invoice/$',
|
||||
@@ -140,11 +148,12 @@ urlpatterns = patterns('',
|
||||
# User editing
|
||||
url(r'^user/$', login_required(views.ProfileDetail.as_view()), name='profile_detail'),
|
||||
url(r'^user/(?P<pk>\d+)/$',
|
||||
permission_required_with_403('RIGS.view_profile')(views.ProfileDetail.as_view()),
|
||||
name='profile_detail'),
|
||||
permission_required_with_403('RIGS.view_profile')(views.ProfileDetail.as_view()),
|
||||
name='profile_detail'),
|
||||
url(r'^user/edit/$', login_required(views.ProfileUpdateSelf.as_view()),
|
||||
name='profile_update_self'),
|
||||
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/unlink_forum$', login_required(views.UnlinkForum.as_view(permanent=False)), name='unlink_forum'),
|
||||
|
||||
# ICS Calendar - API key authentication
|
||||
url(r'^ical/(?P<api_pk>\d+)/(?P<api_key>\w+)/rigs.ics$', api_key_required(ical.CalendarICS()), name="ics_calendar"),
|
||||
@@ -154,8 +163,7 @@ urlpatterns = patterns('',
|
||||
url(r'^api/(?P<model>\w+)/(?P<pk>\d+)/$', login_required(views.SecureAPIRequest.as_view()), name="api_secure"),
|
||||
|
||||
# Legacy URL's
|
||||
url(r'^rig/show/(?P<pk>\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')),
|
||||
)
|
||||
|
||||
url(r'^rig/show/(?P<pk>\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')),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -355,3 +382,12 @@ class ResetApiKey(generic.RedirectView):
|
||||
self.request.user.save()
|
||||
|
||||
return reverse_lazy('profile_detail')
|
||||
|
||||
class UnlinkForum(generic.RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
for link in self.request.user.social_auth.all():
|
||||
link.delete()
|
||||
|
||||
self.request.user.save()
|
||||
|
||||
return reverse_lazy('profile_detail')
|
||||
|
||||
@@ -12,17 +12,23 @@ django-widget-tweaks==1.3
|
||||
gunicorn==19.3.0
|
||||
icalendar==3.9.0
|
||||
lxml==3.4.4
|
||||
oauthlib==2.0.0
|
||||
Pillow==2.8.1
|
||||
psycopg2==2.6
|
||||
Pygments==2.0.2
|
||||
PyJWT==1.4.2
|
||||
PyPDF2==1.24
|
||||
python-dateutil==2.4.2
|
||||
python-openid==2.2.5
|
||||
python-social-auth==0.2.21
|
||||
pytz==2015.4
|
||||
raven==5.8.1
|
||||
reportlab==3.1.44
|
||||
requests==2.11.1
|
||||
requests-oauthlib==0.7.0
|
||||
selenium==2.53.6
|
||||
simplejson==3.7.2
|
||||
six==1.9.0
|
||||
six==1.10.0
|
||||
sqlparse==0.1.15
|
||||
static3==0.6.1
|
||||
svg2rlg==0.3
|
||||
|
||||
49
templates/base_embed.html
Normal file
49
templates/base_embed.html
Normal 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">×</span></button>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block js %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
24
templates/login_redirect.html
Normal file
24
templates/login_redirect.html
Normal 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 %}
|
||||
@@ -1,7 +1,49 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static from staticfiles %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'registration/loginform.html' %}
|
||||
<div class="text-center">
|
||||
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="panel-group">
|
||||
{% url "social:complete" "discourse" as completeUrl %}
|
||||
{% if not request.GET.next == completeUrl %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
|
||||
Login with TEC Forum
|
||||
|
||||
</h4>
|
||||
</div>
|
||||
<div id="forumLogin">
|
||||
<div class="panel-body" style="text-align:center;">
|
||||
<a class="btn btn-default" href="{% url "social:begin" "discourse" %}?next={{request.GET.next}}">
|
||||
|
||||
<h4>Login using</h4>
|
||||
<img src="{% static "imgs/forum-logo.gif" %}" width=200></img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
Login with RIGS Credentials
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="panel-body">
|
||||
{% include 'registration/loginform.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
34
templates/registration/login_embed.html
Normal file
34
templates/registration/login_embed.html
Normal 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 %}
|
||||
@@ -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>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user