Compare commits

..

1 Commits

Author SHA1 Message Date
16d845ad3a Initial noodling 2022-05-20 17:43:46 +01:00
43 changed files with 1026 additions and 999 deletions

View File

@@ -1,151 +0,0 @@
name: 'Combine PRs'
# Controls when the action will run - in this case triggered manually
on:
workflow_dispatch:
inputs:
branchPrefix:
description: 'Branch prefix to find combinable PRs based on'
required: true
default: 'dependabot'
mustBeGreen:
description: 'Only combine PRs that are green (status is success)'
required: true
default: true
combineBranchName:
description: 'Name of the branch to combine PRs into'
required: true
default: 'combine-prs-branch'
ignoreLabel:
description: 'Exclude PRs with this label'
required: true
default: 'nocombine'
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "combine-prs"
combine-prs:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- uses: actions/github-script@v6
id: create-combined-pr
name: Create Combined PR
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', {
owner: context.repo.owner,
repo: context.repo.repo
});
let branchesAndPRStrings = [];
let baseBranch = null;
let baseBranchSHA = null;
for (const pull of pulls) {
const branch = pull['head']['ref'];
console.log('Pull for branch: ' + branch);
if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) {
console.log('Branch matched prefix: ' + branch);
let statusOK = true;
if(${{ github.event.inputs.mustBeGreen }}) {
console.log('Checking green status: ' + branch);
const stateQuery = `query($owner: String!, $repo: String!, $pull_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number:$pull_number) {
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
}
}
}`
const vars = {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull['number']
};
const result = await github.graphql(stateQuery, vars);
const [{ commit }] = result.repository.pullRequest.commits.nodes;
const state = commit.statusCheckRollup.state
console.log('Validating status: ' + state);
if(state != 'SUCCESS') {
console.log('Discarding ' + branch + ' with status ' + state);
statusOK = false;
}
}
console.log('Checking labels: ' + branch);
const labels = pull['labels'];
for(const label of labels) {
const labelName = label['name'];
console.log('Checking label: ' + labelName);
if(labelName == '${{ github.event.inputs.ignoreLabel }}') {
console.log('Discarding ' + branch + ' with label ' + labelName);
statusOK = false;
}
}
if (statusOK) {
console.log('Adding branch to array: ' + branch);
const prString = '#' + pull['number'] + ' ' + pull['title'];
branchesAndPRStrings.push({ branch, prString });
baseBranch = pull['base']['ref'];
baseBranchSHA = pull['base']['sha'];
}
}
}
if (branchesAndPRStrings.length == 0) {
core.setFailed('No PRs/branches matched criteria');
return;
}
try {
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}',
sha: baseBranchSHA
});
} catch (error) {
console.log(error);
core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?');
return;
}
let combinedPRs = [];
let mergeFailedPRs = [];
for(const { branch, prString } of branchesAndPRStrings) {
try {
await github.rest.repos.merge({
owner: context.repo.owner,
repo: context.repo.repo,
base: '${{ github.event.inputs.combineBranchName }}',
head: branch,
});
console.log('Merged branch ' + branch);
combinedPRs.push(prString);
} catch (error) {
console.log('Failed to merge branch ' + branch);
mergeFailedPRs.push(prString);
}
}
console.log('Creating combined PR');
const combinedPRsString = combinedPRs.join('\n');
let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString;
if(mergeFailedPRs.length > 0) {
const mergeFailedPRsString = mergeFailedPRs.join('\n');
body += '\n\n⚠ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString
}
await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Combined PR',
head: '${{ github.event.inputs.combineBranchName }}',
base: baseBranch,
body: body
});

View File

@@ -1,18 +0,0 @@
# 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, verbose_name="Approval Status", help_text="Designates whether a staff member has approved this user.")
is_approved = models.BooleanField(default=False)
# 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)

View File

