Compare commits

...

42 Commits

Author SHA1 Message Date
1660f51e55 Merge branch 'master' into subhire
# Conflicts:
#	Pipfile.lock
2022-12-11 00:53:27 +00:00
a7bf990666 FIX: Do not specify minor version of python in CI 2022-12-11 00:51:19 +00:00
9feea56211 Draft "upcoming subhire" view 2022-12-11 00:49:56 +00:00
951227e68b Make calendar work reasonably on mobile 2022-12-11 00:44:14 +00:00
a4a28a6130 Add nickname field to assets
Seems necessary given all the lights and some of the amps and distros have names :-)
2022-12-11 00:29:30 +00:00
e3d8cf8978 Dependency update 2022-12-11 00:26:24 +00:00
6e8779c81b Revamped calendar basically there
To fix:
- Does not yet display events that span weeks correctly!
- Breaks (overflows) on mobile
2022-12-06 15:31:35 +00:00
e0da6a3120 Remove rogue print statement from JS 2022-12-06 15:03:57 +00:00
0c80ef1b72 More calendar work 2022-12-06 15:03:27 +00:00
0f127d8ca4 Entirely new concept 2022-12-06 13:20:07 +00:00
626779ef25 Turn off RA reminders for non-rigs 2022-12-05 21:30:24 +00:00
fa1dc31639 FIX: Only require login for viewing RAs/ECs 2022-11-26 12:58:15 +00:00
d69543e309 FIX: Only require login to view training profiles
Previously required a specific permission only granted to keyholders
2022-11-26 12:54:42 +00:00
04ec728972 Reimplemented calendar mostly working
Multi day alignment puzzle is pretty hard...
2022-11-22 23:59:21 +00:00
dependabot[bot]
a24e6d4495 Build(deps): Bump pillow from 9.0.1 to 9.3.0 (#516)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.0.1 to 9.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/9.0.1...9.3.0)

---
updated-dependencies:
- dependency-name: pillow
  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-22 17:33:42 +00:00
dependabot[bot]
fa5792914a Build(deps): Bump engine.io from 6.2.0 to 6.2.1 (#515)
Bumps [engine.io](https://github.com/socketio/engine.io) from 6.2.0 to 6.2.1.
- [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/6.2.0...6.2.1)

---
updated-dependencies:
- dependency-name: engine.io
  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-22 17:33:14 +00:00
bede8b4176 Merge branch 'master' into subhire
# Conflicts:
#	Pipfile
#	Pipfile.lock
#	package-lock.json
2022-11-21 19:49:33 +00:00
0117091f3e FEAT #513: Implement email reminders to complete risk assessments (#514)
* FEAT #513: Implement email reminders to complete risk assessments

* Add missing f-string tag
2022-11-19 15:34:15 +00:00
8cade512d1 Initial work at reimplementing the calendar in python
Buhby fullcalendar
2022-11-18 16:16:06 +00:00
418219940b Add subhire link to new event page 2022-11-18 14:58:59 +00:00
37101d3340 Update lockfile 2022-11-18 14:29:44 +00:00
de4bed92a4 Build(deps): Generally update JS & Python deps (#512) 2022-11-17 12:06:21 +00:00
dependabot[bot]
3767923175 Build(deps): Bump yargs-parser and yargs (#507)
Bumps [yargs-parser](https://github.com/yargs/yargs-parser) and [yargs](https://github.com/yargs/yargs). These dependencies needed to be updated together.

Updates `yargs-parser` from 5.0.0-security.0 to 20.2.9
- [Release notes](https://github.com/yargs/yargs-parser/releases)
- [Changelog](https://github.com/yargs/yargs-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yargs/yargs-parser/commits/yargs-parser-v20.2.9)

Updates `yargs` from 7.1.1 to 15.4.1
- [Release notes](https://github.com/yargs/yargs/releases)
- [Changelog](https://github.com/yargs/yargs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yargs/yargs/commits/v15.4.1)

---
updated-dependencies:
- dependency-name: yargs-parser
  dependency-type: indirect
- dependency-name: yargs
  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-15 15:52:49 +00:00
dependabot[bot]
1d77cf95d3 Build(deps): Bump scss-tokenizer and node-sass (#506)
Bumps [scss-tokenizer](https://github.com/sasstools/scss-tokenizer) to 0.4.3 and updates ancestor dependency [node-sass](https://github.com/sass/node-sass). These dependencies need to be updated together.


Updates `scss-tokenizer` from 0.3.0 to 0.4.3
- [Release notes](https://github.com/sasstools/scss-tokenizer/releases)
- [Commits](https://github.com/sasstools/scss-tokenizer/compare/v0.3.0...v0.4.3)

Updates `node-sass` from 7.0.1 to 7.0.3
- [Release notes](https://github.com/sass/node-sass/releases)
- [Changelog](https://github.com/sass/node-sass/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sass/node-sass/compare/v7.0.1...v7.0.3)

---
updated-dependencies:
- dependency-name: scss-tokenizer
  dependency-type: indirect
- dependency-name: node-sass
  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-15 15:44:25 +00:00
dependabot[bot]
1f21d0b265 Build(deps): Bump engine.io and browser-sync (#508)
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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-15 15:33:50 +00:00
7846a6d31e Rename combine-prs to combine-prs.yml 2022-11-14 18:20:57 +00:00
d28b73a0b8 Create combine-prs
Copied from here: https://github.com/hrvey/combine-prs-workflow
2022-11-14 16:30:55 +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
948a41f43a Initial work on associating events with subhires 2022-10-20 12:56:42 +01:00
4449efcced Add subhire detail view 2022-10-20 00:00:57 +01:00
39ed5aefb4 Set printed PDF title == filename
Should fix #497
2022-10-18 12:48:25 +01:00
8b0cd13159 Initially create subhire model and form 2022-10-15 19:09:51 +01:00
e7e760de2e Fix copy to clipboard buttons on authorisation request form 2022-10-15 17:58:07 +01:00
9091197639 Minor audit fix 2022-06-02 15:36:53 +01:00
4f4baa62c1 Fix tests 2022-05-30 22:33:55 +01:00
b9f8621e1a Migrations fix 2022-05-26 16:23:51 +01:00
4b1dc37a7f Rename 'salvage value' to 'replacement cost'
This more accurately reflects historical use of the field, and what the insurers actually want. Ref #439
2022-05-26 10:29:53 +01:00
75 changed files with 5340 additions and 4667 deletions

151
.github/workflows/combine-prs.yml vendored Normal file
View File

@@ -0,0 +1,151 @@
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

@@ -17,7 +17,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.9.1 python-version: 3.9
- uses: actions/cache@v2 - uses: actions/cache@v2
id: pcache id: pcache
with: with:

28
Pipfile
View File

@@ -11,7 +11,6 @@ asgiref = "~=3.3.1"
beautifulsoup4 = "~=4.9.3" beautifulsoup4 = "~=4.9.3"
Brotli = "~=1.0.9" Brotli = "~=1.0.9"
cachetools = "~=4.2.1" cachetools = "~=4.2.1"
certifi = "~=2020.12.5"
chardet = "~=4.0.0" chardet = "~=4.0.0"
configparser = "~=5.0.1" configparser = "~=5.0.1"
contextlib2 = "~=0.6.0.post1" contextlib2 = "~=0.6.0.post1"
@@ -22,22 +21,19 @@ dj-static = "~=0.0.6"
Django = "~=3.2" Django = "~=3.2"
django-debug-toolbar = "~=3.2" django-debug-toolbar = "~=3.2"
django-filter = "~=2.4.0" django-filter = "~=2.4.0"
django-ical = "~=1.7.1" django-ical = "~=1.8.3"
django-recurrence = "~=1.10.3"
django-registration-redux = "~=2.9" django-registration-redux = "~=2.9"
django-reversion = "~=3.0.9" django-reversion = "~=3.0.9"
django-toolbelt = "~=0.0.1"
django-widget-tweaks = "~=1.4.8" django-widget-tweaks = "~=1.4.8"
django-htmlmin = "~=0.11.0" django-htmlmin = "~=0.11.0"
envparse = "~=0.2.0" envparse = "*"
gunicorn = "~=20.0.4" gunicorn = "~=20.0.4"
icalendar = "~=4.0.7" icalendar = "~=4.0.7"
idna = "~=2.10" idna = "~=2.10"
lxml = "~=4.7.1"
Markdown = "~=3.3.3" Markdown = "~=3.3.3"
msgpack = "~=1.0.2" msgpack = "~=1.0.2"
pep517 = "~=0.9.1" pep517 = "~=0.9.1"
Pillow = "~=9.0.0" Pillow = "~=9.3.0"
premailer = "~=3.7.0" premailer = "~=3.7.0"
progress = "~=1.5" progress = "~=1.5"
psutil = "~=5.8.0" psutil = "~=5.8.0"
@@ -45,7 +41,7 @@ psycopg2 = "~=2.8.6"
Pygments = "~=2.7.4" Pygments = "~=2.7.4"
pyparsing = "~=2.4.7" pyparsing = "~=2.4.7"
PyPDF2 = "~=1.27.5" PyPDF2 = "~=1.27.5"
PyPOM = "~=2.2.0" PyPOM = "~=2.2.4"
python-dateutil = "~=2.8.1" python-dateutil = "~=2.8.1"
pytoml = "~=0.1.21" pytoml = "~=0.1.21"
pytz = "~=2020.5" pytz = "~=2020.5"
@@ -79,13 +75,12 @@ django-hCaptcha = "*"
importlib-metadata = "*" importlib-metadata = "*"
django-hcaptcha = "*" django-hcaptcha = "*"
"z3c.rml" = "*" "z3c.rml" = "*"
pikepdf = "*"
django-queryable-properties = "*" django-queryable-properties = "*"
django-mass-edit = "*" django-mass-edit = "*"
selenium = "~=3.141.0"
[dev-packages] [dev-packages]
selenium = "~=3.141.0" pycodestyle = "~=2.9.1"
pycodestyle = "*"
coveralls = "*" coveralls = "*"
django-coverage-plugin = "*" django-coverage-plugin = "*"
pytest-cov = "*" pytest-cov = "*"
@@ -94,14 +89,11 @@ pluggy = "*"
pytest-splinter = "*" pytest-splinter = "*"
pytest = "*" pytest = "*"
pytest-reverse = "*" pytest-reverse = "*"
pytest-xdist = {extras = [ "psutil",], version = "*"}
PyPOM = {extras = [ "splinter",], version = "*"}
[requires] [requires]
python_version = "3.9" python_version = "3.9"
[dev-packages.pytest-xdist] [pipenv]
extras = [ "psutil",] allow_prereleases = true
version = "*"
[dev-packages.PyPOM]
extras = [ "splinter",]
version = "*"

1240
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,8 +42,9 @@ if not DEBUG:
INTERNAL_IPS = ['127.0.0.1'] INTERNAL_IPS = ['127.0.0.1']
ADMINS = [('Tom Price', 'tomtom5152@gmail.com'), ('IT Manager', 'it@nottinghamtec.co.uk'), DOMAIN = env('DOMAIN', default='example.com')
('Arona Jones', 'arona.jones@nottinghamtec.co.uk')]
ADMINS = [('IT Manager', f'it@{DOMAIN}'), ('Arona Jones', f'arona.jones@{DOMAIN}')]
if DEBUG: if DEBUG:
ADMINS.append(('Testing Superuser', 'superuser@example.com')) ADMINS.append(('Testing Superuser', 'superuser@example.com'))

View File

@@ -124,6 +124,22 @@ class EventForm(forms.ModelForm):
'purchase_order', 'collector'] '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['start_date'].widget.format = '%Y-%m-%d'
self.fields['end_date'].widget.format = '%Y-%m-%d'
class Meta:
model = models.Subhire
fields = '__all__'
class BaseClientEventAuthorisationForm(forms.ModelForm): class BaseClientEventAuthorisationForm(forms.ModelForm):
tos = forms.BooleanField(required=True, label="Terms of hire") tos = forms.BooleanField(required=True, label="Terms of hire")
name = forms.CharField(label="Your Name") name = forms.CharField(label="Your Name")

View File

@@ -0,0 +1,38 @@
import premailer
import datetime
from django.template.loader import get_template
from django.contrib.staticfiles import finders
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from django.urls import reverse
from RIGS import models
class Command(BaseCommand):
help = 'Sends email reminders as required. Triggered daily through heroku-scheduler in production.'
def handle(self, *args, **options):
events = models.Event.objects.current_events().select_related('riskassessment')
for event in events:
earliest_time = event.earliest_time if isinstance(event.earliest_time, datetime.datetime) else timezone.make_aware(datetime.datetime.combine(event.earliest_time, datetime.time(00, 00)))
# 48 hours = 172800 seconds
if event.is_rig and not event.cancelled and not event.dry_hire and (earliest_time - timezone.now()).total_seconds() <= 172800 and not hasattr(event, 'riskassessment'):
context = {
"event": event,
"url": "https://" + settings.DOMAIN + reverse('event_ra', kwargs={'pk': event.pk})
}
target = event.mic.email if event.mic else f"productions@{settings.DOMAIN}"
msg = EmailMultiAlternatives(
f"{event} - Risk Assessment Incomplete",
get_template("email/ra_reminder.txt").render(context),
to=[target],
reply_to=[f"h.s.manager@{settings.DOMAIN}"],
)
css = finders.find('css/email.css')
html = premailer.Premailer(get_template("email/ra_reminder.html").render(context), external_styles=css).transform()
msg.attach_alternative(html, 'text/html')
msg.send()

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

@@ -0,0 +1,36 @@
# Generated by Django 3.2.12 on 2022-10-15 19:36
from django.db import migrations, models
import django.db.models.deletion
import versioning.versioning
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0044_profile_is_supervisor'),
]
operations = [
migrations.CreateModel(
name='Subhire',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='')),
('status', models.IntegerField(choices=[(0, 'Provisional'), (1, 'Confirmed'), (2, 'Booked'), (3, 'Cancelled')], default=0)),
('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(blank=True, default='', max_length=255, verbose_name='PO')),
('insurance_value', models.DecimalField(decimal_places=2, max_digits=10)),
('organisation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='RIGS.organisation')),
('person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='RIGS.person')),
],
options={
'abstract': False,
},
bases=(models.Model, versioning.versioning.RevisionMixin),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-10-20 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0045_subhire'),
]
operations = [
migrations.AddField(
model_name='subhire',
name='events',
field=models.ManyToManyField(to='RIGS.Event'),
),
]

View File

@@ -36,7 +36,7 @@ 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='')
api_key = models.CharField(max_length=40, blank=True, editable=False, 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... # 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) last_emailed = models.DateTimeField(blank=True, null=True)
dark_theme = models.BooleanField(default=False) dark_theme = models.BooleanField(default=False)
@@ -304,8 +304,29 @@ class EventManager(models.Manager):
return qs return qs
@reversion.register(follow=['items']) def find_earliest_event_time(event, datetime_list):
class Event(models.Model, RevisionMixin): # 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):
# Done to make it much nicer on the database # Done to make it much nicer on the database
PROVISIONAL = 0 PROVISIONAL = 0
CONFIRMED = 1 CONFIRMED = 1
@@ -321,31 +342,97 @@ class Event(models.Model, RevisionMixin):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE) person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE)
organisation = models.ForeignKey('Organisation', blank=True, null=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='') description = models.TextField(blank=True, default='')
notes = models.TextField(blank=True, default='')
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL) 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 # Timing
start_date = models.DateField() start_date = models.DateField()
start_time = models.TimeField(blank=True, null=True) start_time = models.TimeField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True) end_date = models.DateField(blank=True, null=True)
end_time = models.TimeField(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
@property
def length(self):
start = self.earliest_time
if isinstance(self.earliest_time, datetime.datetime):
start = self.earliest_time.date()
end = self.latest_time
if isinstance(self.latest_time, datetime.datetime):
end = self.latest_time.date()
return (end - start).days
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
def __str__(self):
return f"{self.display_id}: {self.name}"
@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) access_at = models.DateTimeField(blank=True, null=True)
meet_at = models.DateTimeField(blank=True, null=True) meet_at = models.DateTimeField(blank=True, null=True)
# Crew management # Dry-hire only
checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True, checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True,
on_delete=models.CASCADE) 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 # 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') collector = models.CharField(max_length=255, blank=True, default='', verbose_name='collected by')
# Authorisation request details # Authorisation request details
@@ -395,26 +482,10 @@ class Event(models.Model, RevisionMixin):
def total(self): def total(self):
return Decimal(self.sum_total + self.vat).quantize(Decimal('.01')) 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 @property
def hs_done(self): def hs_done(self):
return self.riskassessment is not None and len(self.checklists.all()) > 0 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 @property
def earliest_time(self): 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""" """Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
@@ -428,73 +499,47 @@ class Event(models.Model, RevisionMixin):
if self.meet_at: if self.meet_at:
datetime_list.append(self.meet_at) datetime_list.append(self.meet_at)
# If there is no start time defined, pretend it's midnight earliest = find_earliest_event_time(self, datetime_list)
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 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 @property
def internal(self): def internal(self):
return bool(self.organisation and self.organisation.union_account) return bool(self.organisation and self.organisation.union_account)
@property @property
def authorised(self): def authorised(self):
if self.internal: if self.internal and hasattr(self, 'authorisation'):
return self.authorisation.amount == self.total return self.authorisation.amount == self.total
else: else:
return bool(self.purchase_order) return bool(self.purchase_order)
@property
def color(self):
if self.cancelled:
return "secondary"
elif not self.is_rig:
return "info"
elif not self.mic:
return "danger"
elif self.confirmed and self.authorised:
if self.dry_hire or self.riskassessment:
return "success"
else:
return "warning"
else:
return "warning"
objects = EventManager() objects = EventManager()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('event_detail', kwargs={'pk': self.pk}) return reverse('event_detail', kwargs={'pk': self.pk})
def __str__(self): def get_edit_url(self):
return f"{self.display_id}: {self.name}" return reverse('event_update', kwargs={'pk': self.pk})
def clean(self): def clean(self):
errdict = {} errdict = super.clean()
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 is not None:
if self.access_at.date() > self.start_date: if self.access_at.date() > self.start_date:
@@ -555,6 +600,44 @@ class EventAuthorisation(models.Model, RevisionMixin):
return f"{self.event.display_id} (requested by {self.sent_by.initials})" return f"{self.event.display_id} (requested by {self.sent_by.initials})"
class SubhireManager(models.Manager):
def current_events(self):
events = self.filter(
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True) & ~models.Q(
status=Event.CANCELLED)) | # Starts after with no end
(models.Q(end_date__gte=timezone.now().date()) & ~models.Q(
status=Event.CANCELLED)) # Ends after
).order_by('start_date', 'end_date', 'start_time', 'end_time').select_related('person', 'organisation')
return events
@reversion.register
class Subhire(BaseEvent):
insurance_value = models.DecimalField(max_digits=10, decimal_places=2) # TODO Validate if this is over notifiable threshold
events = models.ManyToManyField(Event)
objects = SubhireManager()
@property
def display_id(self):
return f"S{self.pk:05d}"
@property
def color(self):
return "purple"
def get_edit_url(self):
return reverse('subhire_update', kwargs={'pk': self.pk})
def get_absolute_url(self):
return reverse('subhire_detail', kwargs={'pk': self.pk})
@property
def earliest_time(self):
return find_earliest_event_time(self, [])
class InvoiceManager(models.Manager): class InvoiceManager(models.Manager):
def outstanding_invoices(self): def outstanding_invoices(self):
# Manual query is the only way I have found to do this efficiently. Not ideal but needs must # Manual query is the only way I have found to do this efficiently. Not ideal but needs must

View File

@@ -58,13 +58,13 @@ def send_eventauthorisation_success_email(instance):
client_email = EmailMultiAlternatives( client_email = EmailMultiAlternatives(
subject, subject,
get_template("eventauthorisation_client_success.txt").render(context), get_template("email/eventauthorisation_client_success.txt").render(context),
to=[instance.email], to=[instance.email],
reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS], reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS],
) )
css = finders.find('css/email.css') css = finders.find('css/email.css')
html = Premailer(get_template("eventauthorisation_client_success.html").render(context), html = Premailer(get_template("email/eventauthorisation_client_success.html").render(context),
external_styles=css).transform() external_styles=css).transform()
client_email.attach_alternative(html, 'text/html') client_email.attach_alternative(html, 'text/html')
@@ -82,7 +82,7 @@ def send_eventauthorisation_success_email(instance):
mic_email = EmailMessage( mic_email = EmailMessage(
subject, subject,
get_template("eventauthorisation_mic_success.txt").render(context), get_template("email/eventauthorisation_mic_success.txt").render(context),
to=[mic_email_address] to=[mic_email_address]
) )
@@ -117,12 +117,12 @@ def send_admin_awaiting_approval_email(user, request, **kwargs):
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
f"{context['number_of_users']} new users awaiting approval on RIGS", f"{context['number_of_users']} new users awaiting approval on RIGS",
get_template("admin_awaiting_approval.txt").render(context), get_template("email/admin_awaiting_approval.txt").render(context),
to=[admin.email], to=[admin.email],
reply_to=[user.email], reply_to=[user.email],
) )
css = finders.find('css/email.css') css = finders.find('css/email.css')
html = Premailer(get_template("admin_awaiting_approval.html").render(context), html = Premailer(get_template("email/admin_awaiting_approval.html").render(context),
external_styles=css).transform() external_styles=css).transform()
email.attach_alternative(html, 'text/html') email.attach_alternative(html, 'text/html')
email.send() email.send()

View File

@@ -84,7 +84,7 @@
bulletFontSize="10"/> bulletFontSize="10"/>
</stylesheet> </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" > <pageTemplate id="Headed" >
<pageGraphics> <pageGraphics>
<image file="static/imgs/paperwork/corner-tr-su.jpg" x="395" y="642" height="200" width="200"/> <image file="static/imgs/paperwork/corner-tr-su.jpg" x="395" y="642" height="200" width="200"/>

View File

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

View File

@@ -1,197 +1,131 @@
{% extends 'base_rigs.html' %} {% extends 'base_rigs.html' %}
{% load static %} {% load static %}
{% block title %}Calendar{% endblock %}
{% block css %}
<link href="{% static 'css/main.css' %}" rel='stylesheet' />
{% endblock %}
{% block js %} {% block js %}
<script src="{% static 'js/moment.js' %}"></script> <script src="{% static 'js/moment.js' %}"></script>
<script src="{% static 'js/main.js' %}"></script>
<script> <script>
viewToUrl = {
'timeGridWeek':'week',
'timeGridDay':'day',
'dayGridMonth':'month'
}
viewFromUrl = {
'week':'timeGridWeek',
'day':'timeGridDay',
'month':'dayGridMonth'
}
var calendar; //Need to access it from jquery ready
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
calendar = new FullCalendar.Calendar(calendarEl, {
themeSystem: 'bootstrap',
aspectRatio: 1.5,
eventTimeFormat: {
'hour': '2-digit',
'minute': '2-digit',
'hour12': false
},
headerToolbar: false,
editable: false,
dayMaxEventRows: true, // allow "more" link when too many events
events: function(fetchInfo, successCallback, failureCallback) {
$.ajax({
url: '/api/event',
dataType: 'json',
data: {
start: moment(fetchInfo.startStr).format("YYYY-MM-DD[T]HH:mm:ss"),
end: moment(fetchInfo.endStr).format("YYYY-MM-DD[T]HH:mm:ss")
},
success: function(doc) {
var events = [];
colours = {
'Provisional': '#FFE89B',
'Confirmed': '#3AB54A' ,
'Booked': '#3AB54A' ,
'Cancelled': 'grey' ,
'non-rig': '#25AAE2'
};
$(doc).each(function() {
end = $(this).attr('latest')
allDay = false
if(end.indexOf("T") < 0){ //If latest does not contain a time
end = moment(end + " 23:59").format("YYYY-MM-DD[T]HH:mm:ss")
allDay = true
}
thisEvent = {
'start': $(this).attr('earliest'),
'end': end,
'className': 'modal-href',
'title': $(this).attr('title'),
'url': $(this).attr('url'),
'allDay': allDay
}
if($(this).attr('is_rig')===true || $(this).attr('status') === "Cancelled"){
thisEvent['color'] = colours[$(this).attr('status')];
}else{
thisEvent['color'] = colours['non-rig'];
}
events.push(thisEvent);
});
successCallback(events);
}
});
},
datesSet: function(info) {
var view = info.view;
// Set the title of the view
$('#calendar-header').text(view.title);
// Enable/Disable "Today" button as required
let $today = $('#today-button');
if(moment().isBetween(view.currentStart, view.currentEnd)){
//Today is within the current view
$today.prop('disabled', true);
}else{
$today.prop('disabled', false);
}
// Set active view select button
let $month = $('#month-button');
let $week = $('#week-button');
let $day = $('#day-button');
switch(view.type){
case 'dayGridMonth':
$month.addClass('active');
$week.removeClass('active');
$day.removeClass('active');
break;
case 'timeGridWeek':
$month.removeClass('active');
$week.addClass('active');
$day.removeClass('active');
break;
case 'timeGridDay':
$month.removeClass('active');
$week.removeClass('active');
$day.addClass('active');
break;
}
history.replaceState(null,null,"{% url 'web_calendar' %}"+viewToUrl[view.type]+'/'+moment(view.currentStart).format('YYYY-MM-DD')+'/');
}
});
calendar.render();
});
$(document).ready(function() { $(document).ready(function() {
// set some button listeners // set some button listeners
$('#next-button').click(function(){ calendar.next(); });
$('#prev-button').click(function(){ calendar.prev(); });
$('#today-button').click(function(){ calendar.today(); }); $('#today-button').click(function(){ calendar.today(); });
$('#month-button').click(function(){ calendar.changeView('dayGridMonth'); });
$('#week-button').click(function(){ calendar.changeView('timeGridWeek'); });
$('#day-button').click(function(){ calendar.changeView('timeGridDay'); });
$('#go-to-date-input').change(function(){ $('#go-to-date-input').change(function(){
if(moment($('#go-to-date-input').val()).isValid()){ if(moment($('#go-to-date-input').val()).isValid()){
$('#go-to-date-button').prop('disabled', false); document.getElementById('go-to-date-button').classList.remove('disabled');
document.getElementById('go-to-date-button').href = "?month=" + moment($('#go-to-date-input').val()).format("YYYY-MM");
} else{ } else{
$('#go-to-date-button').prop('disabled', true); document.getElementById('go-to-date-button').classList.add('disabled');
} }
}); });
$('#go-to-date-button').click(function(){
day = moment($('#go-to-date-input').val());
if(day.isValid()){
calendar.gotoDate(day.format("YYYY-MM-DD"));
} else{
alert('Invalid Date');
}
});
{% if view and date %}
// Go to the initial settings, if they're valid
view = viewFromUrl['{{view}}'];
calendar.changeView(view);
day = moment('{{date}}');
if(day.isValid()){
calendar.gotoDate(day.format("YYYY-MM-DD"));
} else{
console.log('Supplied date is invalid - using default')
}
{% endif %}
}); });
</script> </script>
{% endblock %} {% endblock %}
{% block css %}
<style>
.week {
display:grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-flow: dense;
grid-gap: 2px 10px;
border: 1px solid black;
height: 8em;
align-content: start;
max-width: 100%;
}
.day {
display:contents;
}
.day-label {
grid-row-start: 1;
text-align: right;
margin:0;
font-size: 1em !important;
height: 1em;
}
.week-day, .day-label, .event {
padding: 4px 10px;
}
.event {
background-color: #CCC;
font-size: 0.8em !important;
white-space: nowrap;
overflow: hidden;
}
.event-end {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.event-start {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.week-day {
font-size: 0.8em;
}
@media (max-width: 767.98px) {
.event {
padding: 2px;
}
}
[data-span="1"] { grid-column-end: span 1; }
[data-span="2"] { grid-column-end: span 2; }
[data-span="3"] { grid-column-end: span 3; }
[data-span="4"] { grid-column-end: span 4; }
[data-span="5"] { grid-column-end: span 5; }
[data-span="6"] { grid-column-end: span 6; }
[data-span="7"] { grid-column-end: span 7; }
.day > a {
color: inherit !important;
text-decoration: inherit !important;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row justify-content-center mb-1">
<div class="col-sm-12"> <a class="btn btn-info col-2" href="{% url 'web_calendar' %}?{{ prev_month }}"><span class="fas fa-chevron-left"></span> Previous Month</a>
<div class="pull-left"> <div class="form-inline col-4">
<span id="calendar-header" class="h2"></span> <div class="input-group">
</div> <input type="date" id="go-to-date-input" placeholder="Go to date..." class="form-control">
<div class="form-inline float-right btn-page my-3">
<div class="input-group mx-2">
<input type="date" class="form-control" id="go-to-date-input" placeholder="Go to date...">
<span class="input-group-append"> <span class="input-group-append">
<button class="btn btn-success" id="go-to-date-button" type="button" disabled>Go!</button> <a class="btn btn-success" id="go-to-date-button">Go!</a>
</span> </span>
</div> </div>
<div class="btn-group mx-2">
<button type="button" class="btn btn-primary" id="today-button">Today</button>
</div> </div>
<div class="btn-group mx-2"> <button type="button" class="btn btn-primary col-2" id="today-button">Today</button>
<button type="button" class="btn btn-secondary" id="prev-button"><span class="fas fa-chevron-left"></span></button> <a class="btn btn-info mx-2 col-2" href="{% url 'web_calendar' %}?{{ next_month }}"><span class="fas fa-chevron-right"></span> Next Month</a>
<button type="button" class="btn btn-secondary" id="next-button"><span class="fas fa-chevron-right"></span></button>
</div> </div>
<div class="btn-group ml-2">
<button type="button" class="btn btn-light" id="month-button">Month</button> <div class="week" style="height: 2em;">
<button type="button" class="btn btn-light" id="week-button">Week</button> <div class="week-day">Monday</div>
<button type="button" class="btn btn-light" id="day-button">Day</button> <div class="week-day">Tuesday</div>
<div class="week-day">Wednesday</div>
<div class="week-day">Thursday</div>
<div class="week-day">Friday</div>
<div class="week-day">Saturday</div>
<div class="week-day">Sunday</div>
</div> </div>
{% for week in weeks %}
<div class="week">
{% for day in week %}
{% if day.0 != 0 %}
<div class="day" id="{{day.0}}">
<h3 class="day-label text-muted">{{day.0}}</h3>
{{ day.2|safe }}
</div> </div>
{% else %}
<div class="day"><span style="grid-row-start: 1;">&nbsp;<span></div>
{% endif %}
{% endfor %}
</div> </div>
</div> {% endfor %}
<div class="row">
<div class="col-sm-12">
<div id='calendar'></div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,5 @@
Hi {{object.event.mic.get_full_name|default_if_none:"somebody"}},
Just to let you know your event N{{object.eventdisplay_id}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.event.last_edited_at}}.
The TEC Rig Information Gathering System

View File

@@ -0,0 +1,16 @@
{% extends 'base_client_email.html' %}
{% block content %}
<p>Hi {{event.mic.get_full_name|default_if_none:"Productions Manager"}},</p>
{% if event.mic %}
<p>Just to let you know your event {{event.display_id}} <em>requires<em> a pre-event risk assessment completing prior to the event. Please do so as soon as possible.</p>
{% else %}
<p>This is a reminder that event {{event.display_id}} requires a MIC assigning and a risk assessment completing.</p>
{% endif %}
<p>Fill it out here:</p>
<a href="{{url}}" class="btn btn-info"><span class="fas fa-paperclip"></span> Create Risk Assessment</a>
<p>TEC PA &amp; Lighting</p>
{% endblock %}

View File

@@ -0,0 +1,9 @@
Hi {{event.mic.get_full_name|default_if_none:"Productions Manager"}},
{% if event.mic %}
Just to let you know your event {{event.display_id}} requires a risk assessment completing prior to the event. Please do so as soon as possible.
{% else %}
This is a reminder that event {{event.display_id}} requires a MIC assigning and a risk assessment completing.
{% endif %}
The TEC Rig Information Gathering System

View File

@@ -25,13 +25,11 @@
{% include 'partials/hs_details.html' %} {% include 'partials/hs_details.html' %}
</div> </div>
{% endif %} {% endif %}
{% if event.is_rig %}
{% if event.is_rig and event.internal and perms.RIGS.view_event %} {% if event.is_rig and event.internal and perms.RIGS.view_event %}
<div class="col-md-8 py-3"> <div class="col-md-8 py-3">
{% include 'partials/auth_details.html' %} {% include 'partials/auth_details.html' %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% if not request.is_ajax and perms.RIGS.view_event %} {% if not request.is_ajax and perms.RIGS.view_event %}
<div class="col-sm-12 text-right"> <div class="col-sm-12 text-right">
{% include 'partials/event_detail_buttons.html' %} {% include 'partials/event_detail_buttons.html' %}

View File

@@ -106,6 +106,10 @@
title="Things that aren't service-based, like training, meetings and site visits."> title="Things that aren't service-based, like training, meetings and site visits.">
<button type="button" class="btn btn-info w-25" data-is_rig="0">Non-Rig</button> <button type="button" class="btn btn-info w-25" data-is_rig="0">Non-Rig</button>
</span> </span>
<span data-toggle="tooltip"
title="Record equipment hired in from other companies">
<a href="{% url 'subhire_create' %}" class="btn bg-warning w-25">Subhire</a>
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +0,0 @@
Hi {{object.event.mic.get_full_name|default_if_none:"somebody"}},
Just to let you know your event N{{object.event.pk|stringformat:"05d"}} has been successfully authorised for £{{object.amount}} by {{object.name}} as of {{object.event.last_edited_at}}.
The TEC Rig Information Gathering System

View File

@@ -5,21 +5,6 @@
{% block title %}Request Authorisation{% endblock %} {% 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 %} {% block content %}
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
@@ -33,11 +18,11 @@
<dl class="dl-horizontal"> <dl class="dl-horizontal">
{% if object.person.email %} {% if object.person.email %}
<dt>Person Email</dt> <dt>Person Email</dt>
<dd><span id="person-email">{{ object.person.email }}</span>{% button 'copy' id='#person-email' %}</dd> <dd><span id="person-email" class="pr-1">{{ object.person.email }}</span> {% button 'copy' id='#person-email' %}</dd>
{% endif %} {% endif %}
{% if object.organisation.email %} {% if object.organisation.email %}
<dt>Organisation Email</dt> <dt>Organisation Email</dt>
<dd><span id="org-email">{{ object.organisation.email }}</span>{% button 'copy' id='#org-email' %}</dd> <dd><span id="org-email" class="pr-1">{{ object.organisation.email }}</span> {% button 'copy' id='#org-email' %}</dd>
{% endif %} {% endif %}
</dl> </dl>
{% else %} {% else %}
@@ -57,11 +42,20 @@
</form> </form>
</div> </div>
</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> <script>
$('#auth-request-form').on('submit', function () { $('#auth-request-form').on('submit', function () {
$('#auth-request-form button').attr('disabled', true); $('#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> </script>
{% endblock %} {% endblock %}

View File

@@ -12,21 +12,7 @@
</thead> </thead>
<tbody> <tbody>
{% for event in events %} {% for event in events %}
<tr class="{% if event.cancelled %} <tr class="table-{{event.color}}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
table-secondary
{% elif not event.is_rig %}
table-info
{% elif not event.mic %}
table-danger
{% elif event.confirmed and event.authorised %}
{% if event.dry_hire or event.riskassessment %}
table-success
{% else %}
table-warning
{% endif %}
{% else %}
table-warning
{% endif %}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
<!---Number--> <!---Number-->
<th scope="row" id="event_number">{{ event.display_id }}</th> <th scope="row" id="event_number">{{ event.display_id }}</th>
<!--Dates & Times--> <!--Dates & Times-->

View File

@@ -0,0 +1,27 @@
{% extends request.is_ajax|yesno:"base_ajax.html,base_rigs.html" %}
{% load markdown_tags %}
{% block content %}
<div class="row my-3 py-3">
<div class="col-md-6">
{% include 'partials/contact_details.html' %}
</div>
<div class="col-md-6">
{% include 'partials/event_details.html' %}
</div>
{% if not request.is_ajax and perms.RIGS.view_event %}
<div class="col-sm-12 text-right">
{% include 'partials/last_edited.html' with target="event_history" %}
</div>
{% endif %}
</div>
{% endblock %}
{% if request.is_ajax %}
{% block footer %}
{% if perms.RIGS.view_event %}
{% include 'partials/last_edited.html' with target="event_history" %}
{% endif %}
{% endblock %}
{% endif %}

View File

@@ -0,0 +1,192 @@
{% 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 mb-2">
<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>
<div class="col-md-6 mb-2">
<div class="card">
<div class="card-header">Associated Event(s)</div>
<div class="card-body">
<div class="form-group">
<select multiple name="events" id="events_id" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='event' %}"></select>
</div>
</div>
</div>
</div>
{# Event details #}
<div class="col-md-6 mb-2">
<div class="card card-default">
<div class="card-header">Hire 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="col-md-6 mb-2">
<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 font-weight-bold" 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>
<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} return {'submit': True, 'class': 'btn-info', 'icon': 'fa-search', 'text': 'Search', 'id': id, 'style': style}
elif type == 'submit': elif type == 'submit':
return {'submit': True, 'class': 'btn-primary', 'icon': 'fa-save', 'text': 'Save', 'id': id, 'style': style} 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} return {'target': url, 'pk': pk, 'class': clazz, 'icon': icon, 'text': text, 'id': id, 'style': style}

View File

@@ -43,12 +43,8 @@ urlpatterns = [
# Rigboard # Rigboard
path('rigboard/', login_required(views.RigboardIndex.as_view()), name='rigboard'), path('rigboard/', login_required(views.RigboardIndex.as_view()), name='rigboard'),
path('rigboard/calendar/', login_required()(views.WebCalendar.as_view()), re_path(r'^rigboard/calendar/$', login_required()(views.WebCalendar.as_view()),
name='web_calendar'), name='web_calendar'),
re_path(r'^rigboard/calendar/(?P<view>(month|week|day))/$',
login_required()(views.WebCalendar.as_view()), name='web_calendar'),
re_path(r'^rigboard/calendar/(?P<view>(month|week|day))/(?P<date>(\d{4}-\d{2}-\d{2}))/$',
login_required()(views.WebCalendar.as_view()), name='web_calendar'),
path('rigboard/archive/', RedirectView.as_view(permanent=True, pattern_name='event_archive')), path('rigboard/archive/', RedirectView.as_view(permanent=True, pattern_name='event_archive')),
@@ -70,12 +66,24 @@ urlpatterns = [
path('event/<int:pk>/duplicate/', permission_required_with_403('RIGS.add_event')(views.EventDuplicate.as_view()), path('event/<int:pk>/duplicate/', permission_required_with_403('RIGS.add_event')(views.EventDuplicate.as_view()),
name='event_duplicate'), name='event_duplicate'),
# Subhire
path('subhire/<int:pk>/', views.SubhireDetail.as_view(),
name='subhire_detail'),
path('subhire/create/', permission_required_with_403('RIGS.add_event')(views.SubhireCreate.as_view()),
name='subhire_create'),
path('subhire/<int:pk>/edit', views.SubhireEdit.as_view(),
name='subhire_update'),
path('subhire/upcoming', views.SubhireList.as_view(),
name='subhire_list'),
# Event H&S # Event H&S
path('event/hs/', permission_required_with_403('RIGS.view_riskassessment')(views.HSList.as_view()), name='hs_list'), path('event/hs/', permission_required_with_403('RIGS.view_riskassessment')(views.HSList.as_view()), name='hs_list'),
path('event/<int:pk>/ra/', permission_required_with_403('RIGS.add_riskassessment')(views.EventRiskAssessmentCreate.as_view()), path('event/<int:pk>/ra/', permission_required_with_403('RIGS.add_riskassessment')(views.EventRiskAssessmentCreate.as_view()),
name='event_ra'), name='event_ra'),
path('event/ra/<int:pk>/', permission_required_with_403('RIGS.view_riskassessment')(views.EventRiskAssessmentDetail.as_view()), path('event/ra/<int:pk>/', login_required(views.EventRiskAssessmentDetail.as_view()),
name='ra_detail'), name='ra_detail'),
path('event/ra/<int:pk>/edit/', permission_required_with_403('RIGS.change_riskassessment')(views.EventRiskAssessmentEdit.as_view()), path('event/ra/<int:pk>/edit/', permission_required_with_403('RIGS.change_riskassessment')(views.EventRiskAssessmentEdit.as_view()),
name='ra_edit'), name='ra_edit'),
@@ -87,7 +95,7 @@ urlpatterns = [
path('event/<int:pk>/checklist/', permission_required_with_403('RIGS.add_eventchecklist')(views.EventChecklistCreate.as_view()), path('event/<int:pk>/checklist/', permission_required_with_403('RIGS.add_eventchecklist')(views.EventChecklistCreate.as_view()),
name='event_ec'), name='event_ec'),
path('event/checklist/<int:pk>/', permission_required_with_403('RIGS.view_eventchecklist')(views.EventChecklistDetail.as_view()), path('event/checklist/<int:pk>/', login_required(views.EventChecklistDetail.as_view()),
name='ec_detail'), name='ec_detail'),
path('event/checklist/<int:pk>/edit/', permission_required_with_403('RIGS.change_eventchecklist')(views.EventChecklistEdit.as_view()), path('event/checklist/<int:pk>/edit/', permission_required_with_403('RIGS.change_eventchecklist')(views.EventChecklistEdit.as_view()),
name='ec_edit'), name='ec_edit'),

53
RIGS/utils.py Normal file
View File

@@ -0,0 +1,53 @@
from datetime import datetime, timedelta, date
import calendar
from calendar import HTMLCalendar
from RIGS.models import BaseEvent, Event, Subhire
class Calendar(HTMLCalendar):
def __init__(self, year=None, month=None):
self.year = year
self.month = month
super(Calendar, self).__init__()
def get_html(self, day, event):
return f"<a href='{event.get_absolute_url()}' class='modal-href' style='display: contents;'><div class='event event-start event-end bg-{event.color}' data-span='{event.length}' style='grid-column-start: calc({day[1]} + 1)'>{event}</div></a>"
def formatmonth(self, withyear=True):
events = Event.objects.filter(start_date__year=self.year, start_date__month=self.month)
subhires = Subhire.objects.filter(start_date__year=self.year, start_date__month=self.month)
weeks = self.monthdays2calendar(self.year, self.month)
data = []
for week in weeks:
weeks_events = []
for day in week:
events_per_day = events.order_by("start_date").filter(start_date__day=day[0])
subhires_per_day = subhires.order_by("start_date").filter(start_date__day=day[0])
event_html = ""
for event in events_per_day:
event_html += self.get_html(day, event)
for sh in subhires_per_day:
event_html += self.get_html(day, sh)
weeks_events.append((day[0], day[1], event_html))
data.append(weeks_events)
return data
def get_date(req_day):
if req_day:
year, month = (int(x) for x in req_day.split('-'))
return date(year, month, day=1)
return datetime.today()
def prev_month(d):
first = d.replace(day=1)
prev_month = first - timedelta(days=1)
month = f'month={str(prev_month.year)}-{str(prev_month.month)}'
return month
def next_month(d):
days_in_month = calendar.monthrange(d.year, d.month)[1]
last = d.replace(day=days_in_month)
next_month = last + timedelta(days=1)
month = f'month={str(next_month.year)}-{str(next_month.month)}'
return month

View File

@@ -3,3 +3,4 @@ from .finance import *
from .hs import * from .hs import *
from .ical import * from .ical import *
from .rigboard import * from .rigboard import *
from .subhire import *

View File

@@ -17,12 +17,13 @@ from django.template.loader import get_template
from django.urls import reverse from django.urls import reverse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.html import mark_safe
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import generic from django.views import generic
from PyRIGS import decorators from PyRIGS import decorators
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
from RIGS import models, forms from RIGS import models, forms, utils
__author__ = 'ghost' __author__ = 'ghost'
@@ -40,14 +41,25 @@ class RigboardIndex(generic.TemplateView):
return context return context
class WebCalendar(generic.TemplateView): class WebCalendar(generic.ListView):
model = models.Event
template_name = 'calendar.html' template_name = 'calendar.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['view'] = kwargs.get('view', '') # use today's date for the calendar
context['date'] = kwargs.get('date', '') d = utils.get_date(self.request.GET.get('month', None))
# context['page_title'] = "Calendar" context['prev_month'] = utils.prev_month(d)
context['next_month'] = utils.next_month(d)
# Instantiate our calendar class with today's year and date
cal = utils.Calendar(d.year, d.month)
# Call the formatmonth method, which returns our calendar as a table
html_cal = cal.formatmonth(withyear=True)
# context['calendar'] = mark_safe(html_cal)
context['weeks'] = html_cal
context['page_title'] = d.strftime("%B %Y")
return context return context
@@ -61,10 +73,6 @@ class EventDetail(generic.DetailView, ModalURLMixin):
if self.object.dry_hire: if self.object.dry_hire:
title += " <span class='badge badge-secondary'>Dry Hire</span>" title += " <span class='badge badge-secondary'>Dry Hire</span>"
context['page_title'] = title context['page_title'] = title
if is_ajax(self.request):
context['override'] = "base_ajax.html"
else:
context['override'] = 'base_assets.html'
return context return context
@@ -342,12 +350,12 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
f"{self.object.display_id} | {self.object.name} - Event Authorisation Request", f"{self.object.display_id} | {self.object.name} - Event Authorisation Request",
get_template("eventauthorisation_client_request.txt").render(context), get_template("email/eventauthorisation_client_request.txt").render(context),
to=[email], to=[email],
reply_to=[self.request.user.email], reply_to=[self.request.user.email],
) )
css = finders.find('css/email.css') css = finders.find('css/email.css')
html = premailer.Premailer(get_template("eventauthorisation_client_request.html").render(context), html = premailer.Premailer(get_template("email/eventauthorisation_client_request.html").render(context),
external_styles=css).transform() external_styles=css).transform()
msg.attach_alternative(html, 'text/html') msg.attach_alternative(html, 'text/html')
@@ -357,7 +365,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix
class EventAuthoriseRequestEmailPreview(generic.DetailView): class EventAuthoriseRequestEmailPreview(generic.DetailView):
template_name = "eventauthorisation_client_request.html" template_name = "email/eventauthorisation_client_request.html"
model = models.Event model = models.Event
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):

58
RIGS/views/subhire.py Normal file
View File

@@ -0,0 +1,58 @@
from django.urls import reverse_lazy
from django.views import generic
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
from RIGS import models, forms
class SubhireDetail(generic.DetailView, ModalURLMixin):
template_name = 'subhire_detail.html'
model = models.Subhire
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = f"{self.object.display_id} | {self.object.name}"
return context
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
def get_success_url(self):
return reverse_lazy('subhire_detail', kwargs={'pk': self.object.pk})
class SubhireEdit(generic.UpdateView):
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'] = f"Edit Subhire: {self.object.display_id} | {self.object.name}"
context['edit'] = True
form = context['form']
get_related(form, context)
return context
def get_success_url(self):
return reverse_lazy('subhire_detail', kwargs={'pk': self.object.pk})
class SubhireList(generic.TemplateView):
template_name = 'rigboard.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['events'] = models.Subhire.objects.current_events()
context['page_title'] = "Upcoming Subhire"
return context

View File

@@ -0,0 +1,18 @@
# 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

@@ -0,0 +1,24 @@
# 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

@@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-12-11 00:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0026_auto_20220526_1623'),
]
operations = [
migrations.AddField(
model_name='asset',
name='nickname',
field=models.CharField(blank=True, max_length=120),
),
]

View File

@@ -95,7 +95,7 @@ class AssetManager(models.Manager):
def search(self, query=None): def search(self, query=None):
qs = self.get_queryset() qs = self.get_queryset()
if query is not None: if query is not None:
or_lookup = (Q(asset_id__exact=query.upper()) | Q(description__icontains=query) | Q(serial_number__exact=query)) or_lookup = (Q(asset_id__exact=query.upper()) | Q(description__icontains=query) | Q(serial_number__exact=query) | Q(nickname__icontains=query))
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
return qs return qs
@@ -105,6 +105,11 @@ def get_available_asset_id(wanted_prefix=""):
return 9000 if last_asset is None else wanted_prefix + str(last_asset.asset_id_number + 1) 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 @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',
@@ -117,9 +122,10 @@ class Asset(models.Model, RevisionMixin):
purchased_from = models.ForeignKey(to=Supplier, on_delete=models.SET_NULL, blank=True, null=True, related_name="assets") purchased_from = models.ForeignKey(to=Supplier, on_delete=models.SET_NULL, blank=True, null=True, related_name="assets")
date_acquired = models.DateField() date_acquired = models.DateField()
date_sold = models.DateField(blank=True, null=True) date_sold = models.DateField(blank=True, null=True)
purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10) purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
salvage_value = models.DecimalField(null=True, decimal_places=2, max_digits=10) replacement_cost = models.DecimalField(null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
nickname = models.CharField(max_length=120, blank=True)
# Audit # Audit
last_audited_at = models.DateTimeField(blank=True, null=True) last_audited_at = models.DateTimeField(blank=True, null=True)
@@ -165,12 +171,6 @@ class Asset(models.Model, RevisionMixin):
errdict["asset_id"] = [ errdict["asset_id"] = [
"An Asset ID can only consist of letters and numbers, with a final number"] "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 self.is_cable:
if not self.length or self.length <= 0: if not self.length or self.length <= 0:
errdict["length"] = ["The length of a cable must be more than 0"] errdict["length"] = ["The length of a cable must be more than 0"]

View File

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

View File

@@ -21,6 +21,10 @@
<label for="{{ form.description.id_for_label }}">Description</label> <label for="{{ form.description.id_for_label }}">Description</label>
{% render_field form.description|add_class:'form-control' value=object.description %} {% render_field form.description|add_class:'form-control' value=object.description %}
</div> </div>
<div class="form-group">
<label for="{{ form.nickname.id_for_label }}">Nickname</label>
{% render_field form.nickname|add_class:'form-control' value=object.nickname %}
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.category.id_for_label }}" >Category</label> <label for="{{ form.category.id_for_label }}" >Category</label>
{% render_field form.category|add_class:'form-control'%} {% render_field form.category|add_class:'form-control'%}
@@ -45,7 +49,10 @@
{% else %} {% else %}
<dt>Asset ID</dt> <dt>Asset ID</dt>
<dd>{{ object.asset_id }}</dd> <dd>{{ object.asset_id }}</dd>
{% if object.nickname %}
<dt>Nickname</dt>
<dd>"{{ object.nickname }}"</dd>
{% endif %}
<dt>Description</dt> <dt>Description</dt>
<dd>{{ object.description }}</dd> <dd>{{ object.description }}</dd>

View File

@@ -1,7 +1,7 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load title_spaced from filters %} {% load title_spaced from filters %}
{% spaceless %} {% spaceless %}
<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> <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>
{% if append or prepend %} {% if append or prepend %}
<div class="input-group {{col}}"> <div class="input-group {{col}}">
{% if prepend %} {% if prepend %}

View File

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

View File

@@ -28,13 +28,13 @@ def cable_type(db):
@pytest.fixture @pytest.fixture
def test_cable(db, category, status, cable_type): 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", salvage_value=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", replacement_cost=50)
yield cable yield cable
cable.delete() cable.delete()
@pytest.fixture @pytest.fixture
def test_asset(db, category, status): 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), salvage_value=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), replacement_cost=100)
yield asset yield asset
asset.delete() asset.delete()

View File

@@ -79,7 +79,7 @@ class AssetForm(FormPage):
'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')), 'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')),
'comments': (regions.SimpleMDETextArea, (By.ID, 'id_comments')), 'comments': (regions.SimpleMDETextArea, (By.ID, 'id_comments')),
'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')), 'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')),
'salvage_value': (regions.TextBox, (By.ID, 'id_salvage_value')), 'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')),
'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')), 'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')),
'date_sold': (regions.DatePicker, (By.ID, 'id_date_sold')), 'date_sold': (regions.DatePicker, (By.ID, 'id_date_sold')),
'category': (regions.SingleSelectPicker, (By.ID, 'id_category')), 'category': (regions.SingleSelectPicker, (By.ID, 'id_category')),
@@ -221,7 +221,7 @@ class AssetAuditList(AssetList):
'description': (regions.TextBox, (By.ID, 'id_description')), 'description': (regions.TextBox, (By.ID, 'id_description')),
'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')), 'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')),
'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')), 'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')),
'salvage_value': (regions.TextBox, (By.ID, 'id_salvage_value')), 'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')),
'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')), 'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')),
'category': (regions.SingleSelectPicker, (By.ID, 'id_category')), 'category': (regions.SingleSelectPicker, (By.ID, 'id_category')),
'status': (regions.SingleSelectPicker, (By.ID, 'id_status')), '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.status = status.name
page.serial_number = "MELON-MELON-MELON" page.serial_number = "MELON-MELON-MELON"
page.comments = "You might need that" page.comments = "You might need that"
page.salvage_value = "666" page.replacement_cost = "666"
page.is_cable = True page.is_cable = True
assert logged_in_browser.driver.find_element(By.ID, 'cable-table').is_displayed() 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.comments = comments = "This is actually a sledgehammer, not a cable..."
self.page.purchase_price = "12.99" self.page.purchase_price = "12.99"
self.page.salvage_value = "99.12" self.page.replacement_cost = "99.12"
self.page.date_acquired = acquired = datetime.date(2020, 5, 2) self.page.date_acquired = acquired = datetime.date(2020, 5, 2)
self.page.purchased_from_selector.toggle() self.page.purchased_from_selector.toggle()
self.assertTrue(self.page.purchased_from_selector.is_open) 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, self.connector = models.Connector.objects.create(description="Trailer Socket", current_rating=1,
voltage_rating=40, num_pins=13) voltage_rating=40, num_pins=13)
models.Asset.objects.create(asset_id="1", description="Trailer Cable", status=self.status, models.Asset.objects.create(asset_id="1", description="Trailer Cable", status=self.status,
category=self.category, date_acquired=datetime.date(2020, 2, 1), salvage_value=10) category=self.category, date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
models.Asset.objects.create(asset_id="11", description="Trailerboard", status=self.status, models.Asset.objects.create(asset_id="11", description="Trailerboard", status=self.status,
category=self.category, date_acquired=datetime.date(2020, 2, 1), salvage_value=10) category=self.category, date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
models.Asset.objects.create(asset_id="111", description="Erms", status=self.status, category=self.category, models.Asset.objects.create(asset_id="111", description="Erms", status=self.status, category=self.category,
date_acquired=datetime.date(2020, 2, 1), salvage_value=10) date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
self.asset = models.Asset.objects.create(asset_id="1111", description="A hammer", status=self.status, self.asset = models.Asset.objects.create(asset_id="1111", description="A hammer", status=self.status,
category=self.category, category=self.category,
date_acquired=datetime.date(2020, 2, 1), salvage_value=10) date_acquired=datetime.date(2020, 2, 1), replacement_cost=10)
self.page = pages.AssetAuditList(self.driver, self.live_server_url).open() self.page = pages.AssetAuditList(self.driver, self.live_server_url).open()
self.wait = WebDriverWait(self.driver, 20) self.wait = WebDriverWait(self.driver, 20)

View File

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

View File

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

View File

@@ -24,7 +24,6 @@ function fonts(done) {
function styles(done) { function styles(done) {
const bs_select = ["bootstrap-select.css", "ajax-bootstrap-select.css"] const bs_select = ["bootstrap-select.css", "ajax-bootstrap-select.css"]
return gulp.src(['pipeline/source_assets/scss/**/*.scss', return gulp.src(['pipeline/source_assets/scss/**/*.scss',
'node_modules/fullcalendar/main.css',
'node_modules/bootstrap-select/dist/css/bootstrap-select.css', 'node_modules/bootstrap-select/dist/css/bootstrap-select.css',
'node_modules/ajax-bootstrap-select/dist/css/ajax-bootstrap-select.css', 'node_modules/ajax-bootstrap-select/dist/css/ajax-bootstrap-select.css',
'node_modules/easymde/dist/easymde.min.css' 'node_modules/easymde/dist/easymde.min.css'
@@ -59,7 +58,6 @@ function scripts() {
'node_modules/html5sortable/dist/html5sortable.min.js', 'node_modules/html5sortable/dist/html5sortable.min.js',
'node_modules/clipboard/dist/clipboard.min.js', 'node_modules/clipboard/dist/clipboard.min.js',
'node_modules/moment/moment.js', 'node_modules/moment/moment.js',
'node_modules/fullcalendar/main.js',
'node_modules/bootstrap-select/dist/js/bootstrap-select.js', 'node_modules/bootstrap-select/dist/js/bootstrap-select.js',
'node_modules/ajax-bootstrap-select/dist/js/ajax-bootstrap-select.js', 'node_modules/ajax-bootstrap-select/dist/js/ajax-bootstrap-select.js',
'node_modules/easymde/dist/easymde.min.js', 'node_modules/easymde/dist/easymde.min.js',

7006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
"clipboard": "^2.0.8", "clipboard": "^2.0.8",
"cssnano": "^5.0.13", "cssnano": "^5.0.13",
"easymde": "^2.16.1", "easymde": "^2.16.1",
"fullcalendar": "^5.10.1",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"gulp-flatten": "^0.4.0", "gulp-flatten": "^0.4.0",
@@ -27,14 +26,14 @@
"html5sortable": "^0.13.3", "html5sortable": "^0.13.3",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"konami": "^1.6.3", "konami": "^1.6.3",
"moment": "^2.29.2", "moment": "^2.29.4",
"node-sass": "^7.0.0", "node-sass": "^7.0.3",
"popper.js": "^1.16.1", "popper.js": "^1.16.1",
"postcss": "^8.4.5", "postcss": "^8.4.5",
"uglify-js": "^3.14.5" "uglify-js": "^3.14.5"
}, },
"devDependencies": { "devDependencies": {
"browser-sync": "^2.27.7" "browser-sync": "^2.27.10"
}, },
"scripts": { "scripts": {
"gulp": "gulp", "gulp": "gulp",

View File

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

View File

@@ -4,16 +4,16 @@ Date.prototype.getISOString = function () {
var dd = this.getDate().toString(); var dd = this.getDate().toString();
return yyyy + '-' + (mm[1] ? mm : "0" + mm[0]) + '-' + (dd[1] ? dd : "0" + dd[0]); // padding return yyyy + '-' + (mm[1] ? mm : "0" + mm[0]) + '-' + (dd[1] ? dd : "0" + dd[0]); // padding
}; };
jQuery(document).ready(function () { $(document).ready(function () {
jQuery(document).on('click', '.modal-href', function (e) { $(document).on('click', '.modal-href', function (e) {
$link = jQuery(this); $link = $(this);
// Anti modal inception // Anti modal inception
if ($link.parents('#modal').length == 0) { if ($link.parents('#modal').length == 0) {
e.preventDefault(); e.preventDefault();
modaltarget = $link.data('target'); modaltarget = $link.data('target');
modalobject = ""; modalobject = "";
jQuery('#modal').load($link.attr('href'), function (e) { $('#modal').load($link.attr('href'), function (e) {
jQuery('#modal').modal(); $('#modal').modal();
}); });
} }
}); });
@@ -23,7 +23,6 @@ jQuery(document).ready(function () {
s.type = 'text/javascript'; s.type = 'text/javascript';
document.body.appendChild(s); document.body.appendChild(s);
s.src = '{% static "js/asteroids.min.js"%}'; s.src = '{% static "js/asteroids.min.js"%}';
ga('send', 'event', 'easter_egg', 'activated');
} }
easter_egg.load(); easter_egg.load();
}); });

View File

@@ -281,3 +281,7 @@ html.embedded {
.bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light { .bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light {
padding-right: 1rem !important; padding-right: 1rem !important;
} }
.badge-purple, .bg-purple {
background-color: #800080 !important;
}

View File

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

View File

@@ -1,5 +1,10 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% include 'form_errors.html' %} {% 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"> <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 %} <form action="{% url 'login' %}" method="post" role="form" target="_self">{% csrf_token %}
<div class="form-group"> <div class="form-group">

View File

@@ -1,5 +1,5 @@
from PyRIGS.decorators import user_passes_test_with_403 from PyRIGS.decorators import user_passes_test_with_403
def has_perm_or_supervisor(perm, login_url=None, oembed_view=None): 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) or u.has_perm(perm), login_url=login_url, oembed_view=oembed_view) 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 @property
def activity_feed_string(self): 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 @classmethod
def get_colour_from_depth(cls, depth): 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> <a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
</div> </div>
</li> </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> <li class="nav-item"><a class="nav-link" href="{% url 'session_log' %}"><span class="fas fa-users"></span> Log Session</a></li>
{% endif %} {% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li> <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 %} {% render_field form.date|add_class:'form-control'|attr:'type="date"' value=training_date %}
{% endwith %} {% endwith %}
</div> </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>
<div class="form-group form-row"> <div class="form-group form-row">
<label for="id_notes" class="col-sm-2 col-form-label">Notes</label> <label for="id_notes" class="col-sm-2 col-form-label">Notes</label>

View File

@@ -44,7 +44,7 @@
{% endblock %} {% endblock %}
{% block content %} {% 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"> <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"> <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 <span class="fas fa-plus"></span> Add New Requirement
@@ -79,9 +79,9 @@
{% endfor %} {% endfor %}
<tr><th colspan="3" class="text-center">{{object}}</th></tr> <tr><th colspan="3" class="text-center">{{object}}</th></tr>
<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.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 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 %}<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.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> </tr>
</tbody> </tbody>
</table> </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"> <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 <span class="fas fa-plus"></span> Add New Training Record
</a> </a>

View File

@@ -1,5 +1,5 @@
<label for="supervisor" class="col-sm-2 col-form-label">Supervisor</label> <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 %} {% if supervisor %}
<option value="{{form.supervisor.value}}" selected>{{ supervisor }}</option> <option value="{{form.supervisor.value}}" selected>{{ supervisor }}</option>
{% endif %} {% endif %}

View File

@@ -28,25 +28,26 @@
{% include 'form_errors.html' %} {% include 'form_errors.html' %}
{% csrf_token %} {% csrf_token %}
<h3>People</h3> <h3>People</h3>
<div class="form-group row"> <div class="form-group row" id="supervisor_group">
{% include 'partials/supervisor_field.html' %} {% include 'partials/supervisor_field.html' %}
</div> </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> <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> </select>
</div> </div>
<h3>Training Items</h3> <h3>Training Items</h3>
{% for depth in depths %} {% 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> <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> </select>
</div> </div>
{% endfor %} {% endfor %}
<h3>Session Information</h3> <h3>Session Information</h3>
<div class="form-group"> <div class="form-group row">
{% include 'partials/form_field.html' with field=form.date %} {% include 'partials/form_field.html' with field=form.date col='col-sm-6' %}
{% button 'today' id='id_date' %}
</div> </div>
<div class="form-group"> <div class="form-group">
{% include 'partials/form_field.html' with field=form.notes %} {% include 'partials/form_field.html' with field=form.notes %}

View File

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

View File

@@ -30,6 +30,15 @@ def training_item(db):
training_item.delete() 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 @pytest.fixture
def level(db): def level(db):
level = models.TrainingLevel.objects.create(description="There is no description.", level=models.TrainingLevel.TECHNICIAN) level = models.TrainingLevel.objects.create(description="There is no description.", level=models.TrainingLevel.TECHNICIAN)

View File

@@ -40,3 +40,42 @@ class AddQualification(FormPage):
@property @property
def success(self): def success(self):
return 'add' not in self.driver.current_url 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 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): 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() page = pages.AddQualification(logged_in_browser.driver, live_server.url, pk=trainee.pk).open()
# assert page.name in str(trainee) # 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 assert page.item_selector.options[0].selected
page.item_selector.toggle() page.item_selector.toggle()
page.supervisor_selector.toggle() select_super(page, supervisor)
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() page.submit()
assert page.success assert page.success
@@ -44,3 +48,32 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
assert qualification.date == date assert qualification.date == date
assert qualification.notes == "A note" assert qualification.notes == "A note"
assert qualification.depth == models.TrainingItemQualification.STARTED 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}) 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', 'date', 'Qualification date may not be in the future')
assertFormError(response, 'form', 'supervisor', 'One may not supervise oneself...') 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) print(response.content)
assertFormError(response, 'form', 'supervisor', 'Selected supervisor must actually *be* a supervisor...') assertFormError(response, 'form', 'supervisor', 'Selected supervisor must actually *be* a supervisor...')

View File

@@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from training.decorators import has_perm_or_supervisor from training.decorators import is_supervisor
from training import views, models from training import views, models
from versioning.views import VersionHistory from versioning.views import VersionHistory
@@ -11,23 +11,22 @@ urlpatterns = [
path('item/<int:pk>/qualified_users/', login_required(views.ItemQualifications.as_view()), name='item_qualification'), path('item/<int:pk>/qualified_users/', login_required(views.ItemQualifications.as_view()), name='item_qualification'),
path('trainee/list/', login_required(views.TraineeList.as_view()), name='trainee_list'), path('trainee/list/', login_required(views.TraineeList.as_view()), name='trainee_list'),
path('trainee/<int:pk>/', path('trainee/<int:pk>/', login_required(views.TraineeDetail.as_view()),
has_perm_or_supervisor('RIGS.view_profile')(views.TraineeDetail.as_view()),
name='trainee_detail'), 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>/history', login_required(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>/add_qualification/', is_supervisor()(views.AddQualification.as_view()),
name='add_qualification'), 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'), name='edit_qualification'),
path('levels/', login_required(views.LevelList.as_view()), name='level_list'), 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>/', 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>/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/<int:pk>/add_requirement/', is_supervisor()(views.AddLevelRequirement.as_view()), name='add_requirement'),
path('level/remove_requirement/<int:pk>/', login_required(views.RemoveRequirement.as_view()), name='remove_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('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()