Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions[bot]
9b16cf6333 Merge dependabot/npm_and_yarn/engine.io-and-browser-sync-6.2.0 into combine-prs-branch 2022-11-14 18:21:26 +00:00
dependabot[bot]
7798f5c368 Build(deps): Bump engine.io and browser-sync
Bumps [engine.io](https://github.com/socketio/engine.io) to 6.2.0 and updates ancestor dependency [browser-sync](https://github.com/BrowserSync/browser-sync). These dependencies need to be updated together.


Updates `engine.io` from 3.5.0 to 6.2.0
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/3.5.0...6.2.0)

Updates `browser-sync` from 2.27.7 to 2.27.10
- [Release notes](https://github.com/BrowserSync/browser-sync/releases)
- [Changelog](https://github.com/BrowserSync/browser-sync/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BrowserSync/browser-sync/compare/v2.27.7...v2.27.10)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-type: indirect
- dependency-name: browser-sync
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-14 15:14:28 +00:00
dependabot[bot]
5c2e8b391c Build(deps): Bump moment from 2.29.2 to 2.29.4 (#505)
Bumps [moment](https://github.com/moment/moment) from 2.29.2 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.2...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-14 15:08:05 +00:00
dependabot[bot]
548bc1df81 Build(deps): Bump socket.io-parser from 3.3.2 to 3.3.3 (#503)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-13 16:17:08 +00:00
dependabot[bot]
c1d2bce8fb Build(deps): Bump minimatch from 3.0.4 to 3.0.8 (#504)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.0.8.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.0.8)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-13 16:16:51 +00:00
c71beab278 Change: Only supervisors have edit access to the training database 2022-10-24 16:23:46 +01:00
259932a548 FIX #502: Possibility to choose 'no selection' in session log form
Ref #501...may help/fix this...uncertain yet. Need to finish writing the relevant test!
2022-10-23 10:56:54 +01:00
7526485837 FEAT: Add periodic cleanup command
Currently performs two functions:
1. Inactivates users that have not logged in for at least one year. Closes #478 (Need to circle back round to full deletion SoonTM)
2. Ensures the supervisor database flag is set correctly for each user

This is run automatically by the Heroku Scheduler addon at midnight daily.
2022-10-21 00:05:20 +01:00
39ed5aefb4 Set printed PDF title == filename
Should fix #497
2022-10-18 12:48:25 +01:00
31 changed files with 770 additions and 1147 deletions

View File

@@ -124,21 +124,6 @@ class EventForm(forms.ModelForm):
'purchase_order', 'collector']
class SubhireForm(forms.ModelForm):
related_models = {
'person': models.Person,
'organisation': models.Organisation,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end_date'].widget.format = '%Y-%m-%d'
class Meta:
model = models.Subhire
fields = '__all__'
class BaseClientEventAuthorisationForm(forms.ModelForm):
tos = forms.BooleanField(required=True, label="Terms of hire")
name = forms.CharField(label="Your Name")

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-10-20 23:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0044_profile_is_supervisor'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='is_approved',
field=models.BooleanField(default=False, help_text='Designates whether a staff member has approved this user.', verbose_name='Approval Status'),
),
]

View File

@@ -36,7 +36,7 @@ class Profile(AbstractUser):
initials = models.CharField(max_length=5, null=True, blank=False)
phone = models.CharField(max_length=13, blank=True, default='')
api_key = models.CharField(max_length=40, blank=True, editable=False, default='')
is_approved = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False, verbose_name="Approval Status", help_text="Designates whether a staff member has approved this user.")
# Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that...
last_emailed = models.DateTimeField(blank=True, null=True)
dark_theme = models.BooleanField(default=False)
@@ -304,29 +304,8 @@ class EventManager(models.Manager):
return qs
def find_earliest_event_time(event, datetime_list):
# If there is no start time defined, pretend it's midnight
startTimeFaked = False
if event.has_start_time:
startDateTime = datetime.datetime.combine(event.start_date, event.start_time)
else:
startDateTime = datetime.datetime.combine(event.start_date, datetime.time(00, 00))
startTimeFaked = True
# timezoneIssues - apply the default timezone to the naiive datetime
tz = pytz.timezone(settings.TIME_ZONE)
startDateTime = tz.localize(startDateTime)
datetime_list.append(startDateTime) # then add it to the list
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
# if we faked it & it's the earliest, better own up
if startTimeFaked and earliest == startDateTime:
return event.start_date
return earliest
class BaseEvent(models.Model, RevisionMixin):
@reversion.register(follow=['items'])
class Event(models.Model, RevisionMixin):
# Done to make it much nicer on the database
PROVISIONAL = 0
CONFIRMED = 1
@@ -342,85 +321,31 @@ class BaseEvent(models.Model, RevisionMixin):
name = models.CharField(max_length=255)
person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE)
organisation = models.ForeignKey('Organisation', blank=True, null=True, on_delete=models.CASCADE)
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
description = models.TextField(blank=True, default='')
notes = models.TextField(blank=True, default='')
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL)
dry_hire = models.BooleanField(default=False)
is_rig = models.BooleanField(default=True)
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
null=True)
# Timing
start_date = models.DateField()
start_time = models.TimeField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
end_time = models.TimeField(blank=True, null=True)
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
class Meta:
abstract = True
@property
def cancelled(self):
return (self.status == self.CANCELLED)
@property
def confirmed(self):
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
@property
def has_start_time(self):
return self.start_time is not None
@property
def has_end_time(self):
return self.end_time is not None
@property
def latest_time(self):
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
tz = pytz.timezone(settings.TIME_ZONE)
endDate = self.end_date
if endDate is None:
endDate = self.start_date
if self.has_end_time:
endDateTime = datetime.datetime.combine(endDate, self.end_time)
tz = pytz.timezone(settings.TIME_ZONE)
endDateTime = tz.localize(endDateTime)
return endDateTime
else:
return endDate
def clean(self):
errdict = {}
if self.end_date and self.start_date > self.end_date:
errdict['end_date'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
startEndSameDay = not self.end_date or self.end_date == self.start_date
hasStartAndEnd = self.has_start_time and self.has_end_time
if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time:
errdict['end_time'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
return errdict
@reversion.register(follow=['items'])
class Event(BaseEvent):
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
verbose_name="MIC", on_delete=models.CASCADE)
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
notes = models.TextField(blank=True, default='')
dry_hire = models.BooleanField(default=False)
is_rig = models.BooleanField(default=True)
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
null=True)
access_at = models.DateTimeField(blank=True, null=True)
meet_at = models.DateTimeField(blank=True, null=True)
# Dry-hire only
# Crew management
checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True,
on_delete=models.CASCADE)
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
verbose_name="MIC", on_delete=models.CASCADE)
# Monies
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
collector = models.CharField(max_length=255, blank=True, default='', verbose_name='collected by')
# Authorisation request details
@@ -470,10 +395,26 @@ class Event(BaseEvent):
def total(self):
return Decimal(self.sum_total + self.vat).quantize(Decimal('.01'))
@property
def cancelled(self):
return (self.status == self.CANCELLED)
@property
def confirmed(self):
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
@property
def hs_done(self):
return self.riskassessment is not None and len(self.checklists.all()) > 0
@property
def has_start_time(self):
return self.start_time is not None
@property
def has_end_time(self):
return self.end_time is not None
@property
def earliest_time(self):
"""Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
@@ -487,10 +428,45 @@ class Event(BaseEvent):
if self.meet_at:
datetime_list.append(self.meet_at)
earliest = find_earliest_event_time(self, datetime_list)
# If there is no start time defined, pretend it's midnight
startTimeFaked = False
if self.has_start_time:
startDateTime = datetime.datetime.combine(self.start_date, self.start_time)
else:
startDateTime = datetime.datetime.combine(self.start_date, datetime.time(00, 00))
startTimeFaked = True
# timezoneIssues - apply the default timezone to the naiive datetime
tz = pytz.timezone(settings.TIME_ZONE)
startDateTime = tz.localize(startDateTime)
datetime_list.append(startDateTime) # then add it to the list
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
# if we faked it & it's the earliest, better own up
if startTimeFaked and earliest == startDateTime:
return self.start_date
return earliest
@property
def latest_time(self):
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
tz = pytz.timezone(settings.TIME_ZONE)
endDate = self.end_date
if endDate is None:
endDate = self.start_date
if self.has_end_time:
endDateTime = datetime.datetime.combine(endDate, self.end_time)
tz = pytz.timezone(settings.TIME_ZONE)
endDateTime = tz.localize(endDateTime)
return endDateTime
else:
return endDate
@property
def internal(self):
return bool(self.organisation and self.organisation.union_account)
@@ -511,7 +487,14 @@ class Event(BaseEvent):
return f"{self.display_id}: {self.name}"
def clean(self):
errdict = super.clean()
errdict = {}
if self.end_date and self.start_date > self.end_date:
errdict['end_date'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
startEndSameDay = not self.end_date or self.end_date == self.start_date
hasStartAndEnd = self.has_start_time and self.has_end_time
if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time:
errdict['end_time'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
if self.access_at is not None:
if self.access_at.date() > self.start_date:
@@ -572,10 +555,6 @@ class EventAuthorisation(models.Model, RevisionMixin):
return f"{self.event.display_id} (requested by {self.sent_by.initials})"
class Subhire(BaseEvent):
insurance_value = models.DecimalField(max_digits=10, decimal_places=2) # TODO Validate if this is over notifiable threshold
# TODO Associated events
class InvoiceManager(models.Manager):
def outstanding_invoices(self):
# Manual query is the only way I have found to do this efficiently. Not ideal but needs must

View File

@@ -84,7 +84,7 @@
bulletFontSize="10"/>
</stylesheet>
<template > {# Note: page is 595x842 points (1 point=1/72in) #}
<template title="{{filename}}"> {# Note: page is 595x842 points (1 point=1/72in) #}
<pageTemplate id="Headed" >
<pageGraphics>
<image file="static/imgs/paperwork/corner-tr-su.jpg" x="395" y="642" height="200" width="200"/>

View File

@@ -30,8 +30,6 @@
{% if perms.RIGS.add_event %}
<a class="dropdown-item" href="{% url 'event_create' %}"><span class="fas fa-plus"></span>
New Event</a>
<a class="dropdown-item" href="{% url 'subhire_create' %}"><span class="fas fa-truck"></span>
New Subhire</a>
{% endif %}
</div>
</li>

View File

@@ -1,180 +0,0 @@
{% extends 'base_rigs.html' %}
{% load widget_tweaks %}
{% load static %}
{% load multiply from filters %}
{% load button from filters %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'css/selects.css' %}"/>
<link rel="stylesheet" type="text/css" href="{% static 'css/easymde.min.css' %}">
{% endblock %}
{% block preload_js %}
{{ block.super }}
<script src="{% static 'js/selects.js' %}"></script>
<script src="{% static 'js/easymde.min.js' %}"></script>
{% endblock %}
{% block js %}
{{ block.super }}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script src="{% static 'js/interaction.js' %}"></script>
<script src="{% static 'js/tooltip.js' %}"></script>
<script>
$(document).ready(function () {
setupMDE('#id_description');
});
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});
</script>
{% endblock %}
{% block content %}
<form class="row" role="form" method="POST">
{% csrf_token %}
<div class="col-12">
{% include 'form_errors.html' %}
</div>
{# Contact details #}
<div class="col-md-6">
<div class="card">
<div class="card-header">Contact Details</div>
<div class="card-body">
<div class="form-group" data-toggle="tooltip">
<label for="{{ form.person.id_for_label }}">Primary Contact</label>
<div class="row">
<div class="col-9">
<select id="{{ form.person.id_for_label }}" name="{{ form.person.name }}" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='person' %}">
{% if person %}
<option value="{{form.person.value}}" selected="selected" data-update_url="{% url 'person_update' form.person.value %}">{{ person }}</option>
{% endif %}
</select>
</div>
<div class="col-3 align-right">
<div class="btn-group">
<a href="{% url 'person_create' %}" class="btn btn-success modal-href"
data-target="#{{ form.person.id_for_label }}">
<span class="fas fa-plus"></span>
</a>
<a {% if form.person.value %}href="{% url 'person_update' form.person.value %}"{% endif %} class="btn btn-warning modal-href" id="{{ form.person.id_for_label }}-update" data-target="#{{ form.person.id_for_label }}">
<span class="fas fa-user-edit"></span>
</a>
</div>
</div>
</div>
</div>
<div class="form-group">
<label for="{{ form.organisation.id_for_label }}">Hire Company</label>
<div class="row">
<div class="col-9">
<select id="{{ form.organisation.id_for_label }}" name="{{ form.organisation.name }}" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='organisation' %}">
{% if organisation %}
<option value="{{form.organisation.value}}" selected="selected" data-update_url="{% url 'organisation_update' form.organisation.value %}">{{ organisation }}</option>
{% endif %}
</select>
</div>
<div class="col-3 align-right">
<div class="btn-group">
<a href="{% url 'organisation_create' %}" class="btn btn-success modal-href"
data-target="#{{ form.organisation.id_for_label }}">
<span class="fas fa-plus"></span>
</a>
<a {% if form.organisation.value %}href="{% url 'organisation_update' form.organisation.value %}"{% endif %} class="btn btn-warning modal-href" id="{{ form.organisation.id_for_label }}-update" data-target="#{{ form.organisation.id_for_label }}">
<span class="fas fa-edit"></span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{# Event details #}
<div class="col-md-6 mb-2">
<div class="card card-default">
<div class="card-header">Event Details</div>
<div class="card-body">
<div class="form-group" data-toggle="tooltip" title="Name of the event, displays on rigboard and on paperwork">
<label for="{{ form.name.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.name.label }}</label>
<div class="col-sm-8">
{% render_field form.name class+="form-control" %}
</div>
</div>
<div class="form-group">
<label for="{{ form.start_date.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.start_date.label }}</label>
<div class="col-sm-8">
<div class="row">
<div class="col-sm-12 col-md-7" data-toggle="tooltip" title="Start date for event, required">
{% render_field form.start_date class+="form-control" %}
</div>
<div class="col-sm-12 col-md-5" data-toggle="tooltip" title="Start time of event, can be left blank">
{% render_field form.start_time class+="form-control" step="60" %}
</div>
</div>
</div>
</div>
<div class="form-group">
<label for="{{ form.end_date.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.end_date.label }}</label>
<div class="col-sm-8">
<div class="row">
<div class="col-sm-12 col-md-7" data-toggle="tooltip" title="End date of event, leave blank if unknown or same as start date">
{% render_field form.end_date class+="form-control" %}
</div>
<div class="col-sm-12 col-md-5" data-toggle="tooltip" title="End time of event, leave blank if unknown">
{% render_field form.end_time class+="form-control" step="60" %}
</div>
</div>
</div>
</div>
<div class="form-group" data-toggle="tooltip" title="The current status of the event. Only mark as booked once paperwork is received">
<label for="{{ form.status.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.status.label }}</label>
<div class="col-sm-8">
{% render_field form.status class+="form-control" %}
</div>
</div>
<div class="form-group">
<label for="{{ form.purchase_order.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.purchase_order.label }}</label>
<div class="col-sm-8">
{% render_field form.purchase_order class+="form-control" %}
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Equipment Information</div>
<div class="card-body">
<div class="form-group">
<label for="{{ form.description.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.description.label }}</label>
<div class="col-sm-12">
{% render_field form.description class+="form-control" %}
</div>
</div>
<div class="form-group">
<label for="{{ form.insurance_value.id_for_label }}"
class="col-sm-6 col-form-label">{{ form.insurance_value.label }}</label>
<div class="col-sm-8 input-group">
<div class="input-group-prepend"><span class="input-group-text">£</span></div>
{% render_field form.insurance_value class+="form-control" %}
</div>
<div class="border border-info p-2 rounded mt-1" style="border-width: thin thin thin thick !important;">
If this value is greater than £50,000 then please email productions@nottinghamtec.co.uk in addition to complete the additional insurance requirements
</div>
</div>
</div>
</div>
<div class="col-sm-12 text-right my-3">
{% button 'submit' %}
</div>
</form>
{% endblock %}

View File

@@ -216,6 +216,8 @@ def button(type, url=None, pk=None, clazz="", icon=None, text="", id=None, style
return {'submit': True, 'class': 'btn-info', 'icon': 'fa-search', 'text': 'Search', 'id': id, 'style': style}
elif type == 'submit':
return {'submit': True, 'class': 'btn-primary', 'icon': 'fa-save', 'text': 'Save', 'id': id, 'style': style}
elif type == 'today':
return {'today': True, 'id': id}
return {'target': url, 'pk': pk, 'class': clazz, 'icon': icon, 'text': text, 'id': id, 'style': style}

View File

@@ -70,9 +70,6 @@ urlpatterns = [
path('event/<int:pk>/duplicate/', permission_required_with_403('RIGS.add_event')(views.EventDuplicate.as_view()),
name='event_duplicate'),
path('subhire/create/', permission_required_with_403('RIGS.add_event')(views.SubhireCreate.as_view()),
name='subhire_create'),
# Event H&S
path('event/hs/', permission_required_with_403('RIGS.view_riskassessment')(views.HSList.as_view()), name='hs_list'),

View File

@@ -100,20 +100,6 @@ class EventCreate(generic.CreateView):
return reverse_lazy('event_detail', kwargs={'pk': self.object.pk})
class SubhireCreate(generic.CreateView):
model = models.Subhire
form_class = forms.SubhireForm
template_name = 'subhire_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = "New Subhire"
context['edit'] = True
form = context['form']
get_related(form, context)
return context
class EventUpdate(generic.UpdateView):
model = models.Event
form_class = forms.EventForm

View File

@@ -28,6 +28,7 @@ def admin_user(admin_user):
admin_user.last_name = "Test"
admin_user.initials = "ETU"
admin_user.is_approved = True
admin_user.is_supervisor = True
admin_user.save()
return admin_user

1284
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,14 +27,14 @@
"html5sortable": "^0.13.3",
"jquery": "^3.6.0",
"konami": "^1.6.3",
"moment": "^2.29.2",
"moment": "^2.29.4",
"node-sass": "^7.0.0",
"popper.js": "^1.16.1",
"postcss": "^8.4.5",
"uglify-js": "^3.14.5"
},
"devDependencies": {
"browser-sync": "^2.27.7"
"browser-sync": "^2.27.10"
},
"scripts": {
"gulp": "gulp",

View File

@@ -47,14 +47,16 @@ function initPicker(obj) {
//log: 3,
preprocessData: function (data) {
var i, l = data.length, array = [];
array.push({
text: clearSelectionLabel,
value: '',
data:{
update_url: '',
subtext:''
}
});
if (!obj.data('noclear')) {
array.push({
text: clearSelectionLabel,
value: '',
data:{
update_url: '',
subtext:''
}
});
}
if (l) {
for(i = 0; i < l; i++){
@@ -71,11 +73,13 @@ function initPicker(obj) {
return array;
}
};
obj.prepend($("<option></option>")
.attr("value",'')
.text(clearSelectionLabel)
.data('update_url','')); //Add "clear selection" option
console.log(obj.data);
if (!obj.data('noclear')) {
obj.prepend($("<option></option>")
.attr("value",'')
.text(clearSelectionLabel)
.data('update_url','')); //Add "clear selection" option
}
obj.selectpicker().ajaxSelectPicker(options); //Initiaise selectPicker

View File

@@ -1,13 +1,12 @@
{% load nice_errors from filters %}
{% if form.errors %}
<div class="alert alert-danger alert-dismissable">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
<div class="alert alert-danger mb-0">
<dl>
{% with form|nice_errors as qq %}
{% for error_name,desc in qq.items %}
<span class="row">
<dt class="col-4">{{error_name}}</dt>
<dd class="col-8">{{desc}}</dd>
<dt class="col-3">{{error_name}}</dt>
<dd class="col-9">{{desc}}</dd>
</span>
{% endfor %}
{% endwith %}

View File

@@ -4,6 +4,8 @@
<a href="{% url target pk %}" class="btn {{ class }}" {% if id %}id="{{id}}"{%endif%} {% if style %}style="{{style}}"{%endif%} {% if text == 'Print' %}target="_blank"{%endif%}><span class="fas {{ icon }} align-middle"></span> <span class="d-none d-sm-inline align-middle">{{ text }}</span></a>
{% elif copy %}
<button class="btn btn-secondary btn-sm mr-1" data-clipboard-target="{{id}}" data-content="Copied to clipboard!"><span class="fas fa-copy"></span></button>
{% elif today %}
<button class="btn btn-info col-sm-2" onclick="var date = new Date(); $('#{{id}}').val([date.getFullYear(), ('0' + (date.getMonth()+1)).slice(-2), ('0' + date.getDate()).slice(-2)].join('-'))" tabindex="-1" type="button">Today</button>
{% else %}
<a href="{% url target %}" class="btn {{ class }}" {% if id %}id="{{id}}"{%endif%} {% if style %}style="{{style}}"{%endif%}><span class="fas {{ icon }} align-middle"></span> <span class="d-none d-sm-inline align-middle">{{ text }}</span></a>
{% endif %}

View File

@@ -1,5 +1,10 @@
{% load widget_tweaks %}
{% include 'form_errors.html' %}
{% if form.errors %}
<div class="alert alert-info">
<p><strong>Please note:</strong> If it has been more than a year since you last logged in, your account will have been automatically deactivated. Contact <a href="mailto:it@nottinghamtec.co.uk">it@nottinghamtec.co.uk</a> for assistance.</p>
</div>
{% endif %}
<div class="col-sm-6 offset-sm-3 col-lg-4 offset-lg-4">
<form action="{% url 'login' %}" method="post" role="form" target="_self">{% csrf_token %}
<div class="form-group">

View File

@@ -1,5 +1,5 @@
from PyRIGS.decorators import user_passes_test_with_403
def has_perm_or_supervisor(perm, login_url=None, oembed_view=None):
return user_passes_test_with_403(lambda u: (hasattr(u, 'is_supervisor') and u.is_supervisor) or u.has_perm(perm), login_url=login_url, oembed_view=oembed_view)
def is_supervisor(login_url=None, oembed_view=None):
return user_passes_test_with_403(lambda u: (hasattr(u, 'is_supervisor') and u.is_supervisor))

View File

@@ -201,7 +201,7 @@ class TrainingItemQualification(models.Model, RevisionMixin):
@property
def activity_feed_string(self):
return f"{self.trainee} {self.get_depth_display().lower()} {self.get_depth_display()} in {self.item}"
return f"{self.trainee} {self.get_depth_display().lower()} in {self.item}"
@classmethod
def get_colour_from_depth(cls, depth):

View File

@@ -30,7 +30,7 @@
<a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
</div>
</li>
{% if perms.training.add_trainingitemqualification or request.user.is_supervisor %}
{% if request.user.is_supervisor %}
<li class="nav-item"><a class="nav-link" href="{% url 'session_log' %}"><span class="fas fa-users"></span> Log Session</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li>

View File

@@ -43,7 +43,7 @@
{% render_field form.date|add_class:'form-control'|attr:'type="date"' value=training_date %}
{% endwith %}
</div>
<button class="btn btn-info col-sm-2" onclick="var date = new Date(); $('#id_date').val([date.getFullYear(), ('0' + (date.getMonth()+1)).slice(-2), ('0' + date.getDate()).slice(-2)].join('-'))" tabindex="-1" type="button">Today</button>
{% button 'today' id='id_date' %}
</div>
<div class="form-group form-row">
<label for="id_notes" class="col-sm-2 col-form-label">Notes</label>

View File

@@ -44,7 +44,7 @@
{% endblock %}
{% block content %}
{% if request.user.is_supervisor or perms.training.add_traininglevelrequirement %}
{% if request.user.is_supervisor %}
<div class="col-sm-12 text-right pr-0">
<a type="button" class="btn btn-success mb-3" href="{% url 'add_requirement' pk=object.pk %}" id="requirement_button">
<span class="fas fa-plus"></span> Add New Requirement
@@ -79,9 +79,9 @@
{% endfor %}
<tr><th colspan="3" class="text-center">{{object}}</th></tr>
<tr>
<td><ul class="list-unstyled">{% for req in object.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in object.complete_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 1 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in object.passed_out_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 2 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline"" href="{% url 'remove_requirement' pk=req.pk %}" title="Delete requirement"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in object.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in object.complete_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 1 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in object.passed_out_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 2 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline"" href="{% url 'remove_requirement' pk=req.pk %}" title="Delete requirement"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
</tr>
</tbody>
</table>

View File

@@ -1,4 +1,4 @@
{% if request.user.is_supervisor or perms.training.add_trainingitemqualification %}
{% if request.user.is_supervisor %}
<a type="button" class="btn btn-success" href="{% url 'add_qualification' object.pk %}" id="add_record">
<span class="fas fa-plus"></span> Add New Training Record
</a>

View File

@@ -1,5 +1,5 @@
<label for="supervisor" class="col-sm-2 col-form-label">Supervisor</label>
<select name="supervisor" id="supervisor_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" required>
<select name="supervisor" id="supervisor_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" required data-noclear="true">
{% if supervisor %}
<option value="{{form.supervisor.value}}" selected>{{ supervisor }}</option>
{% endif %}

View File

@@ -28,25 +28,26 @@
{% include 'form_errors.html' %}
{% csrf_token %}
<h3>People</h3>
<div class="form-group row">
<div class="form-group row" id="supervisor_group">
{% include 'partials/supervisor_field.html' %}
</div>
<div class="form-group row">
<div class="form-group row" id="trainees_group">
<label for="trainees_id" class="col-sm-2">Select Attendees</label>
<select multiple name="trainees" id="trainees_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials">
<select multiple name="trainees" id="trainees_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" data-noclear="true">
</select>
</div>
<h3>Training Items</h3>
{% for depth in depths %}
<div class="form-group row">
<div class="form-group row" id="{{depth.0}}">
<label for="selectpicker" class="col-sm-2 text-{% colour_from_depth depth.0 %} py-1">{{ depth.1 }} Items</label>
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active">
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" data-noclear="true">
</select>
</div>
{% endfor %}
<h3>Session Information</h3>
<div class="form-group">
{% include 'partials/form_field.html' with field=form.date %}
<div class="form-group row">
{% include 'partials/form_field.html' with field=form.date col='col-sm-6' %}
{% button 'today' id='id_date' %}
</div>
<div class="form-group">
{% include 'partials/form_field.html' with field=form.notes %}

View File

@@ -54,7 +54,7 @@
<th>Date</th>
<th>Supervisor</th>
<th>Notes</th>
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
{% if request.user.is_supervisor %}
<th></th>
{% endif %}
</tr>
@@ -67,7 +67,7 @@
<td>{{ object.date }}</td>
<td><a href="{{ object.supervisor.get_absolute_url}}">{{ object.supervisor }}</a></td>
<td>{{ object.notes }}</td>
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
{% if request.user.is_supervisor %}
<td>{% button 'edit' 'edit_qualification' object.pk id="edit" %}</td>
{% endif %}
</tr>

View File

@@ -30,6 +30,15 @@ def training_item(db):
training_item.delete()
@pytest.fixture
def training_item_2(db):
training_category = models.TrainingCategory.objects.create(reference_number=2, name="Sound")
training_item = models.TrainingItem.objects.create(category=training_category, reference_number=1, description="Fundamentals of Audio")
yield training_item
training_category.delete()
training_item.delete()
@pytest.fixture
def level(db):
level = models.TrainingLevel.objects.create(description="There is no description.", level=models.TrainingLevel.TECHNICIAN)

View File

@@ -40,3 +40,42 @@ class AddQualification(FormPage):
@property
def success(self):
return 'add' not in self.driver.current_url
class SessionLog(FormPage):
URL_TEMPLATE = 'training/session_log'
_supervisor_selector = (By.CSS_SELECTOR, 'div#supervisor_group>div.bootstrap-select')
_trainees_selector = (By.CSS_SELECTOR, 'div#trainees_group>div.bootstrap-select')
_training_started_selector = (By.XPATH, '//div[1]/div/div/form/div[4]/div')
_training_complete_selector = (By.XPATH, '//div[1]/div/div/form/div[4]/div')
_competency_assessed_selector = (By.XPATH, '//div[1]/div/div/form/div[5]/div')
form_items = {
'date': (regions.DatePicker, (By.ID, 'id_date')),
'notes': (regions.TextBox, (By.ID, 'id_notes')),
}
@property
def supervisor_selector(self):
return regions.BootstrapSelectElement(self, self.find_element(*self._supervisor_selector))
@property
def trainees_selector(self):
return regions.BootstrapSelectElement(self, self.find_element(*self._trainees_selector))
@property
def training_started_selector(self):
return regions.BootstrapSelectElement(self, self.find_element(*self._training_started_selector))
@property
def training_complete_selector(self):
return regions.BootstrapSelectElement(self, self.find_element(*self._training_complete_selector))
@property
def competency_assessed_selector(self):
return regions.BootstrapSelectElement(self, self.find_element(*self._competency_assessed_selector))
@property
def success(self):
return 'log' not in self.driver.current_url

View File

@@ -12,6 +12,15 @@ from training import models
from training.tests import pages
def select_super(page, supervisor):
page.supervisor_selector.toggle()
assert page.supervisor_selector.is_open
page.supervisor_selector.search(supervisor.name[:-6])
time.sleep(2) # Slow down for javascript
assert page.supervisor_selector.options[0].selected
page.supervisor_selector.toggle()
def test_add_qualification(logged_in_browser, live_server, trainee, supervisor, training_item):
page = pages.AddQualification(logged_in_browser.driver, live_server.url, pk=trainee.pk).open()
# assert page.name in str(trainee)
@@ -30,12 +39,7 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
assert page.item_selector.options[0].selected
page.item_selector.toggle()
page.supervisor_selector.toggle()
assert page.supervisor_selector.is_open
page.supervisor_selector.search(supervisor.name[:-6])
time.sleep(2) # Slow down for javascript
assert page.supervisor_selector.options[0].selected
page.supervisor_selector.toggle()
select_super(page, supervisor)
page.submit()
assert page.success
@@ -44,3 +48,32 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
assert qualification.date == date
assert qualification.notes == "A note"
assert qualification.depth == models.TrainingItemQualification.STARTED
def test_session_log(logged_in_browser, live_server, trainee, supervisor, training_item, training_item_2):
page = pages.SessionLog(logged_in_browser.driver, live_server.url).open()
page.date = date = datetime.date(2001, 1, 10)
page.notes = note = "A general note"
time.sleep(2) # Slow down for javascript
select_super(page, supervisor)
page.trainees_selector.toggle()
assert page.trainees_selector.is_open
page.trainees_selector.search(trainee.first_name)
time.sleep(2) # Slow down for javascript
page.trainees_selector.set_option(trainee.name, True)
# assert page.trainees_selector.options[0].selected
page.trainees_selector.toggle()
page.training_started_selector.toggle()
assert page.training_started_selector.is_open
page.training_started_selector.search(training_item.description[:-2])
time.sleep(2) # Slow down for javascript
# assert page.training_started_selector.options[0].selected
page.training_started_selector.toggle()
page.submit()
assert page.success

View File

@@ -16,7 +16,7 @@ def test_add_qualification(admin_client, trainee, admin_user, training_item):
response = admin_client.post(url, {'date': date, 'trainee': trainee.pk, 'supervisor': trainee.pk, 'item': training_item.pk})
assertFormError(response, 'form', 'date', 'Qualification date may not be in the future')
assertFormError(response, 'form', 'supervisor', 'One may not supervise oneself...')
response = admin_client.post(url, {'date': date, 'trainee': trainee.pk, 'supervisor': admin_user.pk, 'item': training_item.pk})
response = admin_client.post(url, {'date': date, 'trainee': admin_user.pk, 'supervisor': trainee.pk, 'item': training_item.pk})
print(response.content)
assertFormError(response, 'form', 'supervisor', 'Selected supervisor must actually *be* a supervisor...')

View File

@@ -1,7 +1,8 @@
from django.urls import path
from django.contrib.auth.decorators import login_required
from training.decorators import has_perm_or_supervisor
from training.decorators import is_supervisor
from PyRIGS.decorators import permission_required_with_403
from training import views, models
from versioning.views import VersionHistory
@@ -12,22 +13,22 @@ urlpatterns = [
path('trainee/list/', login_required(views.TraineeList.as_view()), name='trainee_list'),
path('trainee/<int:pk>/',
has_perm_or_supervisor('RIGS.view_profile')(views.TraineeDetail.as_view()),
permission_required_with_403('RIGS.view_profile')(views.TraineeDetail.as_view()),
name='trainee_detail'),
path('trainee/<int:pk>/history', has_perm_or_supervisor('RIGS.view_profile')(VersionHistory.as_view()), name='trainee_history', kwargs={'model': models.Trainee, 'app': 'training'}), # Not picked up automatically because proxy model (I think)
path('trainee/<int:pk>/add_qualification/', has_perm_or_supervisor('training.add_trainingitemqualification')(views.AddQualification.as_view()),
path('trainee/<int:pk>/history', permission_required_with_403('RIGS.view_profile')(VersionHistory.as_view()), name='trainee_history', kwargs={'model': models.Trainee, 'app': 'training'}), # Not picked up automatically because proxy model (I think)
path('trainee/<int:pk>/add_qualification/', is_supervisor()(views.AddQualification.as_view()),
name='add_qualification'),
path('trainee/edit_qualification/<int:pk>/', has_perm_or_supervisor('training.change_trainingitemqualification')(views.EditQualification.as_view()),
path('trainee/edit_qualification/<int:pk>/', is_supervisor()(views.EditQualification.as_view()),
name='edit_qualification'),
path('levels/', login_required(views.LevelList.as_view()), name='level_list'),
path('level/<int:pk>/', login_required(views.LevelDetail.as_view()), name='level_detail'),
path('level/<int:pk>/user/<int:u>/', login_required(views.LevelDetail.as_view()), name='level_detail'),
path('level/<int:pk>/add_requirement/', login_required(views.AddLevelRequirement.as_view()), name='add_requirement'),
path('level/remove_requirement/<int:pk>/', login_required(views.RemoveRequirement.as_view()), name='remove_requirement'),
path('level/<int:pk>/add_requirement/', is_supervisor()(views.AddLevelRequirement.as_view()), name='add_requirement'),
path('level/remove_requirement/<int:pk>/', is_supervisor()(views.RemoveRequirement.as_view()), name='remove_requirement'),
path('trainee/<int:pk>/level/<int:level_pk>/confirm', login_required(views.ConfirmLevel.as_view()), name='confirm_level'),
path('trainee/<int:pk>/level/<int:level_pk>/confirm', is_supervisor()(views.ConfirmLevel.as_view()), name='confirm_level'),
path('trainee/<int:pk>/item_record', login_required(views.TraineeItemDetail.as_view()), name='trainee_item_detail'),
path('session_log', has_perm_or_supervisor('training.add_trainingitemqualification')(views.SessionLog.as_view()), name='session_log'),
path('session_log', is_supervisor()(views.SessionLog.as_view()), name='session_log'),
]

View File

@@ -0,0 +1,26 @@
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from RIGS.models import Profile
from training.models import TrainingLevel
# This is triggered nightly by Heroku Scheduler
class Command(BaseCommand):
help = 'Performs perodic user maintenance tasks'
def handle(self, *args, **options):
for person in Profile.objects.all():
# Inactivate users that have not logged in for a year (or have never logged in)
if person.last_login is None or (timezone.now() - person.last_login).days > 365:
person.is_active = False
person.is_approved = False
person.save()
# Ensure everyone with a supervisor level has the flag correctly set in the database
if person.level_qualifications.exclude(confirmed_on=None).select_related('level') \
.filter(level__level__gte=TrainingLevel.SUPERVISOR) \
.exclude(level__department=TrainingLevel.HAULAGE) \
.exclude(level__department__isnull=True).exists():
person.is_supervisor = True
person.save()