@@ -84,7 +84,7 @@
bulletFontSize="10"/>
</stylesheet>
<template title="{{filename}}"> {# Note: page is 595x842 points (1 point=1/72in) #}
<template > {# 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

@@ -5,6 +5,21 @@
{% block title %}Request Authorisation{% endblock %}
{% block js %}
<script src="{% static 'js/tooltip.js' %}"></script>
<script src="{% static 'js/popover.js' %}"></script>
<script src="{% static 'js/clipboard.min.js' %}"></script>
<script>
var clipboard = new ClipboardJS('.btn');
clipboard.on('success', function(e) {
$(e.trigger).popover('show');
window.setTimeout(function () {$(e.trigger).popover('hide')}, 3000);
e.clearSelection();
});
</script>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
@@ -18,11 +33,11 @@
<dl class="dl-horizontal">
{% if object.person.email %}
<dt>Person Email</dt>
<dd><span id="person-email" class="pr-1">{{ object.person.email }}</span> {% button 'copy' id='#person-email' %}</dd>
<dd><span id="person-email">{{ object.person.email }}</span>{% button 'copy' id='#person-email' %}</dd>
{% endif %}
{% if object.organisation.email %}
<dt>Organisation Email</dt>
<dd><span id="org-email" class="pr-1">{{ object.organisation.email }}</span> {% button 'copy' id='#org-email' %}</dd>
<dd><span id="org-email">{{ object.organisation.email }}</span>{% button 'copy' id='#org-email' %}</dd>
{% endif %}
</dl>
{% else %}
@@ -42,20 +57,11 @@
</form>
</div>
</div>
<script src="{% static 'js/tooltip.js' %}"></script>
<script src="{% static 'js/popover.js' %}"></script>
<script src="{% static 'js/clipboard.min.js' %}"></script>
<script>
$('#auth-request-form').on('submit', function () {
$('#auth-request-form button').attr('disabled', true);
});
var clipboard = new ClipboardJS('.btn');
clipboard.on('success', function(e) {
$(e.trigger).popover('show');
window.setTimeout(function () {$(e.trigger).popover('hide')}, 3000);
e.clearSelection();
});
</script>
{% endblock %}

View File

@@ -216,8 +216,6 @@ 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

@@ -3,7 +3,6 @@ from django.db.models import Q
from assets import models
class AssetForm(forms.ModelForm):
related_models = {
'asset': models.Asset,

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.12 on 2022-05-26 09:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0024_alter_asset_salvage_value'),
]
operations = [
migrations.RenameField(
model_name='asset',
old_name='salvage_value',
new_name='replacement_cost',
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 3.2.12 on 2022-05-26 15:23
import assets.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0025_rename_salvage_value_asset_replacement_cost'),
]
operations = [
migrations.AlterField(
model_name='asset',
name='purchase_price',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[assets.models.validate_positive]),
),
migrations.AlterField(
model_name='asset',
name='replacement_cost',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True, validators=[assets.models.validate_positive]),
),
]

View File

@@ -105,11 +105,6 @@ def get_available_asset_id(wanted_prefix=""):
return 9000 if last_asset is None else wanted_prefix + str(last_asset.asset_id_number + 1)
def validate_positive(value):
if value < 0:
raise ValidationError("A price cannot be negative")
@reversion.register
class Asset(models.Model, RevisionMixin):
parent = models.ForeignKey(to='self', related_name='asset_parent',
@@ -122,8 +117,8 @@ class Asset(models.Model, RevisionMixin):
purchased_from = models.ForeignKey(to=Supplier, on_delete=models.SET_NULL, blank=True, null=True, related_name="assets")
date_acquired = models.DateField()
date_sold = models.DateField(blank=True, null=True)
purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
replacement_cost = models.DecimalField(null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10)
salvage_value = models.DecimalField(null=True, decimal_places=2, max_digits=10)
comments = models.TextField(blank=True)
# Audit
@@ -170,6 +165,12 @@ class Asset(models.Model, RevisionMixin):
errdict["asset_id"] = [
"An Asset ID can only consist of letters and numbers, with a final number"]
if self.purchase_price and self.purchase_price < 0:
errdict["purchase_price"] = ["A price cannot be negative"]
if self.salvage_value and self.salvage_value < 0:
errdict["salvage_value"] = ["A price cannot be negative"]
if self.is_cable:
if not self.length or self.length <= 0:
errdict["length"] = ["The length of a cable must be more than 0"]

View File

@@ -9,11 +9,9 @@
date = new Date();
}
$('#id_date_acquired').val([date.getFullYear(), ('0' + (date.getMonth()+1)).slice(-2), ('0' + date.getDate()).slice(-2)].join('-'));
return false;
}
function setFieldValue(ID, CSA) {
$('#' + String(ID)).val(CSA);
return false;
}
function checkIfCableHidden() {
document.getElementById("cable-table").hidden = !document.getElementById("id_is_cable").checked;
@@ -41,16 +39,16 @@
</div>
<div class="form-group form-row">
{% include 'partials/form_field.html' with field=form.date_acquired col="col-6" %}
<div class="col-sm-2">
<button class="btn btn-info" onclick="return setAcquired(true);" tabindex="-1">Today</button>
<button class="btn btn-warning" onclick="return setAcquired(false);" tabindex="-1">Unknown</button>
<div class="col-sm-4">
<button class="btn btn-info" onclick="setAcquired(true);" tabindex="-1">Today</button>
<button class="btn btn-warning" onclick="setAcquired(false);" tabindex="-1">Unknown</button>
</div>
</div>
<div class="form-group form-row">
{% include 'partials/form_field.html' with field=form.date_sold col="col-6" %}
</div>
<div class="form-group form-row">
{% include 'partials/form_field.html' with field=form.replacement_cost col="col-6" prepend="£" %}
{% include 'partials/form_field.html' with field=form.salvage_value col="col-6" prepend="£" %}
</div>
<hr>
<div class="form-group form-row">
@@ -66,16 +64,16 @@
<div class="form-group form-row">
{% include 'partials/form_field.html' with field=form.length append=form.length.help_text col="col-6" %}
<div class="col-4">
<button class="btn btn-danger" onclick="return setFieldValue('{{ form.length.id_for_label }}','5');" tabindex="-1" type="button">5{{ form.length.help_text }}</button>
<button class="btn btn-success" onclick="return setFieldValue('{{ form.length.id_for_label }}','10');" tabindex="-1" type="button">10{{ form.length.help_text }}</button>
<button class="btn btn-info" onclick="return setFieldValue('{{ form.length.id_for_label }}','20');" tabindex="-1" type="button">20{{ form.length.help_text }}</button>
<button class="btn btn-danger" onclick="setFieldValue('{{ form.length.id_for_label }}','5');" tabindex="-1" type="button">5{{ form.length.help_text }}</button>
<button class="btn btn-success" onclick="setFieldValue('{{ form.length.id_for_label }}','10');" tabindex="-1" type="button">10{{ form.length.help_text }}</button>
<button class="btn btn-info" onclick="setFieldValue('{{ form.length.id_for_label }}','20');" tabindex="-1" type="button">20{{ form.length.help_text }}</button>
</div>
</div>
<div class="form-group form-row">
{% include 'partials/form_field.html' with field=form.csa append=form.csa.help_text title='CSA' col="col-6" %}
<div class="col-4">
<button class="btn btn-secondary" onclick="return setFieldValue('{{ form.csa.id_for_label }}', '1.5');" tabindex="-1" type="button">1.5{{ form.csa.help_text }}</button>
<button class="btn btn-secondary" onclick="return setFieldValue('{{ form.csa.id_for_label }}', '2.5');" tabindex="-1" type="button">2.5{{ form.csa.help_text }}</button>
<button class="btn btn-secondary" onclick="setFieldValue('{{ form.csa.id_for_label }}', '1.5');" tabindex="-1" type="button">1.5{{ form.csa.help_text }}</button>
<button class="btn btn-secondary" onclick="setFieldValue('{{ form.csa.id_for_label }}', '2.5');" tabindex="-1" type="button">2.5{{ form.csa.help_text }}</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
{% extends 'base_assets.html' %}
{% load widget_tweaks %}
{% load button from filters %}
{% load cache %}
{% block content %}
{% if create %}
<form method="POST" action="{% url 'cable_test'%}">
{% elif edit %}
<form method="POST" action="{% url 'cable_test' object.id %}">
{% endif %}
{% include 'form_errors.html' %}
{% csrf_token %}
<input type="hidden" name="id" value="{{ object.id|default:0 }}" hidden="">
<div class="row">
<div class="col-sm-12">
{% for field in form %}
<div class="form-group">
{% include 'partials/form_field.html' with field=field %}
</div>
{% endfor %}
<div class="text-right">
{% button 'submit' %}
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% load widget_tweaks %}
{% load title_spaced from filters %}
{% spaceless %}
<label for="{{ field.id_for_label }}" {% if col %}class="col-4 col-form-label"{% endif %}>{% if title %}{{ title }}{%else%}{{field.name|title_spaced}}{%endif%}</label>
<label for="{{ field.id_for_label }}" {% if col %}class="col-2 col-form-label"{% endif %}>{% if title %}{{ title }}{%else%}{{field.name|title_spaced}}{%endif%}</label>
{% if append or prepend %}
<div class="input-group {{col}}">
{% if prepend %}

