mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-02-23 14:58:23 +00:00
Merge branch 'master' into assets_cabletypes
This commit is contained in:
@@ -12,14 +12,14 @@ install:
|
||||
- export PATH=$PATH:$(pwd)
|
||||
- chmod +x chromedriver
|
||||
- pip install -r requirements.txt
|
||||
- pip install coveralls codeclimate-test-reporter pep8
|
||||
- pip install coveralls codeclimate-test-reporter pycodestyle
|
||||
|
||||
before_script:
|
||||
- export PATH=$PATH:/usr/lib/chromium-browser/
|
||||
- python manage.py collectstatic --noinput
|
||||
|
||||
script:
|
||||
- pep8 . --exclude=migrations,importer*
|
||||
- pycodestyle . --exclude=migrations,importer*
|
||||
- python manage.py check
|
||||
- python manage.py makemigrations --check --dry-run
|
||||
- coverage run manage.py test --verbosity=2
|
||||
|
||||
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/1.7/ref/settings/
|
||||
import os
|
||||
import raven
|
||||
import secrets
|
||||
import datetime
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
@@ -44,12 +45,11 @@ if not DEBUG:
|
||||
|
||||
INTERNAL_IPS = ['127.0.0.1']
|
||||
|
||||
ADMINS = (
|
||||
('Tom Price', 'tomtom5152@gmail.com')
|
||||
)
|
||||
ADMINS = [('Tom Price', 'tomtom5152@gmail.com'), ('IT Manager', 'it@nottinghamtec.co.uk'), ('Arona Jones', 'arona.jones@nottinghamtec.co.uk')]
|
||||
if DEBUG:
|
||||
ADMINS.append(('Testing Superuser', 'superuser@example.com'))
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = (
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
@@ -168,6 +168,8 @@ RECAPTCHA_PUBLIC_KEY = os.environ.get('RECAPTCHA_PUBLIC_KEY', "6LeIxAcTAAAAAJcZV
|
||||
RECAPTCHA_PRIVATE_KEY = os.environ.get('RECAPTCHA_PRIVATE_KEY', "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe") # If not set, use development key
|
||||
NOCAPTCHA = True
|
||||
|
||||
SILENCED_SYSTEM_CHECKS = ['captcha.recaptcha_test_key_error']
|
||||
|
||||
# Email
|
||||
EMAILER_TEST = False
|
||||
if not DEBUG or EMAILER_TEST:
|
||||
@@ -182,6 +184,8 @@ if not DEBUG or EMAILER_TEST:
|
||||
else:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
EMAIL_COOLDOWN = datetime.timedelta(minutes=15)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.7/topics/i18n/
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ def create_browser():
|
||||
if os.environ.get('CI', False):
|
||||
options.add_argument("--headless")
|
||||
options.add_argument("--no-sandbox")
|
||||
driver = webdriver.Chrome(chrome_options=options)
|
||||
driver = webdriver.Chrome(options=options)
|
||||
return driver
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.urls import path
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
@@ -15,8 +16,8 @@ urlpatterns = [
|
||||
url('^assets/', include('assets.urls')),
|
||||
url('^user/register/$', RegistrationView.as_view(form_class=RIGS.forms.ProfileRegistrationFormUniqueEmail),
|
||||
name="registration_register"),
|
||||
url('^user/', include('django.contrib.auth.urls')),
|
||||
url('^user/', include('registration.backends.default.urls')),
|
||||
path('user/', include('django.contrib.auth.urls')),
|
||||
path('user/', include('registration.backends.default.urls')),
|
||||
|
||||
url(r'^admin/', admin.site.urls),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from RIGS import models, forms
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from reversion.admin import VersionAdmin
|
||||
|
||||
from django.contrib.admin import helpers
|
||||
@@ -22,13 +22,22 @@ admin.site.register(models.Invoice)
|
||||
admin.site.register(models.Payment)
|
||||
|
||||
|
||||
def approve_user(modeladmin, request, queryset):
|
||||
queryset.update(is_approved=True)
|
||||
|
||||
|
||||
approve_user.short_description = "Approve selected users"
|
||||
|
||||
|
||||
@admin.register(models.Profile)
|
||||
class ProfileAdmin(UserAdmin):
|
||||
# Don't know how to add 'is_approved' whilst preserving the default list...
|
||||
list_filter = ('is_approved', 'is_active', 'is_staff', 'is_superuser', 'groups')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
(_('Personal info'), {
|
||||
'fields': ('first_name', 'last_name', 'email', 'initials', 'phone')}),
|
||||
(_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser',
|
||||
(_('Permissions'), {'fields': ('is_approved', 'is_active', 'is_staff', 'is_superuser',
|
||||
'groups', 'user_permissions')}),
|
||||
(_('Important dates'), {
|
||||
'fields': ('last_login', 'date_joined')}),
|
||||
@@ -41,6 +50,7 @@ class ProfileAdmin(UserAdmin):
|
||||
)
|
||||
form = forms.ProfileChangeForm
|
||||
add_form = forms.ProfileCreationForm
|
||||
actions = [approve_user]
|
||||
|
||||
|
||||
class AssociateAdmin(VersionAdmin):
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.template.loader import get_template
|
||||
from django.views import generic
|
||||
from django.db.models import Q
|
||||
from z3c.rml import rml2pdf
|
||||
from django.db.models import Q
|
||||
|
||||
from RIGS import models
|
||||
|
||||
@@ -76,7 +77,7 @@ class InvoicePrint(generic.View):
|
||||
|
||||
pdfData = buffer.read()
|
||||
|
||||
escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', object.name)
|
||||
escapedEventName = re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name)
|
||||
|
||||
response = HttpResponse(content_type='application/pdf')
|
||||
response['Content-Disposition'] = "filename=Invoice %05d - N%05d | %s.pdf" % (invoice.pk, invoice.event.pk, escapedEventName)
|
||||
@@ -122,6 +123,34 @@ class InvoiceArchive(generic.ListView):
|
||||
template_name = 'RIGS/invoice_list_archive.html'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
q = self.request.GET.get('q', "")
|
||||
|
||||
filter = Q(event__name__icontains=q)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(q)
|
||||
filter = filter | Q(pk=val)
|
||||
filter = filter | Q(event__pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
try:
|
||||
if q[0] == "N":
|
||||
val = int(q[1:])
|
||||
filter = Q(event__pk=val) # If string is Nxxxxx then filter by event number
|
||||
elif q[0] == "#":
|
||||
val = int(q[1:])
|
||||
filter = Q(pk=val) # If string is #xxxxx then filter by invoice number
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
object_list = self.model.objects.filter(filter).order_by('-invoice_date')
|
||||
|
||||
return object_list
|
||||
|
||||
|
||||
class InvoiceWaiting(generic.ListView):
|
||||
model = models.Event
|
||||
|
||||
@@ -2,8 +2,10 @@ from django import forms
|
||||
from django.utils import formats
|
||||
from django.conf import settings
|
||||
from django.core import serializers
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm, PasswordResetForm
|
||||
from registration.forms import RegistrationFormUniqueEmail
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from captcha.fields import ReCaptchaField
|
||||
import simplejson
|
||||
|
||||
@@ -33,8 +35,16 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail):
|
||||
return self.cleaned_data['initials']
|
||||
|
||||
|
||||
class CheckApprovedForm(AuthenticationForm):
|
||||
def confirm_login_allowed(self, user):
|
||||
if user.is_approved or user.is_superuser:
|
||||
return AuthenticationForm.confirm_login_allowed(self, user)
|
||||
else:
|
||||
raise forms.ValidationError("Your account hasn't been approved by an administrator yet. Please check back in a few minutes!")
|
||||
|
||||
|
||||
# Embedded Login form - remove the autofocus
|
||||
class EmbeddedAuthenticationForm(AuthenticationForm):
|
||||
class EmbeddedAuthenticationForm(CheckApprovedForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['username'].widget.attrs.pop('autofocus', None)
|
||||
|
||||
23
RIGS/migrations/0036_profile_is_approved.py
Normal file
23
RIGS/migrations/0036_profile_is_approved.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.0.13 on 2020-01-10 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('RIGS', '0035_auto_20191124_1319'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_approved',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='last_emailed',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
19
RIGS/migrations/0037_approve_legacy.py
Normal file
19
RIGS/migrations/0037_approve_legacy.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.13 on 2020-01-11 18:29
|
||||
# This migration ensures that legacy Profiles from before approvals were implemented are automatically approved
|
||||
from django.db import migrations
|
||||
|
||||
def approve_legacy(apps, schema_editor):
|
||||
Profile = apps.get_model('RIGS', 'Profile')
|
||||
for person in Profile.objects.all():
|
||||
person.is_approved = True
|
||||
person.save()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('RIGS', '0036_profile_is_approved'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(approve_legacy)
|
||||
]
|
||||
37
RIGS/migrations/0038_auto_20200306_2000.py
Normal file
37
RIGS/migrations/0038_auto_20200306_2000.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 2.0.13 on 2020-03-06 20:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('RIGS', '0037_approve_legacy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='event',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='invoice',
|
||||
options={'ordering': ['-invoice_date']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='organisation',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='person',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='profile',
|
||||
options={'verbose_name': 'user', 'verbose_name_plural': 'users'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='venue',
|
||||
options={},
|
||||
),
|
||||
]
|
||||
@@ -8,7 +8,6 @@ from django.contrib.auth.models import AbstractUser
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from reversion import revisions as reversion
|
||||
from reversion.models import Version
|
||||
import string
|
||||
@@ -22,11 +21,12 @@ from django.urls import reverse_lazy
|
||||
|
||||
|
||||
# Create your models here.
|
||||
@python_2_unicode_compatible
|
||||
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)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
last_emailed = models.DateTimeField(blank=True, null=True) # Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that...
|
||||
|
||||
@classmethod
|
||||
def make_api_key(cls):
|
||||
@@ -53,14 +53,17 @@ class Profile(AbstractUser):
|
||||
def latest_events(self):
|
||||
return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
@classmethod
|
||||
def admins(cls):
|
||||
return Profile.objects.filter(email__in=[y for x in settings.ADMINS for y in x])
|
||||
|
||||
@classmethod
|
||||
def users_awaiting_approval_count(cls):
|
||||
return Profile.objects.filter(models.Q(is_approved=False)).count()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_profile', 'Can view Profile'),
|
||||
)
|
||||
|
||||
|
||||
class RevisionMixin(object):
|
||||
@property
|
||||
@@ -91,7 +94,6 @@ class RevisionMixin(object):
|
||||
|
||||
|
||||
@reversion.register
|
||||
@python_2_unicode_compatible
|
||||
class Person(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, null=True)
|
||||
@@ -127,14 +129,8 @@ class Person(models.Model, RevisionMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('person_detail', kwargs={'pk': self.pk})
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_person', 'Can view Persons'),
|
||||
)
|
||||
|
||||
|
||||
@reversion.register
|
||||
@python_2_unicode_compatible
|
||||
class Organisation(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, null=True)
|
||||
@@ -171,11 +167,6 @@ class Organisation(models.Model, RevisionMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('organisation_detail', kwargs={'pk': self.pk})
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_organisation', 'Can view Organisations'),
|
||||
)
|
||||
|
||||
|
||||
class VatManager(models.Manager):
|
||||
def current_rate(self):
|
||||
@@ -192,7 +183,6 @@ class VatManager(models.Manager):
|
||||
|
||||
|
||||
@reversion.register
|
||||
@python_2_unicode_compatible
|
||||
class VatRate(models.Model, RevisionMixin):
|
||||
start_at = models.DateField()
|
||||
rate = models.DecimalField(max_digits=6, decimal_places=6)
|
||||
@@ -213,7 +203,6 @@ class VatRate(models.Model, RevisionMixin):
|
||||
|
||||
|
||||
@reversion.register
|
||||
@python_2_unicode_compatible
|
||||
class Venue(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
phone = models.CharField(max_length=15, blank=True, null=True)
|
||||
@@ -236,11 +225,6 @@ class Venue(models.Model, RevisionMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('venue_detail', kwargs={'pk': self.pk})
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_venue', 'Can view Venues'),
|
||||
)
|
||||
|
||||
|
||||
class EventManager(models.Manager):
|
||||
def current_events(self):
|
||||
@@ -287,7 +271,6 @@ class EventManager(models.Manager):
|
||||
|
||||
|
||||
@reversion.register(follow=['items'])
|
||||
@python_2_unicode_compatible
|
||||
class Event(models.Model, RevisionMixin):
|
||||
# Done to make it much nicer on the database
|
||||
PROVISIONAL = 0
|
||||
@@ -481,11 +464,6 @@ class Event(models.Model, RevisionMixin):
|
||||
self.full_clean()
|
||||
super(Event, self).save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_event', 'Can view Events'),
|
||||
)
|
||||
|
||||
|
||||
class EventItem(models.Model):
|
||||
event = models.ForeignKey('Event', related_name='items', blank=True, on_delete=models.CASCADE)
|
||||
@@ -523,7 +501,7 @@ class EventAuthorisation(models.Model, RevisionMixin):
|
||||
uni_id = models.CharField(max_length=10, blank=True, null=True, verbose_name="University ID")
|
||||
account_code = models.CharField(max_length=50, blank=True, null=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount")
|
||||
sent_by = models.ForeignKey('RIGS.Profile', on_delete=models.CASCADE)
|
||||
sent_by = models.ForeignKey('Profile', on_delete=models.CASCADE)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('event_detail', kwargs={'pk': self.event.pk})
|
||||
@@ -533,7 +511,6 @@ class EventAuthorisation(models.Model, RevisionMixin):
|
||||
return str("N%05d" % self.event.pk + ' (requested by ' + self.sent_by.initials + ')')
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Invoice(models.Model):
|
||||
event = models.OneToOneField('Event', on_delete=models.CASCADE)
|
||||
invoice_date = models.DateField(auto_now_add=True)
|
||||
@@ -566,13 +543,9 @@ class Invoice(models.Model):
|
||||
return "%i: %s (%.2f)" % (self.pk, self.event, self.balance)
|
||||
|
||||
class Meta:
|
||||
permissions = (
|
||||
('view_invoice', 'Can view Invoices'),
|
||||
)
|
||||
ordering = ['-invoice_date']
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Payment(models.Model):
|
||||
CASH = 'C'
|
||||
INTERNAL = 'I'
|
||||
|
||||
@@ -110,7 +110,7 @@ class EventCreate(generic.CreateView):
|
||||
context['currentVAT'] = models.VatRate.objects.current_rate()
|
||||
|
||||
form = context['form']
|
||||
if re.search('"-\d+"', form['items_json'].value()):
|
||||
if re.search(r'"-\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.
|
||||
@@ -206,7 +206,6 @@ class EventPrint(generic.View):
|
||||
}
|
||||
|
||||
rml = template.render(context)
|
||||
|
||||
buffer = rml2pdf.parseString(rml)
|
||||
merger.append(PdfFileReader(buffer))
|
||||
buffer.close()
|
||||
@@ -219,17 +218,25 @@ class EventPrint(generic.View):
|
||||
|
||||
response = HttpResponse(content_type='application/pdf')
|
||||
|
||||
escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', object.name)
|
||||
escapedEventName = re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name)
|
||||
|
||||
response['Content-Disposition'] = "filename=N%05d | %s.pdf" % (object.pk, escapedEventName)
|
||||
response.write(merged.getvalue())
|
||||
return response
|
||||
|
||||
|
||||
class EventArchive(generic.ArchiveIndexView):
|
||||
class EventArchive(generic.ListView):
|
||||
model = models.Event
|
||||
date_field = "start_date"
|
||||
paginate_by = 25
|
||||
template_name = "RIGS/event_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# get super context
|
||||
context = super(EventArchive, self).get_context_data(**kwargs)
|
||||
|
||||
context['start'] = self.request.GET.get('start', None)
|
||||
context['end'] = self.request.GET.get('end', datetime.date.today().strftime('%Y-%m-%d'))
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
start = self.request.GET.get('start', None)
|
||||
@@ -241,19 +248,34 @@ class EventArchive(generic.ArchiveIndexView):
|
||||
"Muppet! Check the dates, it has been fixed for you.")
|
||||
start, end = end, start # Stop the impending fail
|
||||
|
||||
filter = False
|
||||
filter = Q()
|
||||
if end != "":
|
||||
filter = Q(start_date__lte=end)
|
||||
filter &= Q(start_date__lte=end)
|
||||
if start:
|
||||
if filter:
|
||||
filter = filter & Q(start_date__gte=start)
|
||||
else:
|
||||
filter = Q(start_date__gte=start)
|
||||
filter &= Q(start_date__gte=start)
|
||||
|
||||
if filter:
|
||||
qs = self.model.objects.filter(filter).order_by('-start_date')
|
||||
else:
|
||||
qs = self.model.objects.all().order_by('-start_date')
|
||||
q = self.request.GET.get('q', "")
|
||||
|
||||
if q is not "":
|
||||
qfilter = Q(name__icontains=q) | Q(description__icontains=q) | Q(notes__icontains=q)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(q)
|
||||
qfilter = qfilter | Q(pk=val)
|
||||
except: # noqa not an integer
|
||||
pass
|
||||
|
||||
try:
|
||||
if q[0] == "N":
|
||||
val = int(q[1:])
|
||||
qfilter = Q(pk=val) # If string is N###### then do a simple PK filter
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
filter &= qfilter
|
||||
|
||||
qs = self.model.objects.filter(filter).order_by('-start_date')
|
||||
|
||||
# Preselect related for efficiency
|
||||
qs.select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
@@ -10,6 +11,9 @@ from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from django.template.loader import get_template
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from registration.signals import user_activated
|
||||
from premailer import Premailer
|
||||
from z3c.rml import rml2pdf
|
||||
|
||||
@@ -69,7 +73,7 @@ def send_eventauthorisation_success_email(instance):
|
||||
external_styles=css).transform()
|
||||
client_email.attach_alternative(html, 'text/html')
|
||||
|
||||
escapedEventName = re.sub('[^a-zA-Z0-9 \n\.]', '', instance.event.name)
|
||||
escapedEventName = re.sub(r'[^a-zA-Z0-9 \n\.]', '', instance.event.name)
|
||||
|
||||
client_email.attach('N%05d - %s - CONFIRMATION.pdf' % (instance.event.pk, escapedEventName),
|
||||
merged.getvalue(),
|
||||
@@ -102,3 +106,35 @@ def on_revision_commit(sender, instance, created, **kwargs):
|
||||
|
||||
|
||||
post_save.connect(on_revision_commit, sender=models.EventAuthorisation)
|
||||
|
||||
|
||||
def send_admin_awaiting_approval_email(user, request, **kwargs):
|
||||
# Bit more controlled than just emailing all superusers
|
||||
for admin in models.Profile.admins():
|
||||
# Check we've ever emailed them before and if so, if cooldown has passed.
|
||||
if admin.last_emailed is None or admin.last_emailed + settings.EMAIL_COOLDOWN <= timezone.now():
|
||||
context = {
|
||||
'request': request,
|
||||
'link_suffix': reverse("admin:RIGS_profile_changelist") + '?is_approved__exact=0',
|
||||
'number_of_users': models.Profile.users_awaiting_approval_count(),
|
||||
'to_name': admin.first_name
|
||||
}
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
"%s new users awaiting approval on RIGS" % (context['number_of_users']),
|
||||
get_template("RIGS/admin_awaiting_approval.txt").render(context),
|
||||
to=[admin.email],
|
||||
reply_to=[user.email],
|
||||
)
|
||||
css = staticfiles_storage.path('css/email.css')
|
||||
html = Premailer(get_template("RIGS/admin_awaiting_approval.html").render(context),
|
||||
external_styles=css).transform()
|
||||
email.attach_alternative(html, 'text/html')
|
||||
email.send()
|
||||
|
||||
# Update last sent
|
||||
admin.last_emailed = timezone.now()
|
||||
admin.save()
|
||||
|
||||
|
||||
user_activated.connect(send_admin_awaiting_approval_email)
|
||||
|
||||
9
RIGS/templates/RIGS/admin_awaiting_approval.html
Normal file
9
RIGS/templates/RIGS/admin_awaiting_approval.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends 'base_client_email.html' %}
|
||||
|
||||
{% block content %}
|
||||
<p>Hi {{ to_name|default_if_none:"Administrator" }},</p>
|
||||
|
||||
<p>{{ number_of_users|default_if_none:"Some" }} new users are awaiting administrator approval on RIGS. Click <a href="{{ request.scheme }}://{{ request.get_host }}{{ link_suffix }}">here</a> to approve them.</p>
|
||||
|
||||
<p>TEC PA & Lighting</p>
|
||||
{% endblock %}
|
||||
5
RIGS/templates/RIGS/admin_awaiting_approval.txt
Normal file
5
RIGS/templates/RIGS/admin_awaiting_approval.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Hi {{ to_name|default_if_none:"Administrator" }},
|
||||
|
||||
{{ number_of_users|default_if_none:"Some" }} new users are awaiting administrator approval on RIGS. Use this link to approve them: {{ request.scheme }}://{{ request.get_host }}/{{ link_suffix }}
|
||||
|
||||
TEC PA & Lighting
|
||||
@@ -5,34 +5,49 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<h2>Event Archive</h2>
|
||||
|
||||
<div class="col-sm-12 col-md-6 pagination">
|
||||
<div class="col-sm-12">
|
||||
<h2>Event Archive</h2>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<form class="form-inline">
|
||||
<div class="form-group">
|
||||
<label for="start">Start</label>
|
||||
<input type="date" name="start" id="start" value="{{ request.GET.start }}" placeholder="Start" class="form-control" />
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">Start</div>
|
||||
<input type="date" name="start" id="start" value="{{ start|default_if_none:"" }}" placeholder="Start" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end">End</label>
|
||||
<input type="date" name="end" id="end" value="{% if request.GET.end %}{{ request.GET.end }}{% else %}{% now "Y-m-d" %}{% endif %}" placeholder="End" class="form-control" />
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">End</div>
|
||||
<input type="date" name="end" id="end" value="{{ end|default_if_none:"" }}" placeholder="End" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" class="btn btn-primary" />
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">Keyword</div>
|
||||
<input type="search" name="q" placeholder="Keyword" value="{{ request.GET.q }}"
|
||||
class="form-control"/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="submit" class="btn btn-primary" value="Search"/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="col-md-6 text-right">
|
||||
{% paginator %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-12">
|
||||
{% if is_paginated %}
|
||||
<div class="pull-right">
|
||||
{% paginator %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
{% with latest as events %}
|
||||
{% include 'RIGS/event_table.html' %}
|
||||
{% endwith %}
|
||||
<div class="col-sm-12">
|
||||
{% with object_list as events %}
|
||||
{% include 'RIGS/event_table.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
| {{ object.name }} {% if event.dry_hire %}<span class="badge">Dry Hire</span>{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<div class="col-sm-12 text-right">
|
||||
{% include 'RIGS/event_detail_buttons.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% if object.is_rig %}
|
||||
{% if object.is_rig and perms.RIGS.view_event %}
|
||||
{# only need contact details for a rig #}
|
||||
<div class="col-sm-12 col-md-6 col-lg-5">
|
||||
<div class="panel panel-default">
|
||||
@@ -72,7 +74,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-12 {% if event.is_rig %}col-md-6 col-lg-7{% endif %}">
|
||||
<div class="col-sm-12 {% if event.is_rig and perms.RIGS.view_event %}col-md-6 col-lg-7{% endif %}">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">Event Info</div>
|
||||
<div class="panel-body">
|
||||
@@ -147,7 +149,7 @@
|
||||
<dd>{{ object.collector }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.is_rig and not event.internal %}
|
||||
{% if event.is_rig and not event.internal and perms.RIGS.view_event %}
|
||||
<dd> </dd>
|
||||
<dt>PO</dt>
|
||||
<dd>{{ object.purchase_order }}</dd>
|
||||
@@ -156,7 +158,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if event.is_rig and event.internal %}
|
||||
{% if event.is_rig and event.internal and perms.RIGS.view_event %}
|
||||
<div class="col-sm-12">
|
||||
<div class="panel panel-default
|
||||
{% if object.authorised %}
|
||||
@@ -212,7 +214,7 @@
|
||||
</div>
|
||||
<div>
|
||||
{% endif %}
|
||||
{% if not request.is_ajax %}
|
||||
{% if not request.is_ajax and perms.RIGS.view_event %}
|
||||
<div class="col-sm-12 text-right">
|
||||
{% include 'RIGS/event_detail_buttons.html' %}
|
||||
</div>
|
||||
@@ -222,21 +224,23 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Event Details</div>
|
||||
<div class="panel-body">
|
||||
{% if perms.RIGS.view_event %}
|
||||
<div class="well well-sm">
|
||||
<h4>Notes</h4>
|
||||
<div class="dont-break-out">{{ event.notes|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'RIGS/item_table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not request.is_ajax %}
|
||||
{% if not request.is_ajax and perms.RIGS.view_event %}
|
||||
<div class="col-sm-12 text-right">
|
||||
{% include 'RIGS/event_detail_buttons.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not request.is_ajax %}
|
||||
{% if not request.is_ajax and perms.RIGS.view_event %}
|
||||
<div class="col-sm-12 text-right">
|
||||
<div>
|
||||
<a href="{% url 'event_history' object.pk %}" title="View Revision History">
|
||||
@@ -251,12 +255,16 @@
|
||||
{% if request.is_ajax %}
|
||||
{% block footer %}
|
||||
<div class="row">
|
||||
{% if perms.RIGS.view_event %}
|
||||
<div class="col-sm-10 align-left">
|
||||
<a href="{% url 'event_history' object.pk %}" title="View Revision History">
|
||||
Last edited at {{ object.last_edited_at|default:'never' }} by {{ object.last_edited_by.name|default:'nobody' }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
{% else %}
|
||||
<div class="col-sm-12">
|
||||
{% endif %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'event_detail' object.pk %}" class="btn btn-primary">Open Event Page <span
|
||||
class="glyphicon glyphicon-eye"></span></a>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% extends 'base_embed.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<a href="/">
|
||||
@@ -22,7 +21,7 @@
|
||||
</span>
|
||||
|
||||
<h3>
|
||||
<a {% if perms.RIGS.view_event %}href="{% url 'event_detail' object.pk %}"{% endif %}>
|
||||
<a href="{% url 'event_detail' object.pk %}">
|
||||
{% if object.is_rig %}N{{ object.pk|stringformat:"05d" }}{% else %}{{ object.pk }}{% endif %}
|
||||
| {{ object.name }} </a>
|
||||
{% if object.venue %}
|
||||
@@ -72,7 +71,6 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
|
||||
{% if object.meet_at %}
|
||||
<p>
|
||||
<strong>Crew meet:</strong>
|
||||
@@ -97,10 +95,7 @@
|
||||
{{ object.description|linebreaksbr }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% load filters %}
|
||||
<setNextFrame name="main"/>
|
||||
<nextFrame/>
|
||||
|
||||
|
||||
<blockTable style="headLayout" colWidths="330,165">
|
||||
<tr>
|
||||
<td>
|
||||
@@ -13,7 +12,7 @@
|
||||
|
||||
<keepInFrame>
|
||||
<para style="style.event_description">
|
||||
{{ object.description|default_if_none:""|linebreaksbr }}
|
||||
{{ object.description|default_if_none:""|linebreaksxml }}
|
||||
</para>
|
||||
</keepInFrame>
|
||||
</td>
|
||||
@@ -75,9 +74,9 @@
|
||||
{% if invoice %}
|
||||
<keepInFrame>
|
||||
{% if object.organisation.address %}
|
||||
<para style="specific_description">{{ object.organisation.address|default_if_none:""|linebreaksbr }}</para>
|
||||
<para style="specific_description">{{ object.organisation.address|default_if_none:""|linebreaksxml }}</para>
|
||||
{% elif object.person.address %}
|
||||
<para style="specific_description">{{ object.person.address|default_if_none:""|linebreaksbr }}</para>
|
||||
<para style="specific_description">{{ object.person.address|default_if_none:""|linebreaksxml }}</para>
|
||||
{% endif %}
|
||||
</keepInFrame>
|
||||
{% endif %}
|
||||
@@ -109,7 +108,7 @@
|
||||
<h3>{{ object.venue.name }}</h3>
|
||||
{% if not invoice %}
|
||||
<keepInFrame>
|
||||
<para style="specific_description">{{ object.venue.address|default_if_none:""|linebreaksbr }}</para>
|
||||
<para style="specific_description">{{ object.venue.address|default_if_none:""|linebreaksxml }}</para>
|
||||
</keepInFrame>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -185,7 +184,7 @@
|
||||
{% if item.description %}
|
||||
</para>
|
||||
<para style="item_description">
|
||||
<em>{{ item.description|linebreaksbr }}</em>
|
||||
<em>{{ item.description|linebreaksxml }}</em>
|
||||
</para>
|
||||
<para>
|
||||
{% endif %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<h4>
|
||||
<a {% if perms.RIGS.view_event %}href="{% url 'event_detail' event.pk %}" {% endif %}>
|
||||
<a href="{% url 'event_detail' event.pk %}">
|
||||
{{ event.name }}
|
||||
</a>
|
||||
{% if event.venue %}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% block title %}RIGS{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-sm-12">
|
||||
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
|
||||
@@ -26,44 +34,51 @@
|
||||
|
||||
<a class="list-group-item" href="https://forum.nottinghamtec.co.uk" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Forum</a>
|
||||
<a class="list-group-item" href="//members.nottinghamtec.co.uk/wiki" target="_blank"><span class="glyphicon glyphicon-link"></span> TEC Wiki</a>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<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://goo.gl/forms/jdPWov8PCNPoXtbn2" target="_blank"><span class="glyphicon glyphicon-link"></span> Subhire Insurance Form</a>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Search Rigboard</h4>
|
||||
<h4 class="panel-title">Search Rigboard
|
||||
<a href="{% url 'search_help' %}" class="pull-right modal-href"><span class="glyphicon glyphicon-question-sign"</span></a></h4>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(function(){
|
||||
$('#search-options li a').click(function(){
|
||||
$('#searchForm').attr('action', $(this).data('action')).submit();
|
||||
});
|
||||
$('#id_search_input').keypress(function (e) {
|
||||
if (e.which == 13) {
|
||||
$('#searchForm').attr('action', $('#search-options li a').first().data('action')).submit();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<form class="form" role="form" action="{% url 'person_list' %}" method="GET">
|
||||
<div class="input-group">
|
||||
<input type="search" name="q" class="form-control" placeholder="Search People" />
|
||||
<form id="searchForm" class="form" role="form" method="GET">
|
||||
<div class="input-group" data-toggle="tooltip" title="Use the dropdown button to select what to search. The default is Event Archive.">
|
||||
<input id="id_search_input" type="search" name="q" class="form-control" placeholder="Search..." />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-search"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<form class="form" role="form" action="{% url 'organisation_list' %}" method="GET">
|
||||
<div class="input-group">
|
||||
<input type="search" name="q" class="form-control" placeholder="Search Organisations" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-search"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<form class="form" role="form" action="{% url 'venue_list' %}" method="GET">
|
||||
<div class="input-group">
|
||||
<input type="search" name="q" class="form-control" placeholder="Search Venues" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-search"></span></button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-search"></span> Search <span class="caret"></span></button>
|
||||
<ul id="search-options" class="dropdown-menu">
|
||||
<li><a data-action="{% url 'event_archive' %}" href="#">Events</a></li>
|
||||
<li><a data-action="{% url 'person_list' %}" href="#">People</a></li>
|
||||
<li><a data-action="{% url 'organisation_list' %}" href="#">Organisations</a></li>
|
||||
<li><a data-action="{% url 'venue_list' %}" href="#">Venues</a></li>
|
||||
{% if perms.RIGS.view_invoice %}
|
||||
<li><a data-action="{% url 'invoice_archive' %}" href="#">Invoices</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt>Authorsation request sent by</dt>
|
||||
<dt>Authorisation request sent by</dt>
|
||||
<dd>{{ object.authorisation.sent_by }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{% paginator %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block search %}{% endblock %}
|
||||
<div class="table-responsive col-sm-12">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
|
||||
@@ -11,3 +11,14 @@ All Invoices
|
||||
{% block description %}
|
||||
<p>This page displays all invoices: outstanding, paid, and void</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
<div class="col-sm-3 col-sm-offset-9">
|
||||
<form class="form form-horizontal col-sm-12">
|
||||
<div class="form-group">
|
||||
<input type="search" name="q" placeholder="Search" value="{{ request.GET.q }}"
|
||||
class="form-control"/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="modal fade" id="itemModal" role="dialog" aria-labelledby="itemModal" aria-hidded="true">
|
||||
<div class="modal fade" id="itemModal" role="dialog" aria-labelledby="itemModal" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -33,7 +33,6 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-sm-6">
|
||||
<div class="form-group">
|
||||
<label for="item_quantity" class="col-sm-4 control-label">Quantity</label>
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
<em class="description">{{item.description|linebreaksbr}}</em>
|
||||
</div>
|
||||
</td>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<td>£ <span class="cost">{{item.cost|floatformat:2}}</span></td>
|
||||
{% endif %}
|
||||
<td class="quantity">{{item.quantity}}</td>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<td>£ <span class="sub-total" data-subtotal="{{item.total_cost}}">{{item.total_cost|floatformat:2}}</span></td>
|
||||
{% endif %}
|
||||
{% if edit %}
|
||||
<td class="vert-align text-right">
|
||||
<button type="button" class="item-edit btn btn-xs btn-default"
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Item</td>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<td>Price</td>
|
||||
{% endif %}
|
||||
<td>Quantity</td>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<td>Sub-total</td>
|
||||
{% endif %}
|
||||
{% if edit %}
|
||||
<td class="text-right">
|
||||
<button type="button" class="btn btn-default btn-xs item-add"
|
||||
data-url="{#% url eventitem_add object.pk %#}" data-toggle="modal"
|
||||
data-toggle="modal"
|
||||
data-target="#itemModal">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
</button>
|
||||
@@ -22,6 +26,7 @@
|
||||
{% include 'RIGS/item_row.html' %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if perms.RIGS.view_event %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td rowspan="3" colspan="2"></td>
|
||||
@@ -43,6 +48,7 @@
|
||||
<td colspan="2">£ <span id="total">{{object.total|default:0|floatformat:2}}</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<table class="hidden invisible">
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
|
||||
{% block title %}Password Reset Disabled{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Password reset is disabled</h1>
|
||||
<p> We are very sorry for the inconvenience, but due to a security vulnerability, password reset is currently disabled until the vulnerability can be patched.</p>
|
||||
<p> If you are locked out of your account, please contact an administrator and we can manually perform a reset</p>
|
||||
{% endblock %}
|
||||
70
RIGS/templates/RIGS/search_help.html
Normal file
70
RIGS/templates/RIGS/search_help.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends request.is_ajax|yesno:"base_ajax.html,base.html" %}
|
||||
|
||||
{% block title %}Search Help{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% if not request.is_ajax %}
|
||||
<div class="col-sm-12">
|
||||
<h1>Search Help</h1>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Searching Events</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
Searches for entire query in:
|
||||
<button type="button" class="btn btn-default btn-xs">name</button>
|
||||
<button type="button" class="btn btn-default btn-xs">description</button> and
|
||||
<button type="button" class="btn btn-default btn-xs">notes</button>
|
||||
</p>
|
||||
<p>You can search for an event by <button type="button" class="btn btn-default btn-xs">event_id</button> by entering an integer, or using the format <code>N01234</code></p>
|
||||
<p>On the search results page you can also specify the date range for the <button type="button" class="btn btn-default btn-xs">start_date</button> of the event</p>
|
||||
<p>Events are sorted in reverse <button type="button" class="btn btn-default btn-xs">start_date</button> order (most recent events at the top)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Searching People/Organisations/Venues</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
Searches for entire search phrase in:
|
||||
<button type="button" class="btn btn-default btn-xs">name</button>
|
||||
<button type="button" class="btn btn-default btn-xs">email</button>
|
||||
<button type="button" class="btn btn-default btn-xs">address</button>
|
||||
<button type="button" class="btn btn-default btn-xs">notes</button> and
|
||||
<button type="button" class="btn btn-default btn-xs">phone</button>
|
||||
</p>
|
||||
<p>You can search for an entry by <button type="button" class="btn btn-default btn-xs">id</button> by entering an integer</p>
|
||||
<p>Entries are sorted in alphabetical order by <button type="button" class="btn btn-default btn-xs">name</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.RIGS.view_invoice %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Searching Invoices</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
Searches for entire search phrase in:
|
||||
<button type="button" class="btn btn-default btn-xs">event__name</button>
|
||||
</p>
|
||||
<p>You can search for an event's invoice by entering the <button type="button" class="btn btn-default btn-xs">event_id</button> using the format <code>N01234</code></p>
|
||||
<p>You can search for an invoice by <button type="button" class="btn btn-default btn-xs">invoice_id</button> using the format <code>#01234</code></p>
|
||||
<p>Entering a raw integer will search by both <button type="button" class="btn btn-default btn-xs">invoice_id</button> and <button type="button" class="btn btn-default btn-xs">event_id</button></p>
|
||||
<p>Entries are sorted in reverse <button type="button" class="btn btn-default btn-xs">invoice_date</button> order</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,10 +2,24 @@ from django import template
|
||||
from django import forms
|
||||
from django.forms.forms import NON_FIELD_ERRORS
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.utils.text import normalize_newlines
|
||||
from django.template.defaultfilters import stringfilter
|
||||
from django.utils.safestring import SafeData, mark_safe
|
||||
from django.utils.html import escape
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(is_safe=True, needs_autoescape=True)
|
||||
@stringfilter
|
||||
def linebreaksxml(value, autoescape=True):
|
||||
autoescape = autoescape and not isinstance(value, SafeData)
|
||||
value = normalize_newlines(value)
|
||||
if autoescape:
|
||||
value = escape(value)
|
||||
return mark_safe(value.replace('\n', '<br />'))
|
||||
|
||||
|
||||
@register.filter
|
||||
def multiply(value, arg):
|
||||
return value * arg
|
||||
|
||||
@@ -94,7 +94,8 @@ class UserRegistrationTest(LiveServerTestCase):
|
||||
# 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)
|
||||
# TODO Use regex matching to handle smart/unsmart quotes...
|
||||
self.assertIn("password fields didn", alert)
|
||||
|
||||
# Passwords should be empty
|
||||
self.assertEqual(password1.get_attribute('value'), '')
|
||||
@@ -121,7 +122,7 @@ class UserRegistrationTest(LiveServerTestCase):
|
||||
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)
|
||||
r'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
|
||||
@@ -141,18 +142,41 @@ class UserRegistrationTest(LiveServerTestCase):
|
||||
self.assertEqual(password.get_attribute('placeholder'), 'Password')
|
||||
self.assertEqual(password.get_attribute('type'), 'password')
|
||||
|
||||
# Expected to fail as not approved
|
||||
username.send_keys('TestUsername')
|
||||
password.send_keys('correcthorsebatterystaple')
|
||||
self.browser.execute_script(
|
||||
"return function() {jQuery('#g-recaptcha-response').val('PASSED'); return 0}()")
|
||||
password.send_keys(Keys.ENTER)
|
||||
|
||||
# Test approval
|
||||
profileObject = models.Profile.objects.all()[0]
|
||||
self.assertFalse(profileObject.is_approved)
|
||||
|
||||
# Read what the error is
|
||||
alert = self.browser.find_element_by_css_selector(
|
||||
'div.alert-danger').text
|
||||
self.assertIn("approved", alert)
|
||||
|
||||
# Approve the user so we can proceed
|
||||
profileObject.is_approved = True
|
||||
profileObject.save()
|
||||
|
||||
# Retry login
|
||||
self.browser.get(self.live_server_url + '/user/login')
|
||||
username = self.browser.find_element_by_id('id_username')
|
||||
username.send_keys('TestUsername')
|
||||
password = self.browser.find_element_by_id('id_password')
|
||||
password.send_keys('correcthorsebatterystaple')
|
||||
self.browser.execute_script(
|
||||
"return function() {jQuery('#g-recaptcha-response').val('PASSED'); return 0}()")
|
||||
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 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')
|
||||
@@ -231,7 +255,7 @@ class EventTest(LiveServerTestCase):
|
||||
|
||||
# Slider expands and save button visible
|
||||
self.assertTrue(save.is_displayed())
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
|
||||
# For now, just check that HTML5 Client validation is in place TODO Test needs rewriting to properly test all levels of validation.
|
||||
self.assertTrue(self.browser.find_element_by_id('id_name').get_attribute('required') is not None)
|
||||
@@ -469,7 +493,7 @@ class EventTest(LiveServerTestCase):
|
||||
|
||||
save = self.browser.find_element_by_xpath(
|
||||
'(//button[@type="submit"])[3]')
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
|
||||
# Check the items are visible
|
||||
table = self.browser.find_element_by_id('item-table') # ID number is known, see above
|
||||
@@ -481,8 +505,10 @@ class EventTest(LiveServerTestCase):
|
||||
|
||||
# Add item
|
||||
form.find_element_by_xpath('//button[contains(@class, "item-add")]').click()
|
||||
wait.until(animation_is_finished())
|
||||
modal = self.browser.find_element_by_id("itemModal")
|
||||
wait.until(animation_is_finished())
|
||||
# See modal has opened
|
||||
self.assertTrue(modal.is_displayed())
|
||||
modal.find_element_by_id("item_name").send_keys("Test Item 3")
|
||||
modal.find_element_by_id("item_description").send_keys(
|
||||
"This is an item description\nthat for reasons unknown spans two lines")
|
||||
@@ -551,7 +577,7 @@ class EventTest(LiveServerTestCase):
|
||||
# Click Rig button
|
||||
self.browser.find_element_by_xpath('//button[.="Rig"]').click()
|
||||
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('//*[@id="content"]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
|
||||
# Set title
|
||||
@@ -581,7 +607,7 @@ class EventTest(LiveServerTestCase):
|
||||
self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text)
|
||||
|
||||
# Same date, end time before start time
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
|
||||
self.browser.execute_script("document.getElementById('id_start_date').value='3015-04-24'")
|
||||
@@ -600,7 +626,7 @@ class EventTest(LiveServerTestCase):
|
||||
self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text)
|
||||
|
||||
# Same date, end time before start time
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
|
||||
self.browser.execute_script("document.getElementById('id_start_date').value='3015-04-24'")
|
||||
@@ -613,7 +639,7 @@ class EventTest(LiveServerTestCase):
|
||||
form.find_element_by_id('id_end_time').send_keys('06:00')
|
||||
|
||||
# No end date, end time before start time
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
|
||||
self.browser.execute_script("document.getElementById('id_start_date').value='3015-04-24'")
|
||||
@@ -632,7 +658,7 @@ class EventTest(LiveServerTestCase):
|
||||
self.assertIn("can't finish before it has started", error.find_element_by_xpath('//dd[1]/ul/li').text)
|
||||
|
||||
# 2 dates, end after start
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
self.browser.execute_script("document.getElementById('id_start_date').value='3015-04-24'")
|
||||
self.browser.execute_script("document.getElementById('id_end_date').value='3015-04-26'")
|
||||
@@ -665,7 +691,7 @@ class EventTest(LiveServerTestCase):
|
||||
# Click Rig button
|
||||
self.browser.find_element_by_xpath('//button[.="Rig"]').click()
|
||||
|
||||
form = self.browser.find_element_by_tag_name('form')
|
||||
form = self.browser.find_element_by_xpath('/html/body/div[2]/div[1]/form')
|
||||
save = self.browser.find_element_by_xpath('(//button[@type="submit"])[3]')
|
||||
|
||||
# Set title
|
||||
@@ -1199,3 +1225,47 @@ class TECEventAuthorisationTest(TestCase):
|
||||
self.assertEqual(self.event.auth_request_by, self.profile)
|
||||
self.assertEqual(self.event.auth_request_to, 'client@functional.test')
|
||||
self.assertIsNotNone(self.event.auth_request_at)
|
||||
|
||||
|
||||
class SearchTest(LiveServerTestCase):
|
||||
def setUp(self):
|
||||
self.profile = models.Profile(
|
||||
username="SearchTest", first_name="Search", last_name="Test", initials="STU", is_superuser=True)
|
||||
self.profile.set_password("SearchTestPassword")
|
||||
self.profile.save()
|
||||
|
||||
self.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1')
|
||||
|
||||
self.browser = create_browser()
|
||||
self.browser.implicitly_wait(10) # Set implicit wait session wide
|
||||
|
||||
os.environ['RECAPTCHA_TESTING'] = 'True'
|
||||
|
||||
models.Event.objects.create(name="Right Event", status=models.Event.PROVISIONAL,
|
||||
start_date=date.today(), description="This event is searched for endlessly over and over")
|
||||
models.Event.objects.create(name="Wrong Event", status=models.Event.PROVISIONAL, start_date=date.today(),
|
||||
description="This one should never be found.")
|
||||
|
||||
def tearDown(self):
|
||||
self.browser.quit()
|
||||
os.environ['RECAPTCHA_TESTING'] = 'False'
|
||||
|
||||
def test_search(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
username = self.browser.find_element_by_id('id_username')
|
||||
password = self.browser.find_element_by_id('id_password')
|
||||
submit = self.browser.find_element_by_css_selector(
|
||||
'input[type=submit]')
|
||||
|
||||
username.send_keys("SearchTest")
|
||||
password.send_keys("SearchTestPassword")
|
||||
submit.click()
|
||||
|
||||
form = self.browser.find_element_by_id('searchForm')
|
||||
search_box = form.find_element_by_id('id_search_input')
|
||||
search_box.send_keys('Right')
|
||||
search_box.send_keys(Keys.ENTER)
|
||||
|
||||
event_name = self.browser.find_element_by_xpath('//*[@id="content"]/div[1]/div[4]/div/div/table/tbody/tr[1]/td[3]/h4').text
|
||||
self.assertIn('Right', event_name)
|
||||
self.assertNotIn('Wrong', event_name)
|
||||
|
||||
@@ -424,7 +424,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
|
||||
def test_find_parent_version(self):
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
self.assertEqual(currentVersion._object_version.object.notes, "A new note on the event")
|
||||
|
||||
# Check the prev version is loaded correctly
|
||||
@@ -436,7 +436,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
|
||||
def test_changes_since(self):
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
|
||||
changes = currentVersion.changes
|
||||
self.assertEqual(len(changes.field_changes), 1)
|
||||
@@ -453,7 +453,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.event.save()
|
||||
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
diff = currentVersion.changes
|
||||
|
||||
# There are two changes
|
||||
@@ -475,7 +475,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.person.save()
|
||||
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.person).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.person).latest('revision__date_created')
|
||||
diff = currentVersion.changes
|
||||
|
||||
# Should be declared as long
|
||||
@@ -488,7 +488,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.event.save()
|
||||
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
|
||||
# Check the diff is correct
|
||||
self.assertEqual(currentVersion.changes.field_changes[0].diff,
|
||||
@@ -504,12 +504,12 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.event.status = models.Event.CONFIRMED
|
||||
self.event.save()
|
||||
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
self.assertEqual(currentVersion.changes.field_changes[0].old, 'Provisional')
|
||||
self.assertEqual(currentVersion.changes.field_changes[0].new, 'Confirmed')
|
||||
|
||||
def test_creation_behaviour(self):
|
||||
firstVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created').parent
|
||||
firstVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created').parent
|
||||
diff = firstVersion.changes
|
||||
|
||||
# Mainly to check for exceptions:
|
||||
@@ -522,7 +522,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.event.save()
|
||||
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
|
||||
diffs = currentVersion.changes.item_changes
|
||||
|
||||
@@ -541,7 +541,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
item1.save()
|
||||
self.event.save()
|
||||
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
|
||||
diffs = currentVersion.changes.item_changes
|
||||
|
||||
@@ -563,7 +563,7 @@ class RIGSVersionTestCase(TestCase):
|
||||
self.event.save()
|
||||
|
||||
# Find the most recent version
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest(field_name='revision__date_created')
|
||||
currentVersion = versioning.RIGSVersion.objects.get_for_object(self.event).latest('revision__date_created')
|
||||
|
||||
diffs = currentVersion.changes.item_changes
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ class TestPrintPaperwork(TestCase):
|
||||
cls.vatrate = models.VatRate.objects.create(start_at='2014-03-05', rate=0.20, comment='test1')
|
||||
|
||||
cls.events = {
|
||||
1: models.Event.objects.create(name="TE E1", start_date=date.today()),
|
||||
1: models.Event.objects.create(name="TE E1", start_date=date.today(), description="This is an event description\nthat for a very specific reason spans two lines."),
|
||||
}
|
||||
|
||||
cls.invoices = {
|
||||
@@ -423,3 +423,107 @@ class TestSampleDataGenerator(TestCase):
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
self.assertRaisesRegex(CommandError, ".*production", call_command, 'generateSampleRIGSData')
|
||||
|
||||
|
||||
class TestSearchLogic(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.persons = {
|
||||
1: models.Person.objects.create(name="Right Person", phone="1234"),
|
||||
2: models.Person.objects.create(name="Wrong Person", phone="5678"),
|
||||
}
|
||||
|
||||
cls.organisations = {
|
||||
1: models.Organisation.objects.create(name="Right Organisation", email="test@example.com"),
|
||||
2: models.Organisation.objects.create(name="Wrong Organisation", email="check@fake.co.uk"),
|
||||
}
|
||||
|
||||
cls.venues = {
|
||||
1: models.Venue.objects.create(name="Right Venue", address="1 Test Street, EX1"),
|
||||
2: models.Venue.objects.create(name="Wrong Venue", address="2 Check Way, TS2"),
|
||||
}
|
||||
|
||||
cls.events = {
|
||||
1: models.Event.objects.create(name="Right Event", start_date=date.today(), person=cls.persons[1],
|
||||
organisation=cls.organisations[1], venue=cls.venues[1]),
|
||||
2: models.Event.objects.create(name="Wrong Event", start_date=date.today(), person=cls.persons[2],
|
||||
organisation=cls.organisations[2], venue=cls.venues[2]),
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.profile.set_password('testuser')
|
||||
self.profile.save()
|
||||
self.assertTrue(self.client.login(username=self.profile.username, password='testuser'))
|
||||
|
||||
def test_event_search(self):
|
||||
# Test search by name
|
||||
request_url = "%s?q=%s" % (reverse('event_archive'), self.events[1].name)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.events[1].name)
|
||||
self.assertNotContains(response, self.events[2].name)
|
||||
|
||||
# Test search by ID
|
||||
request_url = "%s?q=%s" % (reverse('event_archive'), self.events[1].pk)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.events[1].name)
|
||||
self.assertNotContains(response, self.events[2].name)
|
||||
|
||||
def test_people_search(self):
|
||||
# Test search by name
|
||||
request_url = "%s?q=%s" % (reverse('person_list'), self.persons[1].name)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.persons[1].name)
|
||||
self.assertNotContains(response, self.persons[2].name)
|
||||
|
||||
# Test search by ID
|
||||
request_url = "%s?q=%s" % (reverse('person_list'), self.persons[1].pk)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.persons[1].name)
|
||||
self.assertNotContains(response, self.persons[2].name)
|
||||
|
||||
# Test search by phone
|
||||
request_url = "%s?q=%s" % (reverse('person_list'), self.persons[1].phone)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.persons[1].name)
|
||||
self.assertNotContains(response, self.persons[2].name)
|
||||
|
||||
def test_organisation_search(self):
|
||||
# Test search by name
|
||||
request_url = "%s?q=%s" % (reverse('organisation_list'), self.organisations[1].name)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.organisations[1].name)
|
||||
self.assertNotContains(response, self.organisations[2].name)
|
||||
|
||||
# Test search by ID
|
||||
request_url = "%s?q=%s" % (reverse('organisation_list'), self.organisations[1].pk)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.organisations[1].name)
|
||||
self.assertNotContains(response, self.organisations[2].name)
|
||||
|
||||
# Test search by email
|
||||
request_url = "%s?q=%s" % (reverse('organisation_list'), self.organisations[1].email)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.organisations[1].email)
|
||||
self.assertNotContains(response, self.organisations[2].email)
|
||||
|
||||
def test_venue_search(self):
|
||||
# Test search by name
|
||||
request_url = "%s?q=%s" % (reverse('venue_list'), self.venues[1].name)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.venues[1].name)
|
||||
self.assertNotContains(response, self.venues[2].name)
|
||||
|
||||
# Test search by ID
|
||||
request_url = "%s?q=%s" % (reverse('venue_list'), self.venues[1].pk)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.venues[1].name)
|
||||
self.assertNotContains(response, self.venues[2].name)
|
||||
|
||||
# Test search by address
|
||||
request_url = "%s?q=%s" % (reverse('venue_list'), self.venues[1].address)
|
||||
response = self.client.get(request_url, follow=True)
|
||||
self.assertContains(response, self.venues[1].address)
|
||||
self.assertNotContains(response, self.venues[2].address)
|
||||
|
||||
15
RIGS/urls.py
15
RIGS/urls.py
@@ -1,12 +1,14 @@
|
||||
from django.urls import path
|
||||
from django.conf.urls import url
|
||||
from django.contrib.auth.views import password_reset
|
||||
from django.contrib.auth.views import PasswordResetView
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import LoginView
|
||||
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 permission_required_with_403, has_oembed
|
||||
from PyRIGS.decorators import api_key_required
|
||||
|
||||
urlpatterns = [
|
||||
@@ -16,10 +18,10 @@ urlpatterns = [
|
||||
url('^$', login_required(views.Index.as_view()), name='index'),
|
||||
url(r'^closemodal/$', views.CloseModal.as_view(), name='closemodal'),
|
||||
|
||||
url('^user/login/$', views.login, name='login'),
|
||||
url('^user/login/embed/$', xframe_options_exempt(views.login_embed), name='login_embed'),
|
||||
path('user/login/', LoginView.as_view(authentication_form=forms.CheckApprovedForm), name='login'),
|
||||
path('user/login/embed/', xframe_options_exempt(views.LoginEmbed.as_view()), name='login_embed'),
|
||||
|
||||
url(r'^user/password_reset/$', views.PasswordResetDisabled.as_view()),
|
||||
url(r'^search_help/$', views.SearchHelp.as_view(), name='search_help'),
|
||||
|
||||
# People
|
||||
url(r'^people/$', permission_required_with_403('RIGS.view_person')(views.PersonList.as_view()),
|
||||
@@ -87,8 +89,7 @@ urlpatterns = [
|
||||
permission_required_with_403('RIGS.view_event')(versioning.ActivityFeed.as_view()),
|
||||
name='activity_feed'),
|
||||
|
||||
url(r'^event/(?P<pk>\d+)/$',
|
||||
permission_required_with_403('RIGS.view_event', oembed_view="event_oembed")(
|
||||
url(r'^event/(?P<pk>\d+)/$', has_oembed(oembed_view="event_oembed")(
|
||||
rigboard.EventDetail.as_view()),
|
||||
name='event_detail'),
|
||||
url(r'^event/(?P<pk>\d+)/embed/$',
|
||||
|
||||
@@ -25,7 +25,7 @@ class FieldComparison(object):
|
||||
self._new = new
|
||||
|
||||
def display_value(self, value):
|
||||
if isinstance(self.field, IntegerField) and len(self.field.choices) > 0:
|
||||
if isinstance(self.field, IntegerField) and self.field.choices is not None and len(self.field.choices) > 0:
|
||||
return [x[1] for x in self.field.choices if x[0] == value][0]
|
||||
if self.field.name == "risk_assessment_edit_url":
|
||||
return "completed" if value else ""
|
||||
@@ -184,8 +184,7 @@ class RIGSVersion(Version):
|
||||
versions = RIGSVersion.objects.get_for_object_reference(self.content_type.model_class(), thisId).select_related("revision", "revision__user").all()
|
||||
|
||||
try:
|
||||
previousVersion = versions.filter(revision_id__lt=self.revision_id).latest(
|
||||
field_name='revision__date_created')
|
||||
previousVersion = versions.filter(revision_id__lt=self.revision_id).latest('revision__date_created')
|
||||
except ObjectDoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.http.response import HttpResponseRedirect
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy, reverse, NoReverseMatch
|
||||
from django.views import generic
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core import serializers
|
||||
@@ -34,28 +35,19 @@ class Index(generic.TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
def login(request, **kwargs):
|
||||
if request.user.is_authenticated:
|
||||
next = request.GET.get('next', '/')
|
||||
return HttpResponseRedirect(next)
|
||||
else:
|
||||
from django.contrib.auth.views import login
|
||||
|
||||
return login(request)
|
||||
class SearchHelp(generic.TemplateView):
|
||||
template_name = 'RIGS/search_help.html'
|
||||
|
||||
|
||||
# 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):
|
||||
if request.user.is_authenticated:
|
||||
next = request.GET.get('next', '/')
|
||||
return HttpResponseRedirect(next)
|
||||
else:
|
||||
from django.contrib.auth.views import login
|
||||
class LoginEmbed(LoginView):
|
||||
template_name = 'registration/login_embed.html'
|
||||
|
||||
@csrf_exempt
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if request.method == "POST":
|
||||
csrf_cookie = request.COOKIES.get('csrftoken', None)
|
||||
|
||||
@@ -63,7 +55,7 @@ def login_embed(request, **kwargs):
|
||||
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", authentication_form=forms.EmbeddedAuthenticationForm)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
"""
|
||||
@@ -86,11 +78,20 @@ class PersonList(generic.ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
q = self.request.GET.get('q', "")
|
||||
if len(q) >= 3:
|
||||
object_list = self.model.objects.filter(Q(name__icontains=q) | Q(email__icontains=q))
|
||||
else:
|
||||
object_list = self.model.objects.all()
|
||||
orderBy = self.request.GET.get('orderBy', None)
|
||||
|
||||
filter = Q(name__icontains=q) | Q(email__icontains=q) | Q(address__icontains=q) | Q(notes__icontains=q) | Q(phone__startswith=q) | Q(phone__endswith=q)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(q)
|
||||
filter = filter | Q(pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
object_list = self.model.objects.filter(filter)
|
||||
|
||||
orderBy = self.request.GET.get('orderBy', 'name')
|
||||
if orderBy is not None:
|
||||
object_list = object_list.order_by(orderBy)
|
||||
return object_list
|
||||
@@ -140,11 +141,20 @@ class OrganisationList(generic.ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
q = self.request.GET.get('q', "")
|
||||
if len(q) >= 3:
|
||||
object_list = self.model.objects.filter(Q(name__icontains=q) | Q(address__icontains=q))
|
||||
else:
|
||||
object_list = self.model.objects.all()
|
||||
orderBy = self.request.GET.get('orderBy', "")
|
||||
|
||||
filter = Q(name__icontains=q) | Q(email__icontains=q) | Q(address__icontains=q) | Q(notes__icontains=q) | Q(phone__startswith=q) | Q(phone__endswith=q)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(q)
|
||||
filter = filter | Q(pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
object_list = self.model.objects.filter(filter)
|
||||
|
||||
orderBy = self.request.GET.get('orderBy', "name")
|
||||
if orderBy is not "":
|
||||
object_list = object_list.order_by(orderBy)
|
||||
return object_list
|
||||
@@ -194,11 +204,20 @@ class VenueList(generic.ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
q = self.request.GET.get('q', "")
|
||||
if len(q) >= 3:
|
||||
object_list = self.model.objects.filter(Q(name__icontains=q) | Q(address__icontains=q))
|
||||
else:
|
||||
object_list = self.model.objects.all()
|
||||
orderBy = self.request.GET.get('orderBy', "")
|
||||
|
||||
filter = Q(name__icontains=q) | Q(email__icontains=q) | Q(address__icontains=q) | Q(notes__icontains=q) | Q(phone__startswith=q) | Q(phone__endswith=q)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(q)
|
||||
filter = filter | Q(pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
object_list = self.model.objects.filter(filter)
|
||||
|
||||
orderBy = self.request.GET.get('orderBy', "name")
|
||||
if orderBy is not "":
|
||||
object_list = object_list.order_by(orderBy)
|
||||
return object_list
|
||||
@@ -392,7 +411,3 @@ class ResetApiKey(generic.RedirectView):
|
||||
self.request.user.save()
|
||||
|
||||
return reverse_lazy('profile_detail')
|
||||
|
||||
|
||||
class PasswordResetDisabled(generic.TemplateView):
|
||||
template_name = "RIGS/password_reset_disable.html"
|
||||
|
||||
@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=None, related_name='asset_parent', to='assets.Asset'),
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_parent', to='assets.Asset'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
@@ -85,7 +85,7 @@ class Migration(migrations.Migration):
|
||||
('circuits', models.IntegerField(blank=True, null=True)),
|
||||
('cores', models.IntegerField(blank=True, null=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='assets.AssetCategory')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=None, related_name='asset_parent', to='assets.Cable')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_parent', to='assets.Cable')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 2.0.13 on 2020-02-07 17:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0009_auto_20200103_2215'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='supplier',
|
||||
options={'ordering': ['name'], 'permissions': (('view_supplier', 'Can view a supplier'),)},
|
||||
),
|
||||
]
|
||||
21
assets/migrations/0010_auto_20200219_1444.py
Normal file
21
assets/migrations/0010_auto_20200219_1444.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-19 14:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0009_auto_20200103_2215'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='asset',
|
||||
options={'ordering': ['asset_id_prefix', 'asset_id_number'], 'permissions': [('asset_finance', 'Can see financial data for assets')]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='supplier',
|
||||
options={'ordering': ['name']},
|
||||
),
|
||||
]
|
||||
@@ -41,13 +41,10 @@ class AssetStatus(models.Model):
|
||||
|
||||
@reversion.register
|
||||
class Supplier(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=80)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
permissions = (
|
||||
('view_supplier', 'Can view a supplier'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=80)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('supplier_list')
|
||||
@@ -85,10 +82,9 @@ class CableType(models.Model):
|
||||
class Asset(models.Model, RevisionMixin):
|
||||
class Meta:
|
||||
ordering = ['asset_id_prefix', 'asset_id_number']
|
||||
permissions = (
|
||||
('asset_finance', 'Can see financial data for assets'),
|
||||
('view_asset', 'Can view an asset')
|
||||
)
|
||||
permissions = [
|
||||
('asset_finance', 'Can see financial data for assets')
|
||||
]
|
||||
|
||||
parent = models.ForeignKey(to='self', related_name='asset_parent',
|
||||
blank=True, null=True, on_delete=models.SET_NULL)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% extends 'base_embed.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<a href="/assets">
|
||||
@@ -35,6 +34,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -248,7 +248,7 @@ class TestSupplierCreateAndEdit(AutoLoginTest):
|
||||
def test_supplier_edit(self):
|
||||
self.page = pages.SupplierEdit(self.driver, self.live_server_url, supplier_id=self.supplier.pk).open()
|
||||
|
||||
self.assertEquals("Fullmetal Heavy Industry", self.page.name)
|
||||
self.assertEqual("Fullmetal Heavy Industry", self.page.name)
|
||||
new_name = "Cyberdyne Systems"
|
||||
self.page.name = new_name
|
||||
self.page.submit()
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
beautifulsoup4==4.6.0
|
||||
contextlib2==0.5.5
|
||||
diff-match-patch==20121119
|
||||
diff-match-patch==20181111
|
||||
dj-database-url==0.5.0
|
||||
dj-static==0.0.6
|
||||
Django==2.0.13
|
||||
django-filter==2.0.0
|
||||
django-widget-tweaks==1.4.3
|
||||
django-debug-toolbar==1.9.1
|
||||
django-ical==1.4
|
||||
django-recaptcha==1.4.0
|
||||
django-registration-redux==2.4
|
||||
django-reversion==2.0.13
|
||||
django-toolbelt==0.0.1
|
||||
premailer==3.2.0
|
||||
git+git://github.com/jazzband/django-widget-tweaks.git@1.4.2
|
||||
gunicorn==19.8.1
|
||||
icalendar==4.0.1
|
||||
lxml==4.2.1
|
||||
Markdown==2.6.11
|
||||
Pillow==6.2.0
|
||||
psycopg2==2.7.4
|
||||
Pygments==2.2.0
|
||||
Django==3.0.3
|
||||
django-debug-toolbar==2.2
|
||||
django-ical==1.7.0
|
||||
django-recaptcha==2.0.6
|
||||
django-registration-redux==2.7
|
||||
django-reversion==3.0.7
|
||||
django-widget-tweaks==1.4.5
|
||||
gunicorn==20.0.4
|
||||
icalendar==4.0.4
|
||||
lxml==4.5.0
|
||||
premailer==3.6.1
|
||||
psycopg2==2.8.4
|
||||
PyPDF2==1.26.0
|
||||
python-dateutil==2.7.3
|
||||
pytz==2018.4
|
||||
raven==6.8.0
|
||||
reportlab==3.4.0
|
||||
selenium==3.12.0
|
||||
simplejson==3.15.0
|
||||
six==1.11.0
|
||||
sqlparse==0.2.4
|
||||
static3==0.7.0
|
||||
svg2rlg==0.3
|
||||
yolk==0.4.3
|
||||
whitenoise==4.1.2
|
||||
z3c.rml==3.5.0
|
||||
zope.event==4.3.0
|
||||
zope.interface==4.5.0
|
||||
zope.schema==4.5.0
|
||||
pypom==2.2.0
|
||||
PyPOM==2.2.0
|
||||
pytz==2019.3
|
||||
raven==6.10.0
|
||||
requests==2.23.0
|
||||
selenium==3.141.0
|
||||
simplejson==3.17.0
|
||||
whitenoise==5.0.1
|
||||
z3c.rml==3.9.1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Bad Request{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Unauthorized{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Forbidden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Page Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Server error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
{% load raven %}
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
{% load raven %}
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
{% load raven %}
|
||||
|
||||
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<table class="main-table">
|
||||
<tr class="client-header">
|
||||
<td align="center">
|
||||
@@ -51,8 +48,5 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% load static from staticfiles %}
|
||||
{% load static %}
|
||||
{% load raven %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load static %}
|
||||
{% block title %}Login Required{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
{% block content %}
|
||||
<div class="alert alert-success">
|
||||
<h2>Activation Complete</h2>
|
||||
<p>You user account is now fully registered. Enjoy RIGS</p>
|
||||
<p>Your user account is now awaiting administrator approval. Won't be long!</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
{% load widget_tweaks %}
|
||||
{% 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" target="_self">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="id_username">{{ form.username.label }}</label>
|
||||
|
||||
Reference in New Issue
Block a user