Refactor search logic to a create an 'omnisearch' (#484)

This commit is contained in:
2022-02-08 15:01:01 +00:00
committed by GitHub
parent 3e1e0079d8
commit 54c90a7be4
19 changed files with 290 additions and 164 deletions

View File

@@ -9,9 +9,8 @@ from RIGS import models
def get_oembed(login_url, request, oembed_view, kwargs): def get_oembed(login_url, request, oembed_view, kwargs):
context = {} context = {}
context['oembed_url'] = "{0}://{1}{2}".format(request.scheme, request.META['HTTP_HOST'], context['oembed_url'] = f"{request.scheme}://{request.META['HTTP_HOST']}{reverse(oembed_view, kwargs=kwargs)}"
reverse(oembed_view, kwargs=kwargs)) context['login_url'] = f"{login_url}?{REDIRECT_FIELD_NAME}={request.get_full_path()}"
context['login_url'] = "{0}?{1}={2}".format(login_url, REDIRECT_FIELD_NAME, request.get_full_path())
resp = render(request, 'login_redirect.html', context=context) resp = render(request, 'login_redirect.html', context=context)
return resp return resp
@@ -25,7 +24,7 @@ def has_oembed(oembed_view, login_url=settings.LOGIN_URL):
if oembed_view is not None: if oembed_view is not None:
return get_oembed(login_url, request, oembed_view, kwargs) return get_oembed(login_url, request, oembed_view, kwargs)
else: else:
return HttpResponseRedirect('%s?%s=%s' % (login_url, REDIRECT_FIELD_NAME, request.get_full_path())) return HttpResponseRedirect(f'{login_url}?{REDIRECT_FIELD_NAME}={request.get_full_path()}')
_checklogin.__doc__ = view_func.__doc__ _checklogin.__doc__ = view_func.__doc__
_checklogin.__dict__ = view_func.__dict__ _checklogin.__dict__ = view_func.__dict__
@@ -55,7 +54,7 @@ def user_passes_test_with_403(test_func, login_url=None, oembed_view=None):
if oembed_view is not None: if oembed_view is not None:
return get_oembed(login_url, request, oembed_view, kwargs) return get_oembed(login_url, request, oembed_view, kwargs)
else: else:
return HttpResponseRedirect('%s?%s=%s' % (login_url, REDIRECT_FIELD_NAME, request.get_full_path())) return HttpResponseRedirect(f'{login_url}?{REDIRECT_FIELD_NAME}={request.get_full_path()}')
else: else:
resp = render(request, '403.html') resp = render(request, '403.html')
resp.status_code = 403 resp.status_code = 403

View File

@@ -10,6 +10,7 @@ from pytest_django.asserts import assertTemplateUsed, assertInHTML
from PyRIGS import urls from PyRIGS import urls
from RIGS.models import Event, Profile from RIGS.models import Event, Profile
from assets.models import Asset from assets.models import Asset
from training.tests.test_unit import get_response
from django.db import connection from django.db import connection
from django.template.defaultfilters import striptags from django.template.defaultfilters import striptags
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
@@ -135,3 +136,11 @@ def test_keyholder_access(client):
assertContains(response, 'View Revision History') assertContains(response, 'View Revision History')
client.logout() client.logout()
call_command('deleteSampleData') call_command('deleteSampleData')
def test_search(admin_client, admin_user):
url = reverse('search')
response = admin_client.get(url, {'q': "Definetelynothingfoundifwesearchthis"})
assertContains(response, "No results found")
response = admin_client.get(url, {'q': admin_user.first_name})
assertContains(response, admin_user.first_name)

View File

@@ -23,6 +23,7 @@ urlpatterns = [
name="api_secure"), name="api_secure"),
path('closemodal/', views.CloseModal.as_view(), name='closemodal'), path('closemodal/', views.CloseModal.as_view(), name='closemodal'),
path('search/', login_required(views.Search.as_view()), name='search'),
path('search_help/', login_required(views.SearchHelp.as_view()), name='search_help'), path('search_help/', login_required(views.SearchHelp.as_view()), name='search_help'),
path('', include('users.urls')), path('', include('users.urls')),

View File