View File

@@ -10,7 +10,7 @@
<label for="{{ form.purchased_from.id_for_label }}">Supplier</label>
<div class="row">
<div class="col">
<select id="{{ form.purchased_from.id_for_label }}" name="{{ form.purchased_from.name }}" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='supplier' %}">
<select id="{{ form.purchased_from.id_for_label }}" name="{{ form.purchased_from.name }}" class="form-control selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='supplier' %}">
{% if object.purchased_from %}
<option value="{{form.purchased_from.value}}" selected="selected" data-update_url="{% url 'supplier_update' form.purchased_from.value %}">{{ object.purchased_from }}</option>
{% endif %}
@@ -39,10 +39,10 @@
</div>
<div class="form-group">
<label for="{{ form.salvage_value.id_for_label }}">Replacement Cost</label>
<label for="{{ form.salvage_value.id_for_label }}">Salvage Value</label>
<div class="input-group">
<div class="input-group-prepend"><span class="input-group-text">£</span></div>
{% render_field form.replacement_cost|add_class:'form-control' value=object.replacement_cost %}
{% render_field form.salvage_value|add_class:'form-control' value=object.salvage_value %}
</div>
</div>
@@ -70,8 +70,8 @@
<dd>{% if object.purchased_from %}<a href="{{object.purchased_from.get_absolute_url}}">{{ object.purchased_from }}</a>{%else%}-{%endif%}</dd>
<dt>Purchase Price</dt>
<dd>£{{ object.purchase_price|default_if_none:'-' }}</dd>
<dt>Replacement Cost</dt>
<dd>£{{ object.replacement_cost|default_if_none:'-' }}</dd>
<dt>Salvage Value</dt>
<dd>£{{ object.salvage_value|default_if_none:'-' }}</dd>
<dt>Date Acquired</dt>
<dd>{{ object.date_acquired|default_if_none:'-' }}</dd>
{% if object.date_sold %}