@@ -1,6 +1,7 @@
import datetime import datetime
import operator import operator
from functools import reduce from functools import reduce
from itertools import chain
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
@@ -120,7 +121,7 @@ class SecureAPIRequest(generic.View):
'text': o.name, 'text': o.name,
} }
try: # See if there is a valid update URL try: # See if there is a valid update URL
data['update'] = reverse("%s_update" % model, kwargs={'pk': o.pk}) data['update'] = reverse(f"{model}_update", kwargs={'pk': o.pk})
except NoReverseMatch: except NoReverseMatch:
pass pass
results.append(data) results.append(data)
@@ -182,20 +183,7 @@ class GenericListView(generic.ListView):
return context return context
def get_queryset(self): def get_queryset(self):
q = self.request.GET.get('q', "") object_list = self.model.objects.search(query=self.request.GET.get('q', ""))
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") orderBy = self.request.GET.get('orderBy', "name")
if orderBy != "": if orderBy != "":
@@ -236,6 +224,53 @@ class GenericCreateView(generic.CreateView):
return context return context
class Search(generic.ListView):
template_name = 'search_results.html'
paginate_by = 20
count = 0
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['count'] = self.count or 0
context['query'] = self.request.GET.get('q')
context['page_title'] = f"{context['count']} search results for <b>{context['query']}</b>"
return context
def get_queryset(self):
request = self.request
query = request.GET.get('q', None)
if query is not None:
event_results = models.Event.objects.search(query)
person_results = models.Person.objects.search(query)
organisation_results = models.Organisation.objects.search(query)
venue_results = models.Venue.objects.search(query)
invoice_results = models.Invoice.objects.search(query)
asset_results = asset_models.Asset.objects.search(query)
supplier_results = asset_models.Supplier.objects.search(query)
trainee_results = training_models.Trainee.objects.search(query)
training_item_results = training_models.TrainingItem.objects.search(query)
# combine querysets
queryset_chain = chain(
event_results,
person_results,
organisation_results,
venue_results,
invoice_results,
asset_results,
supplier_results,
trainee_results,
training_item_results,
)
qs = sorted(queryset_chain,
key=lambda instance: instance.pk,
reverse=True)
self.count = len(qs) # since qs is actually a list
return qs
return models.Event.objects.none() # just an empty queryset as default
class SearchHelp(generic.TemplateView): class SearchHelp(generic.TemplateView):
template_name = 'search_help.html' template_name = 'search_help.html'

View File

@@ -172,9 +172,9 @@ class EventRiskAssessmentForm(forms.ModelForm):
unexpected_values = [] unexpected_values = []
for field, value in models.RiskAssessment.expected_values.items(): for field, value in models.RiskAssessment.expected_values.items():
if self.cleaned_data.get(field) != value: if self.cleaned_data.get(field) != value:
unexpected_values.append("<li>{}</li>".format(self._meta.model._meta.get_field(field).help_text)) unexpected_values.append(f"<li>{self._meta.model._meta.get_field(field).help_text}</li>")
if len(unexpected_values) > 0 and not self.cleaned_data.get('supervisor_consulted'): if len(unexpected_values) > 0 and not self.cleaned_data.get('supervisor_consulted'):
raise forms.ValidationError("Your answers to these questions: <ul>{}</ul> require consulting with a supervisor.".format(''.join([str(elem) for elem in unexpected_values])), code='unusual_answers') raise forms.ValidationError(f"Your answers to these questions: <ul>{''.join([str(elem) for elem in unexpected_values])}</ul> require consulting with a supervisor.", code='unusual_answers')
return super(EventRiskAssessmentForm, self).clean() return super(EventRiskAssessmentForm, self).clean()
class Meta: class Meta:
@@ -235,9 +235,9 @@ class EventChecklistForm(forms.ModelForm):
pk = int(key.split('_')[1]) pk = int(key.split('_')[1])
for field in other_fields: for field in other_fields:
value = self.data['{}_{}'.format(field, pk)] value = self.data[f'{field}_{pk}']
if value == '': if value == '':
raise forms.ValidationError('Add a {} to crewmember {}'.format(field, pk), code='{}_mismatch'.format(field)) raise forms.ValidationError(f'Add a {field} to crewmember {pk}', code=f'{field}_mismatch')
try: try:
item = models.EventChecklistCrew.objects.get(pk=pk) item = models.EventChecklistCrew.objects.get(pk=pk)

View File