42
assets/testing.py Normal file
View File

@@ -0,0 +1,42 @@
from django.db import models
from . import models as am
class Test(models.Model):
item = models.ForeignKey(to=am.Asset, on_delete=models.CASCADE)
date = models.DateField()
tested_by = models.ForeignKey(to='RIGS.Profile', on_delete=models.CASCADE)
class ElectricalTest(Test):
visual = models.BooleanField()
remarks = models.TextField()
class CableTest(ElectricalTest):
# Should contain X circuit tests, where X is determined by circuits as per cable type
pass
class CircuitTest(models.Model):
test = models.ForeignKey(to=CableTest, on_delete=models.CASCADE)
continuity = models.DecimalField(help_text='Ω')
insulation_resistance = models.DecimalField(help_text='')
class TestRequirement(models.Model):
item = models.ForeignKey(to=am.Asset, on_delete=models.CASCADE)
test_type = models.ForeignKey(to=Test, on_delete=models.CASCADE)
period = models.IntegerField() # X months
class CableTestForm(forms.ModelForm):
class Meta
model = CableTest
class CircuitTest(forms.ModelForm):
class Meta:
model = Choice
exclude = ('test',)

View File

@@ -28,13 +28,13 @@ def cable_type(db):
@pytest.fixture
def test_cable(db, category, status, cable_type):
cable = models.Asset.objects.create(asset_id="9666", description="125A -> Jack", comments="The cable from Hell...", status=status, category=category, date_acquired=datetime.date(2006, 6, 6), is_cable=True, cable_type=cable_type, length=10, csa="1.5", replacement_cost=50)
cable = models.Asset.objects.create(asset_id="9666", description="125A -> Jack", comments="The cable from Hell...", status=status, category=category, date_acquired=datetime.date(2006, 6, 6), is_cable=True, cable_type=cable_type, length=10, csa="1.5", salvage_value=50)
yield cable
cable.delete()
@pytest.fixture
def test_asset(db, category, status):
asset, created = models.Asset.objects.get_or_create(asset_id="91991", description="Spaceflower", status=status, category=category, date_acquired=datetime.date(1991, 12, 26), replacement_cost=100)
asset, created = models.Asset.objects.get_or_create(asset_id="91991", description="Spaceflower", status=status, category=category, date_acquired=datetime.date(1991, 12, 26), salvage_value=100)
yield asset
asset.delete()

View File

@@ -79,7 +79,7 @@ class AssetForm(FormPage):
'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')),
'comments': (regions.SimpleMDETextArea, (By.ID, 'id_comments')),
'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')),
'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')),
'salvage_value': (regions.TextBox, (By.ID, 'id_salvage_value')),
'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')),
'date_sold': (regions.DatePicker, (By.ID, 'id_date_sold')),
'category': (regions.SingleSelectPicker, (By.ID, 'id_category')),
@@ -221,7 +221,7 @@ class AssetAuditList(AssetList):
'description': (regions.TextBox, (By.ID, 'id_description')),
'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')),
'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')),
'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')),
'salvage_value': (regions.TextBox, (By.ID, 'id_salvage_value')),
'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')),
'category': (regions.SingleSelectPicker, (By.ID, 'id_category')),
'status': (regions.SingleSelectPicker, (By.ID, 'id_status')),

View File

@@ -102,7 +102,7 @@ def test_cable_create(logged_in_browser, admin_user, live_server, test_asset, ca
page.status = status.name
page.serial_number = "MELON-MELON-MELON"
page.comments = "You might need that"
page.replacement_cost = "666"
page.salvage_value = "666"
page.is_cable = True
assert logged_in_browser.driver.find_element(By.ID, 'cable-table').is_displayed()
@@ -179,7 +179,7 @@ class TestAssetForm(AutoLoginTest):
self.page.comments = comments = "This is actually a sledgehammer, not a cable..."
self.page.purchase_price = "12.99"
self.page.replacement_cost = "99.12"
self.page.salvage_value = "99.12"
self.page.date_acquired = acquired = datetime.date(2020, 5, 2)
self.page.purchased_from_selector.toggle()
self.assertTrue(self.page.purchased_from_selector.is_open)
@@ -320,14 +320,14 @@ class TestAssetAudit(AutoLoginTest):
self.connector = models.Connector.objects.create(description="Trailer Socket", current_rating=1,
voltage_rating=40, num_pins=13)
models.Asset.objects.create(asset_id="1", description="Trailer Cable", status=self.status,
category=self.category, date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
category=self.category, date_acquired=datetime.date(2020, 2, 1), salvage_value=10)
models.Asset.objects.create(asset_id="11", description="Trailerboard", status=self.status,
category=self.category, date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
category=self.category, date_acquired=datetime.date(2020, 2, 1), salvage_value=10)
models.Asset.objects.create(asset_id="111", description="Erms", status=self.status, category=self.category,
date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
date_acquired=datetime.date(2020, 2, 1), salvage_value=10)
self.asset = models.Asset.objects.create(asset_id="1111", description="A hammer", status=self.status,
category=self.category,
date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
date_acquired=datetime.date(2020, 2, 1), salvage_value=10)
self.page = pages.AssetAuditList(self.driver, self.live_server_url).open()
self.wait = WebDriverWait(self.driver, 20)

View File

@@ -84,7 +84,7 @@ def test_oembed(client, test_asset):
def test_asset_create(admin_client):
response = admin_client.post(reverse('asset_create'), {'date_sold': '2000-01-01', 'date_acquired': '2020-01-01', 'purchase_price': '-30', 'replacement_cost': '-30'})
response = admin_client.post(reverse('asset_create'), {'date_sold': '2000-01-01', 'date_acquired': '2020-01-01', 'purchase_price': '-30', 'salvage_value': '-30'})
assertFormError(response, 'form', 'asset_id', 'This field is required.')
assert_asset_form_errors(response)
@@ -99,7 +99,7 @@ def test_cable_create(admin_client):
def test_asset_edit(admin_client, test_asset):
url = reverse('asset_update', kwargs={'pk': test_asset.asset_id})
response = admin_client.post(url, {'date_sold': '2000-12-01', 'date_acquired': '2020-12-01', 'purchase_price': '-50', 'replacement_cost': '-50', 'description': "", 'status': "", 'category': ""})
response = admin_client.post(url, {'date_sold': '2000-12-01', 'date_acquired': '2020-12-01', 'purchase_price': '-50', 'salvage_value': '-50', 'description': "", 'status': "", 'category': ""})
assert_asset_form_errors(response)
@@ -127,4 +127,4 @@ def assert_asset_form_errors(response):
assertFormError(response, 'form', 'category', 'This field is required.')
assertFormError(response, 'form', 'date_sold', 'Cannot sell an item before it is acquired')
assertFormError(response, 'form', 'purchase_price', 'A price cannot be negative')
assertFormError(response, 'form', 'replacement_cost', 'A price cannot be negative')
assertFormError(response, 'form', 'salvage_value', 'A price cannot be negative')

View File

@@ -43,4 +43,6 @@ urlpatterns = [
(views.SupplierCreate.as_view()), name='supplier_create'),
path('supplier/<int:pk>/edit/', permission_required_with_403('assets.change_supplier')
(views.SupplierUpdate.as_view()), name='supplier_update'),
path('testing/<int:pk>/cable_test/' views.AddCableTest.as_view(), name='cable_test'),
]