@@ -8,6 +8,7 @@ from urllib.parse import urlparse
import pytz import pytz
from django import forms from django import forms
from django.db.models import Q, F
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -20,6 +21,17 @@ from reversion.models import Version
from versioning.versioning import RevisionMixin from versioning.versioning import RevisionMixin
def filter_by_pk(filt, query):
# try and parse an int
try:
val = int(query)
filt = filt | Q(pk=val)
except: # noqa
# not an integer
pass
return filt
class Profile(AbstractUser): class Profile(AbstractUser):
initials = models.CharField(max_length=5, null=True, blank=False) initials = models.CharField(max_length=5, null=True, blank=False)
phone = models.CharField(max_length=13, blank=True, default='') phone = models.CharField(max_length=13, blank=True, default='')
@@ -51,7 +63,7 @@ class Profile(AbstractUser):
def name(self): def name(self):
name = self.get_full_name() name = self.get_full_name()
if self.initials: if self.initials:
name += ' "{}"'.format(self.initials) name += f' "{self.initials}"'
return name return name
@property @property
@@ -70,15 +82,28 @@ class Profile(AbstractUser):
return self.name return self.name
class ContactableManager(models.Manager):
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = Q(name__icontains=query) | Q(email__icontains=query) | Q(address__icontains=query) | Q(notes__icontains=query) | Q(
phone__startswith=query) | Q(phone__endswith=query)
or_lookup = filter_by_pk(or_lookup, query)
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
class Person(models.Model, RevisionMixin): class Person(models.Model, RevisionMixin):
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
phone = models.CharField(max_length=15, blank=True, default='') phone = models.CharField(max_length=15, blank=True, default='')
email = models.EmailField(blank=True, default='') email = models.EmailField(blank=True, default='')
address = models.TextField(blank=True, default='') address = models.TextField(blank=True, default='')
notes = models.TextField(blank=True, default='') notes = models.TextField(blank=True, default='')
objects = ContactableManager()
def __str__(self): def __str__(self):
string = self.name string = self.name
if self.notes is not None: if self.notes is not None:
@@ -110,12 +135,12 @@ class Organisation(models.Model, RevisionMixin):
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
phone = models.CharField(max_length=15, blank=True, default='') phone = models.CharField(max_length=15, blank=True, default='')
email = models.EmailField(blank=True, default='') email = models.EmailField(blank=True, default='')
address = models.TextField(blank=True, default='') address = models.TextField(blank=True, default='')
notes = models.TextField(blank=True, default='') notes = models.TextField(blank=True, default='')
union_account = models.BooleanField(default=False) union_account = models.BooleanField(default=False)
objects = ContactableManager()
def __str__(self): def __str__(self):
string = self.name string = self.name
if self.notes is not None: if self.notes is not None:
@@ -184,9 +209,10 @@ class Venue(models.Model, RevisionMixin):
email = models.EmailField(blank=True, default='') email = models.EmailField(blank=True, default='')
three_phase_available = models.BooleanField(default=False) three_phase_available = models.BooleanField(default=False)
notes = models.TextField(blank=True, default='') notes = models.TextField(blank=True, default='')
address = models.TextField(blank=True, default='') address = models.TextField(blank=True, default='')
objects = ContactableManager()
def __str__(self): def __str__(self):
string = self.name string = self.name
if self.notes and len(self.notes) > 0: if self.notes and len(self.notes) > 0:
@@ -260,6 +286,23 @@ class EventManager(models.Manager):
return events return events
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = Q(name__icontains=query) | Q(description__icontains=query) | Q(notes__icontains=query)
or_lookup = filter_by_pk(or_lookup, query)
try:
if query[0] == "N":
val = int(query[1:])
or_lookup = Q(pk=val) # If string is N###### then do a simple PK filter
except: # noqa
pass
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
@reversion.register(follow=['items']) @reversion.register(follow=['items'])
class Event(models.Model, RevisionMixin): class Event(models.Model, RevisionMixin):
@@ -314,10 +357,8 @@ class Event(models.Model, RevisionMixin):
def display_id(self): def display_id(self):
if self.pk: if self.pk:
if self.is_rig: if self.is_rig:
return str("N%05d" % self.pk) return f"N{self.pk:05d}"
return self.pk return self.pk
return "????" return "????"
# Calculated values # Calculated values
@@ -530,6 +571,34 @@ class InvoiceManager(models.Manager):
query = self.raw(sql) query = self.raw(sql)
return query return query
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = Q(event__name__icontains=query)
or_lookup = filter_by_pk(or_lookup, query)
# try and parse an int
try:
val = int(query)
or_lookup = or_lookup | Q(event__pk=val)
except: # noqa
# not an integer
pass
try:
if query[0] == "N":
val = int(query[1:])
or_lookup = Q(event__pk=val) # If string is Nxxxxx then filter by event number
elif query[0] == "#":
val = int(query[1:])
or_lookup = Q(pk=val) # If string is #xxxxx then filter by invoice number
except: # noqa
pass
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
@reversion.register(follow=['payment_set']) @reversion.register(follow=['payment_set'])
class Invoice(models.Model, RevisionMixin): class Invoice(models.Model, RevisionMixin):
@@ -572,11 +641,11 @@ class Invoice(models.Model, RevisionMixin):
return f"#{self.display_id} for Event {self.event.display_id}" return f"#{self.display_id} for Event {self.event.display_id}"
def __str__(self): def __str__(self):
return "%i: %s (%.2f)" % (self.pk, self.event, self.balance) return f"{self.display_id}: {self.event}{self.balance:.2f})"
@property @property
def display_id(self): def display_id(self):
return "{:05d}".format(self.pk) return f"#{self.pk:05d}"
class Meta: class Meta:
ordering = ['-invoice_date'] ordering = ['-invoice_date']
@@ -731,7 +800,7 @@ class RiskAssessment(models.Model, RevisionMixin):
return reverse('ra_detail', kwargs={'pk': self.pk}) return reverse('ra_detail', kwargs={'pk': self.pk})
def __str__(self): def __str__(self):
return "%i - %s" % (self.pk, self.event) return f"{self.pk} - {self.event}"
@reversion.register(follow=['vehicles', 'crew']) @reversion.register(follow=['vehicles', 'crew'])
@@ -813,7 +882,7 @@ class EventChecklist(models.Model, RevisionMixin):
return reverse('ec_detail', kwargs={'pk': self.pk}) return reverse('ec_detail', kwargs={'pk': self.pk})
def __str__(self): def __str__(self):
return "%i - %s" % (self.pk, self.event) return f"{self.pk} - {self.event}"
@reversion.register @reversion.register
@@ -825,7 +894,7 @@ class EventChecklistVehicle(models.Model, RevisionMixin):
reversion_hide = True reversion_hide = True
def __str__(self): def __str__(self):
return "{} driven by {}".format(self.vehicle, str(self.driver)) return f"{self.vehicle} driven by {self.driver}"
@reversion.register @reversion.register
@@ -843,4 +912,4 @@ class EventChecklistCrew(models.Model, RevisionMixin):
raise ValidationError('Unless you\'ve invented time travel, crew can\'t finish before they have started.') raise ValidationError('Unless you\'ve invented time travel, crew can\'t finish before they have started.')
def __str__(self): def __str__(self):
return "{} ({})".format(str(self.crewmember), self.role) return f"{self.crewmember} ({self.role})"

View File

@@ -54,7 +54,7 @@ def send_eventauthorisation_success_email(instance):
elif instance.event.organisation is not None and instance.email == instance.event.organisation.email: elif instance.event.organisation is not None and instance.email == instance.event.organisation.email:
context['to_name'] = instance.event.organisation.name context['to_name'] = instance.event.organisation.name
subject = "N%05d | %s - Event Authorised" % (instance.event.pk, instance.event.name) subject = f"{instance.event.display_id} | {instance.event.name} - Event Authorised"
client_email = EmailMultiAlternatives( client_email = EmailMultiAlternatives(
subject, subject,
@@ -70,7 +70,7 @@ def send_eventauthorisation_success_email(instance):
escapedEventName = re.sub(r'[^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), client_email.attach(f'{instance.event.display_id} - {escapedEventName} - CONFIRMATION.pdf',
merged.getvalue(), merged.getvalue(),
'application/pdf' 'application/pdf'
) )
@@ -116,7 +116,7 @@ def send_admin_awaiting_approval_email(user, request, **kwargs):
} }
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
"%s new users awaiting approval on RIGS" % (context['number_of_users']), f"{context['number_of_users']} new users awaiting approval on RIGS",
get_template("admin_awaiting_approval.txt").render(context), get_template("admin_awaiting_approval.txt").render(context),
to=[admin.email], to=[admin.email],
reply_to=[user.email], reply_to=[user.email],

View File

@@ -28,7 +28,8 @@ class InvoiceIndex(generic.ListView):
total = 0 total = 0
for i in context['object_list']: for i in context['object_list']:
total += i.balance total += i.balance
context['page_title'] = "Outstanding Invoices ({} Events, £{:.2f})".format(len(list(context['object_list'])), total) event_count = len(list(context['object_list']))
context['page_title'] = f"Outstanding Invoices ({event_count} Events, £{total:.2f})"
context['description'] = "Paperwork for these events has been sent to treasury, but the full balance has not yet appeared on a ledger" context['description'] = "Paperwork for these events has been sent to treasury, but the full balance has not yet appeared on a ledger"
return context return context
@@ -43,7 +44,7 @@ class InvoiceDetail(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
invoice_date = self.object.invoice_date.strftime("%d/%m/%Y") invoice_date = self.object.invoice_date.strftime("%d/%m/%Y")
context['page_title'] = f"Invoice {self.object.display_id} ({invoice_date}) " context['page_title'] = f"Invoice {self.object.display_id} ({invoice_date})"
if self.object.void: if self.object.void:
context['page_title'] += "<span class='badge badge-warning float-right'>VOID</span>" context['page_title'] += "<span class='badge badge-warning float-right'>VOID</span>"
elif self.object.is_closed: elif self.object.is_closed:
@@ -59,11 +60,14 @@ class InvoicePrint(generic.View):
object = invoice.event object = invoice.event
template = get_template('event_print.xml') template = get_template('event_print.xml')
name = re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name)
filename = f"Invoice {invoice.display_id} for {object.display_id} {name}.pdf"
context = { context = {
'object': object, 'object': object,
'invoice': invoice, 'invoice': invoice,
'current_user': request.user, 'current_user': request.user,
'filename': 'Invoice {} for {} {}.pdf'.format(invoice.display_id, object.display_id, re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name)) 'filename': filename
} }
rml = template.render(context) rml = template.render(context)
@@ -73,7 +77,7 @@ class InvoicePrint(generic.View):
pdfData = buffer.read() pdfData = buffer.read()
response = HttpResponse(content_type='application/pdf') response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'filename="{}"'.format(context['filename']) response['Content-Disposition'] = f'filename="{filename}"'
response.write(pdfData) response.write(pdfData)
return response return response
@@ -124,32 +128,7 @@ class InvoiceArchive(generic.ListView):
return context return context
def get_queryset(self): def get_queryset(self):
q = self.request.GET.get('q', "") return self.model.objects.search(self.request.GET.get('q')).order_by('-invoice_date')
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): class InvoiceWaiting(generic.ListView):
@@ -163,7 +142,7 @@ class InvoiceWaiting(generic.ListView):
objects = self.get_queryset() objects = self.get_queryset()
for obj in objects: for obj in objects:
total += obj.sum_total total += obj.sum_total
context['page_title'] = "Events for Invoice ({} Events, £{:.2f})".format(len(objects), total) context['page_title'] = f"Events for Invoice ({len(objects)} Events, £{total:.2f})"
return context return context
def get_queryset(self): def get_queryset(self):

View File