View File

@@ -24,7 +24,7 @@ from z3c.rml import rml2pdf
from PyRIGS.views import GenericListView, GenericDetailView, GenericUpdateView, GenericCreateView, ModalURLMixin, \
is_ajax, OEmbedView
from assets import forms, models
from assets import forms, models, testing
class AssetList(LoginRequiredMixin, generic.ListView):
@@ -430,3 +430,8 @@ class GenerateLabels(generic.View):
response['Content-Disposition'] = f'filename="{name}"'
response.write(merged.getvalue())
return response
class AddCableTest(generic.CreateView):
model = testing.CableTest
template = 'cable_test_form.html'

View File

@@ -28,7 +28,6 @@ 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

1393
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.4",
"node-sass": "^7.0.3",
"moment": "^2.29.2",
"node-sass": "^7.0.0",
"popper.js": "^1.16.1",
"postcss": "^8.4.5",
"uglify-js": "^3.14.5"
},
"devDependencies": {
"browser-sync": "^2.27.10"
"browser-sync": "^2.27.7"
},
"scripts": {
"gulp": "gulp",

View File

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

View File

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

View File

@@ -4,8 +4,6 @@
<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,10 +1,5 @@
{% 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 is_supervisor(login_url=None, oembed_view=None):
return user_passes_test_with_403(lambda u: (hasattr(u, 'is_supervisor') and u.is_supervisor))
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)

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()} in {self.item}"
return f"{self.trainee} {self.get_depth_display().lower()} {self.get_depth_display()} 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 request.user.is_supervisor %}
{% if perms.training.add_trainingitemqualification or 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 'today' id='id_date' %}
<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>
</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 %}
{% if request.user.is_supervisor or perms.training.add_traininglevelrequirement %}
<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 %}<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>
<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>
</tr>
</tbody>
</table>