@@ -37,7 +37,7 @@ class EventRiskAssessmentCreate(generic.CreateView):
epk = self.kwargs.get('pk') epk = self.kwargs.get('pk')
event = models.Event.objects.get(pk=epk) event = models.Event.objects.get(pk=epk)
context['event'] = event context['event'] = event
context['page_title'] = 'Create Risk Assessment for Event {}'.format(event.display_id) context['page_title'] = f'Create Risk Assessment for Event {event.display_id}'
return context return context
def get_success_url(self): def get_success_url(self):
@@ -62,7 +62,7 @@ class EventRiskAssessmentEdit(generic.UpdateView):
ra = models.RiskAssessment.objects.get(pk=rpk) ra = models.RiskAssessment.objects.get(pk=rpk)
context['event'] = ra.event context['event'] = ra.event
context['edit'] = True context['edit'] = True
context['page_title'] = 'Edit Risk Assessment for Event {}'.format(ra.event.display_id) context['page_title'] = f'Edit Risk Assessment for Event {ra.event.display_id}'
return context return context
@@ -72,7 +72,7 @@ class EventRiskAssessmentDetail(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(EventRiskAssessmentDetail, self).get_context_data(**kwargs) context = super(EventRiskAssessmentDetail, self).get_context_data(**kwargs)
context['page_title'] = "Risk Assessment for Event <a href='{}'>{} {}</a>".format(self.object.event.get_absolute_url(), self.object.event.display_id, self.object.event.name) context['page_title'] = f"Risk Assessment for Event <a href='{self.object.event.get_absolute_url()}'>{self.object.event.display_id} {self.object.event.name}</a>"
return context return context
@@ -112,7 +112,7 @@ class EventChecklistDetail(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(EventChecklistDetail, self).get_context_data(**kwargs) context = super(EventChecklistDetail, self).get_context_data(**kwargs)
context['page_title'] = "Event Checklist for Event <a href='{}'>{} {}</a>".format(self.object.event.get_absolute_url(), self.object.event.display_id, self.object.event.name) context['page_title'] = f"Event Checklist for Event <a href='{self.object.event.get_absolute_url()}'>{self.object.event.display_id} {self.object.event.name}</a>"
return context return context
@@ -134,7 +134,7 @@ class EventChecklistEdit(generic.UpdateView):
ec = models.EventChecklist.objects.get(pk=pk) ec = models.EventChecklist.objects.get(pk=pk)
context['event'] = ec.event context['event'] = ec.event
context['edit'] = True context['edit'] = True
context['page_title'] = 'Edit Event Checklist for Event {}'.format(ec.event.display_id) context['page_title'] = f'Edit Event Checklist for Event {ec.event.display_id}'
form = context['form'] form = context['form']
# Get some other objects to include in the form. Used when there are errors but also nice and quick. # 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.items(): for field, model in form.related_models.items():
@@ -158,7 +158,7 @@ class EventChecklistCreate(generic.CreateView):
ra = models.RiskAssessment.objects.filter(event=event).first() ra = models.RiskAssessment.objects.filter(event=event).first()
if ra is None: if ra is None:
messages.error(self.request, 'A Risk Assessment must exist prior to creating any Event Checklists for {}! Please create one now.'.format(event)) messages.error(self.request, f'A Risk Assessment must exist prior to creating any Event Checklists for {event}! Please create one now.')
return HttpResponseRedirect(reverse_lazy('event_ra', kwargs={'pk': epk})) return HttpResponseRedirect(reverse_lazy('event_ra', kwargs={'pk': epk}))
return super(EventChecklistCreate, self).get(self) return super(EventChecklistCreate, self).get(self)
@@ -175,7 +175,7 @@ class EventChecklistCreate(generic.CreateView):
epk = self.kwargs.get('pk') epk = self.kwargs.get('pk')
event = models.Event.objects.get(pk=epk) event = models.Event.objects.get(pk=epk)
context['event'] = event context['event'] = event
context['page_title'] = 'Create Event Checklist for Event {}'.format(event.display_id) context['page_title'] = f'Create Event Checklist for Event {event.display_id}'
return context return context
def get_success_url(self): def get_success_url(self):

View File

@@ -188,11 +188,14 @@ class EventPrint(generic.View):
user_str = f"by {request.user.name} " if request.user is not None else "" user_str = f"by {request.user.name} " if request.user is not None else ""
time = timezone.now().strftime('%d/%m/%Y %H:%I') time = timezone.now().strftime('%d/%m/%Y %H:%I')
name = re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name)
filename = f"Event_{object.display_id}_{name}_{object.start_date}.pdf"
context = { context = {
'object': object, 'object': object,
'quote': True, 'quote': True,
'current_user': request.user, 'current_user': request.user,
'filename': 'Event_{}_{}_{}.pdf'.format(object.display_id, re.sub(r'[^a-zA-Z0-9 \n\.]', '', object.name), object.start_date), 'filename': filename,
'info_string': f"[Paperwork generated {user_str}on {time} - {object.current_version_id}]", 'info_string': f"[Paperwork generated {user_str}on {time} - {object.current_version_id}]",
} }
@@ -208,7 +211,7 @@ class EventPrint(generic.View):
merger.write(merged) merger.write(merged)
response = HttpResponse(content_type='application/pdf') response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'filename="{}"'.format(context['filename']) response['Content-Disposition'] = f'filename="{filename}"'
response.write(merged.getvalue()) response.write(merged.getvalue())
return response return response
@@ -244,32 +247,17 @@ class EventArchive(generic.ListView):
filter &= Q(start_date__gte=start) filter &= Q(start_date__gte=start)
q = self.request.GET.get('q', "") q = self.request.GET.get('q', "")
objects = self.model.objects.all()
if q != "": if q:
qfilter = Q(name__icontains=q) | Q(description__icontains=q) | Q(notes__icontains=q) objects = self.model.objects.search(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
status = self.request.GET.getlist('status', "") status = self.request.GET.getlist('status', "")
if len(status) > 0: if len(status) > 0:
filter &= Q(status__in=status) filter &= Q(status__in=status)
qs = self.model.objects.filter(filter).order_by('-start_date') qs = objects.filter(filter).order_by('-start_date')
# Preselect related for efficiency # Preselect related for efficiency
qs.select_related('person', 'organisation', 'venue', 'mic') qs.select_related('person', 'organisation', 'venue', 'mic')
@@ -393,7 +381,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix
context['to_name'] = event.organisation.name context['to_name'] = event.organisation.name
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
"N%05d | %s - Event Authorisation Request" % (self.object.pk, self.object.name), f"{self.object.display_id} | {self.object.name} - Event Authorisation Request",
get_template("eventauthorisation_client_request.txt").render(context), get_template("eventauthorisation_client_request.txt").render(context),
to=[email], to=[email],
reply_to=[self.request.user.email], reply_to=[self.request.user.email],

View File

@@ -2,11 +2,12 @@ import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, connection from django.db import models, connection
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from reversion import revisions as reversion from reversion import revisions as reversion
from reversion.models import Version from reversion.models import Version
from RIGS.models import Profile from RIGS.models import Profile, ContactableManager
from versioning.versioning import RevisionMixin from versioning.versioning import RevisionMixin
@@ -46,6 +47,8 @@ class Supplier(models.Model, RevisionMixin):
notes = models.TextField(blank=True, default="") notes = models.TextField(blank=True, default="")
objects = ContactableManager()
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@@ -107,6 +110,15 @@ def get_available_asset_id(wanted_prefix=""):
cursor.close() cursor.close()
class AssetManager(models.Manager):
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = (Q(asset_id__exact=query.upper()) | Q(description__icontains=query) | Q(serial_number__exact=query))
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
@reversion.register @reversion.register
class Asset(models.Model, RevisionMixin): class Asset(models.Model, RevisionMixin):
parent = models.ForeignKey(to='self', related_name='asset_parent', parent = models.ForeignKey(to='self', related_name='asset_parent',
@@ -142,6 +154,8 @@ class Asset(models.Model, RevisionMixin):
reversion_perm = 'assets.asset_finance' reversion_perm = 'assets.asset_finance'
objects = AssetManager()
class Meta: class Meta:
ordering = ['asset_id_prefix', 'asset_id_number'] ordering = ['asset_id_prefix', 'asset_id_number']
permissions = [ permissions = [

View File

@@ -75,13 +75,13 @@
<div class="col"> <div class="col">
<div id="category-group" class="form-group px-1" style="margin-bottom: 0;"> <div id="category-group" class="form-group px-1" style="margin-bottom: 0;">
<label for="category" class="sr-only">Category</label> <label for="category" class="sr-only">Category</label>
{% render_field form.category|attr:'multiple'|add_class:'form-control custom-select selectpicker col-sm' data-none-selected-text="Categories" data-header="Categories" data-actions-box="true" %} {% render_field form.category|attr:'multiple'|add_class:'selectpicker col-sm' data-none-selected-text="Categories" data-header="Categories" data-actions-box="true" %}
</div> </div>
</div> </div>
<div class="col"> <div class="col">
<div id="status-group" class="form-group px-1" style="margin-bottom: 0;"> <div id="status-group" class="form-group px-1" style="margin-bottom: 0;">
<label for="status" class="sr-only">Status</label> <label for="status" class="sr-only">Status</label>
{% render_field form.status|attr:'multiple'|add_class:'form-control custom-select selectpicker col-sm' data-none-selected-text="Statuses" data-header="Statuses" data-actions-box="true" %} {% render_field form.status|attr:'multiple'|add_class:'selectpicker col-sm' data-none-selected-text="Statuses" data-header="Statuses" data-actions-box="true" %}
</div> </div>
</div> </div>
<div class="col mt-2"> <div class="col mt-2">

View File

@@ -20,7 +20,7 @@ urlpatterns = [
path('asset/id/<asset:pk>/duplicate/', permission_required_with_403('assets.add_asset') path('asset/id/<asset:pk>/duplicate/', permission_required_with_403('assets.add_asset')
(views.AssetDuplicate.as_view()), name='asset_duplicate'), (views.AssetDuplicate.as_view()), name='asset_duplicate'),
path('asset/id/<asset:pk>/label', login_required(views.GenerateLabel.as_view()), name='generate_label'), path('asset/id/<asset:pk>/label', login_required(views.GenerateLabel.as_view()), name='generate_label'),
path('asset/<list:ids>/list/label', views.GenerateLabels.as_view(), name='generate_labels'), path('asset/<list:ids>/list/label', views.GenerateLabels.as_view(), name='generate_labels'),
path('cabletype/list/', login_required(views.CableTypeList.as_view()), name='cable_type_list'), path('cabletype/list/', login_required(views.CableTypeList.as_view()), name='cable_type_list'),
path('cabletype/create/', permission_required_with_403('assets.add_cable_type')(views.CableTypeCreate.as_view()), name='cable_type_create'), path('cabletype/create/', permission_required_with_403('assets.add_cable_type')(views.CableTypeCreate.as_view()), name='cable_type_create'),

View File

@@ -50,13 +50,7 @@ class AssetList(LoginRequiredMixin, generic.ListView):
# TODO Feedback to user when search fails # TODO Feedback to user when search fails
query_string = form.cleaned_data['q'] or "" query_string = form.cleaned_data['q'] or ""
if len(query_string) == 0: queryset = models.Asset.objects.search(query=query_string)
queryset = self.model.objects.all()
elif len(query_string) >= 3:
queryset = self.model.objects.filter(
Q(asset_id__exact=query_string.upper()) | Q(description__icontains=query_string) | Q(serial_number__exact=query_string))
else:
queryset = self.model.objects.filter(Q(asset_id__exact=query_string.upper()))
if form.cleaned_data['is_cable']: if form.cleaned_data['is_cable']:
queryset = queryset.filter(is_cable=True) queryset = queryset.filter(is_cable=True)
@@ -176,6 +170,7 @@ class AssetOEmbed(OEmbedView):
class AssetAuditList(AssetList): class AssetAuditList(AssetList):
template_name = 'asset_audit_list.html' template_name = 'asset_audit_list.html'
hide_hidden_status = True
# TODO Refresh this when the modal is submitted # TODO Refresh this when the modal is submitted
def get_queryset(self): def get_queryset(self):
@@ -388,12 +383,14 @@ class GenerateLabels(generic.View):
base64_encoded_result_str = base64_encoded_result_bytes.decode('ascii') base64_encoded_result_str = base64_encoded_result_bytes.decode('ascii')
images.append(base64_encoded_result_str) images.append(base64_encoded_result_str)
name = f"Asset Label Sheet generated at {timezone.now()}"
context = { context = {
'images0': images[::4], 'images0': images[::4],
'images1': images[1::4], 'images1': images[1::4],
'images2': images[2::4], 'images2': images[2::4],
'images3': images[3::4], 'images3': images[3::4],
'filename': "Asset Label Sheet generated at {}".format(timezone.now()) 'filename': name
} }
merger = PdfFileMerger() merger = PdfFileMerger()
@@ -405,6 +402,6 @@ class GenerateLabels(generic.View):
merged = BytesIO() merged = BytesIO()
merger.write(merged) merger.write(merged)
response['Content-Disposition'] = 'filename="{}"'.format(context['filename']) response['Content-Disposition'] = f'filename="{name}"'
response.write(merged.getvalue()) response.write(merged.getvalue())
return response return response

View File

@@ -1,38 +1,11 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<form id="searchForm" class="form-inline flex-nowrap mx-md-3 px-2 border border-light rounded w-75" role="form" method="GET" action="{% url 'event_archive' %}"> <form id="searchForm" class="form-inline flex-nowrap mx-md-3 px-2 border border-light rounded" role="form" method="GET" action="{% url 'search' %}">
<div class="input-group input-group-sm flex-nowrap"> <div class="input-group">
<div class="input-group-prepend"> <input id="id_search_input" type="search" name="q" class="form-control form-control-sm" placeholder="Search..." value="{{ request.GET.q }}" />
<input id="id_search_input" type="search" name="q" class="form-control form-control-sm" placeholder="Search..." value="{{ request.GET.q }}" /> <div class="input-group-append">
<button class="btn btn-info form-control form-control-sm btn-sm"><span class="fas fa-search"></span><span class="sr-only"> Search</span></button>
</div> </div>
<select id="search-options" class="custom-select form-control" style="border-top-right-radius: 0px; border-bottom-right-radius: 0px; width: 15ch;">
<option selected data-action="{% url 'event_archive' %}" href="#">Events</option>
<option data-action="{% url 'person_list' %}" href="#">People</option>
<option data-action="{% url 'organisation_list' %}" href="#">Organisations</option>
<option data-action="{% url 'venue_list' %}" href="#">Venues</option>
{% if perms.RIGS.view_invoice %}
<option data-action="{% url 'invoice_archive' %}" href="#">Invoices</option>
{% endif %}
<option data-action="{% url 'asset_list' %}" href="#">Assets</option>
<option data-action="{% url 'supplier_list' %}" href="#">Suppliers</option>
</select>
</div> </div>
<button class="btn btn-info form-control form-control-sm btn-sm w-25" style="border-top-left-radius: 0px;border-bottom-left-radius: 0px;"><span class="fas fa-search"></span><span class="sr-only"> Search</span></button>
<a href="{% url 'search_help' %}" class="nav-link modal-href ml-1"><span class="fas fa-question-circle"></span></a> <a href="{% url 'search_help' %}" class="nav-link modal-href ml-1"><span class="fas fa-question-circle"></span></a>
</form> </form>
{% endif %} {% endif %}
{% block js %}
<script>
$('#search-options').change(function(){
$('#searchForm').attr('action', $(this).children('option:selected').data('action'));
});
$(document).ready(function(){
$('#id_search_input').keypress(function (e) {
if (e.which == 13) {
$('#searchForm').attr('action', $('#search-options option').first().data('action')).submit();
return false;
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base_rigs.html" %}
{% load to_class_name from filters %}
{% load markdown_tags %}
{% block content %}
{% include 'partials/search.html' %}
{% for object in object_list %}
{% with object|to_class_name as klass %}
<div class="card m-2">
<h4 class="card-header"><a href='{{ object.get_absolute_url }}'>[{{ klass }}] {{ object }}</a>
<small>
{% if klass == "Event" %}
{% if object.venue %}
<strong>Venue:</strong> {{ object.venue }}
{% endif %}
{% 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 %}
<strong>Times:</strong>
{{ object.start_date|date:"D d/m/Y" }}
{% if object.has_start_time %}
{{ object.start_time|date:"H:i" }}
{% endif %}
{% if object.end_date or object.has_end_time %}
&ndash;
{% endif %}
{% if object.end_date and object.end_date != object.start_date %}
{{ object.end_date|date:"D d/m/Y" }}
{% endif %}
{% if object.has_end_time %}
{{ object.end_time|date:"H:i" }}
{% endif %}
{% endif %}
</small>
</h4>
</div>
{% endwith %}
{% empty %}
<h3 class="py-3 text-warning">No results found</h3>
{% endfor %}
{% endblock content %}

View File

@@ -1,6 +1,7 @@
from RIGS.models import Profile from RIGS.models import Profile, filter_by_pk
from reversion import revisions as reversion from reversion import revisions as reversion
from django.db import models from django.db import models
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from versioning.versioning import RevisionMixin from versioning.versioning import RevisionMixin
@@ -12,6 +13,16 @@ class TraineeManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(is_active=True, is_approved=True) return super().get_queryset().filter(is_active=True, is_approved=True)
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = (Q(first_name__icontains=query) |
Q(last_name__icontains=query) | Q(initials__icontains=query)
)
or_lookup = filter_by_pk(or_lookup, query)
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
@reversion.register(for_concrete_model=False, fields=['is_supervisor']) @reversion.register(for_concrete_model=False, fields=['is_supervisor'])
class Trainee(Profile, RevisionMixin): class Trainee(Profile, RevisionMixin):
@@ -65,6 +76,16 @@ class TrainingCategory(models.Model):
verbose_name_plural = 'Training Categories' verbose_name_plural = 'Training Categories'
class TrainingItemManager(QueryablePropertiesManager):
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = (Q(description__icontains=query)
)
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs
@reversion.register @reversion.register
class TrainingItem(models.Model): class TrainingItem(models.Model):
reference_number = models.IntegerField() reference_number = models.IntegerField()
@@ -72,7 +93,7 @@ class TrainingItem(models.Model):
description = models.CharField(max_length=50) description = models.CharField(max_length=50)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
objects = QueryablePropertiesManager() objects = TrainingItemManager()
@property @property
def name(self): def name(self):
@@ -97,6 +118,9 @@ class TrainingItem(models.Model):
name += " (inactive)" name += " (inactive)"
return name return name
def get_absolute_url(self):
return reverse('item_list')
@staticmethod @staticmethod
def user_has_qualification(item, user, depth): def user_has_qualification(item, user, depth):
return user.qualifications_obtained.only('item', 'depth').filter(item=item, depth__gte=depth).exists() return user.qualifications_obtained.only('item', 'depth').filter(item=item, depth__gte=depth).exists()

View File

@@ -95,23 +95,13 @@ class TraineeList(generic.ListView):
paginate_by = 25 paginate_by = 25
def get_queryset(self): def get_queryset(self):
q = self.request.GET.get('q', "") objects = self.model.objects
filt = Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(initials__icontains=q)
# try and parse an int
try:
val = int(q)
filt = filt | Q(pk=val)
except: # noqa
# not an integer
pass
if self.request.GET.get('is_supervisor', ''): if self.request.GET.get('is_supervisor', ''):
filt = filt & Q(is_supervisor=True) objects = objects.filter(is_supervisor=True)
return self.model.objects.filter(filt).annotate(num_qualifications=Count('qualifications_obtained', filter=Q(qualifications_obtained__depth=models.TrainingItemQualification.PASSED_OUT)) return objects.search(self.request.GET.get('q')).annotate(num_qualifications=Count('qualifications_obtained', filter=Q(qualifications_obtained__depth=models.TrainingItemQualification.PASSED_OUT))
).order_by('-num_qualifications').prefetch_related('level_qualifications', 'qualifications_obtained', 'qualifications_obtained__item') ).order_by('-num_qualifications').prefetch_related('level_qualifications', 'qualifications_obtained', 'qualifications_obtained__item')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)

View File

@@ -41,7 +41,7 @@ class ProfileDetail(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(ProfileDetail, self).get_context_data(**kwargs) context = super(ProfileDetail, self).get_context_data(**kwargs)
context['page_title'] = "Profile: {}".format(self.object) context['page_title'] = f"Profile: {self.object}"
context["completed_levels"] = self.object.level_qualifications.all().select_related('level') context["completed_levels"] = self.object.level_qualifications.all().select_related('level')
return context return context