View File

@@ -1,4 +1,4 @@
{% if request.user.is_supervisor %}
{% if request.user.is_supervisor or perms.training.add_trainingitemqualification %}
<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 data-noclear="true">
<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>
{% if supervisor %}
<option value="{{form.supervisor.value}}" selected>{{ supervisor }}</option>
{% endif %}

View File

@@ -28,26 +28,25 @@
{% include 'form_errors.html' %}
{% csrf_token %}
<h3>People</h3>
<div class="form-group row" id="supervisor_group">
<div class="form-group row">
{% include 'partials/supervisor_field.html' %}
</div>
<div class="form-group row" id="trainees_group">
<div class="form-group row">
<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" data-noclear="true">
<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>
</div>
<h3>Training Items</h3>
{% for depth in depths %}
<div class="form-group row" id="{{depth.0}}">
<div class="form-group row">
<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" data-noclear="true">
<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>
</div>
{% endfor %}
<h3>Session Information</h3>
<div class="form-group row">
{% include 'partials/form_field.html' with field=form.date col='col-sm-6' %}
{% button 'today' id='id_date' %}
<div class="form-group">
{% include 'partials/form_field.html' with field=form.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 %}
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
<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 %}
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
<td>{% button 'edit' 'edit_qualification' object.pk id="edit" %}</td>
{% endif %}
</tr>

View File

@@ -30,15 +30,6 @@ 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,42 +40,3 @@ 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,15 +12,6 @@ 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)
@@ -39,7 +30,12 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
assert page.item_selector.options[0].selected
page.item_selector.toggle()
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()
page.submit()
assert page.success
@@ -48,32 +44,3 @@ 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': admin_user.pk, 'supervisor': trainee.pk, 'item': training_item.pk})
response = admin_client.post(url, {'date': date, 'trainee': trainee.pk, 'supervisor': admin_user.pk, 'item': training_item.pk})
print(response.content)
assertFormError(response, 'form', 'supervisor', 'Selected supervisor must actually *be* a supervisor...')

View File

@@ -1,8 +1,7 @@
from django.urls import path
from django.contrib.auth.decorators import login_required
from training.decorators import is_supervisor
from PyRIGS.decorators import permission_required_with_403
from training.decorators import has_perm_or_supervisor
from training import views, models
from versioning.views import VersionHistory
@@ -13,22 +12,22 @@ urlpatterns = [
path('trainee/list/', login_required(views.TraineeList.as_view()), name='trainee_list'),
path('trainee/<int:pk>/',
permission_required_with_403('RIGS.view_profile')(views.TraineeDetail.as_view()),
has_perm_or_supervisor('RIGS.view_profile')(views.TraineeDetail.as_view()),
name='trainee_detail'),
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()),
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()),
name='add_qualification'),
path('trainee/edit_qualification/<int:pk>/', is_supervisor()(views.EditQualification.as_view()),
path('trainee/edit_qualification/<int:pk>/', has_perm_or_supervisor('training.change_trainingitemqualification')(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/', 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('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('trainee/<int:pk>/level/<int:level_pk>/confirm', is_supervisor()(views.ConfirmLevel.as_view()), name='confirm_level'),
path('trainee/<int:pk>/level/<int:level_pk>/confirm', login_required(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', is_supervisor()(views.SessionLog.as_view()), name='session_log'),
path('session_log', has_perm_or_supervisor('training.add_trainingitemqualification')(views.SessionLog.as_view()), name='session_log'),
]

View File

@@ -1,26 +0,0 @@
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()