Compare commits

..

41 Commits

Author SHA1 Message Date
c12dae6676 pep8 2023-02-20 18:02:34 +00:00
ea6dfca56c Added export button to item list 2023-02-20 17:56:29 +00:00
5ab338c373 Fix line length issue and better spacing on exported PDF 2023-02-20 17:47:31 +00:00
67ba872c26 Initial version of training item export view 2023-02-20 16:22:34 +00:00
d55ec47b18 Add 'description' field to TrainingItems
Renamed existing field to name, removed the dummy property.
2023-02-19 14:21:40 +00:00
dependabot[bot]
6550ed2318 Build(deps): Bump django from 3.2.17 to 3.2.18 (#522)
Bumps [django](https://github.com/django/django) from 3.2.17 to 3.2.18.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.17...3.2.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-16 11:10:11 +00:00
dependabot[bot]
c9759a6339 Build(deps): Bump qs and browser-sync (#521)
Bumps [qs](https://github.com/ljharb/qs) to 6.11.0 and updates ancestor dependency [browser-sync](https://github.com/BrowserSync/browser-sync). These dependencies need to be updated together.


Updates `qs` from 6.2.3 to 6.11.0
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.2.3...v6.11.0)

Updates `browser-sync` from 2.27.10 to 2.27.11
- [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.10...v2.27.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-09 14:56:22 +00:00
dependabot[bot]
b637c4e452 Build(deps): Bump http-cache-semantics from 4.1.0 to 4.1.1 (#520)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-04 10:20:18 +00:00
dependabot[bot]
8986b94b07 Build(deps): Bump django from 3.2.16 to 3.2.17 (#519)
Bumps [django](https://github.com/django/django) from 3.2.16 to 3.2.17.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.16...3.2.17)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-04 10:20:04 +00:00
86c033ba97 Update django.yml 2022-12-11 14:18:05 +00:00
52fd662340 Update django.yml 2022-12-11 14:13:47 +00:00
9818ed995f Update django.yml 2022-12-11 14:13:33 +00:00
5178614d71 Update django.yml
Rollback runner for now
2022-12-11 01:01:01 +00:00
a7bf990666 FIX: Do not specify minor version of python in CI 2022-12-11 00:51:19 +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
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
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
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
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
39ed5aefb4 Set printed PDF title == filename
Should fix #497
2022-10-18 12:48:25 +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
73 changed files with 4940 additions and 4472 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

@@ -13,26 +13,20 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.9.1 python-version: 3.9
- uses: actions/cache@v2 cache: 'pipenv'
id: pcache
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('Pipfile.lock') }}
restore-keys: |
${{ runner.os }}-pipenv-
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install --upgrade pip pipenv python3 -m pip install --upgrade pip pipenv
pipenv install -d pipenv install -d
# if: steps.pcache.outputs.cache-hit != 'true' # if: steps.pcache.outputs.cache-hit != 'true'
- name: Cache Static Files - name: Cache Static Files
id: static-cache id: static-cache
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: 'pipeline/built_assets' path: 'pipeline/built_assets'
key: ${{ hashFiles('package-lock.json') }}-${{ hashFiles('pipeline/source_assets') }} key: ${{ hashFiles('package-lock.json') }}-${{ hashFiles('pipeline/source_assets') }}
@@ -43,9 +37,9 @@ jobs:
- name: Basic Checks - name: Basic Checks
run: | run: |
pipenv run pycodestyle . --exclude=migrations,node_modules pipenv run pycodestyle . --exclude=migrations,node_modules
pipenv run python manage.py check pipenv run python3 manage.py check
pipenv run python manage.py makemigrations --check --dry-run pipenv run python3 manage.py makemigrations --check --dry-run
pipenv run python manage.py collectstatic --noinput pipenv run python3 manage.py collectstatic --noinput
- name: Run Tests - name: Run Tests
run: pipenv run pytest -n auto -vv --cov run: pipenv run pytest -n auto -vv --cov
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2

13
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"
@@ -26,18 +25,16 @@ django-ical = "~=1.7.1"
django-recurrence = "~=1.10.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 +42,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"
@@ -82,10 +79,10 @@ django-hcaptcha = "*"
pikepdf = "*" 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 = "*"

1324
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

@@ -321,45 +321,60 @@ class OEmbedView(generic.View):
return JsonResponse(data) return JsonResponse(data)
def get_info_string(user):
user_str = f"by {user.name} " if user else ""
time = timezone.now().strftime('%d/%m/%Y %H:%I')
return f"[Paperwork generated {user_str}on {time}"
def render_pdf_response(template, context, append_terms):
merger = PdfFileMerger()
rml = template.render(context)
buffer = rml2pdf.parseString(rml)
merger.append(PdfFileReader(buffer))
buffer.close()
if append_terms:
terms = urllib.request.urlopen(settings.TERMS_OF_HIRE_URL)
merger.append(BytesIO(terms.read()))
merged = BytesIO()
merger.write(merged)
response = HttpResponse(content_type='application/pdf')
f = context['filename']
response['Content-Disposition'] = f'filename="{f}"'
response.write(merged.getvalue())
return response
class PrintView(generic.View): class PrintView(generic.View):
append_terms = False append_terms = False
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
obj = get_object_or_404(self.model, pk=self.kwargs['pk']) obj = get_object_or_404(self.model, pk=self.kwargs['pk'])
user_str = f"by {self.request.user.name} " if self.request.user is not None else ""
time = timezone.now().strftime('%d/%m/%Y %H:%I')
object_name = re.sub(r'[^a-zA-Z0-9 \n\.]', '', obj.name) object_name = re.sub(r'[^a-zA-Z0-9 \n\.]', '', obj.name)
context = { context = {
'object': obj, 'object': obj,
'current_user': self.request.user, 'current_user': self.request.user,
'object_name': object_name, 'object_name': object_name,
'info_string': f"[Paperwork generated {user_str}on {time} - {obj.current_version_id}]", 'info_string': get_info_string(self.request.user) + "- {obj.current_version_id}]",
} }
return context return context
def get(self, request, pk): def get(self, request, pk):
template = get_template(self.template_name) return render_pdf_response(get_template(self.template_name), self.get_context_data(), self.append_terms)
merger = PdfFileMerger()
context = self.get_context_data() class PrintListView(generic.ListView):
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['current_user'] = self.request.user,
context['info_string'] = get_info_string(self.request.user) + "]"
return context
rml = template.render(context) def get(self, request):
buffer = rml2pdf.parseString(rml) self.object_list = self.get_queryset()
merger.append(PdfFileReader(buffer)) return render_pdf_response(get_template(self.template_name), self.get_context_data(), False)
buffer.close()
if self.append_terms:
terms = urllib.request.urlopen(settings.TERMS_OF_HIRE_URL)
merger.append(BytesIO(terms.read()))
merged = BytesIO()
merger.write(merged)
response = HttpResponse(content_type='application/pdf')
f = context['filename']
response['Content-Disposition'] = f'filename="{f}"'
response.write(merged.getvalue())
return response

View File

@@ -217,7 +217,7 @@ class EventChecklistForm(forms.ModelForm):
for key in vehicles: for key in vehicles:
pk = int(key.split('_')[1]) pk = int(key.split('_')[1])
driver_key = 'driver_' + str(pk) driver_key = 'driver_' + str(pk)
if(self.data[driver_key] == ''): if (self.data[driver_key] == ''):
raise forms.ValidationError('Add a driver to vehicle ' + str(pk), code='vehicle_mismatch') raise forms.ValidationError('Add a driver to vehicle ' + str(pk), code='vehicle_mismatch')
else: else:
try: try:

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

@@ -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)

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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -11,6 +11,7 @@
<initialize> <initialize>
<color id="LightGray" RGB="#D3D3D3"/> <color id="LightGray" RGB="#D3D3D3"/>
<color id="DarkGray" RGB="#707070"/> <color id="DarkGray" RGB="#707070"/>
<color id="Brand" RGB="#3853a4"/>
</initialize> </initialize>
<paraStyle name="style.para" fontName="OpenSans" /> <paraStyle name="style.para" fontName="OpenSans" />
@@ -27,6 +28,8 @@
<paraStyle name="style.times" fontName="OpenSans" fontSize="10" /> <paraStyle name="style.times" fontName="OpenSans" fontSize="10" />
<paraStyle name="style.head_titles" fontName="OpenSans-Bold" fontSize="10" /> <paraStyle name="style.head_titles" fontName="OpenSans-Bold" fontSize="10" />
<paraStyle name="style.head_numbers" fontName="OpenSans" fontSize="10" /> <paraStyle name="style.head_numbers" fontName="OpenSans" fontSize="10" />
<paraStyle name="style.emheader" fontName="OpenSans" textColor="White" fontSize="12" backColor="Brand" leading="20"/>
<paraStyle name="style.breakbefore" parent="emheader" pageBreakBefore="1"/>
<blockTableStyle id="eventSpecifics"> <blockTableStyle id="eventSpecifics">
<blockValign value="top"/> <blockValign value="top"/>
@@ -84,7 +87,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

@@ -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

@@ -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

@@ -118,9 +118,9 @@ def orderby(request, field, attr):
@register.filter(needs_autoescape=True) # Used for accessing outside of a form, i.e. in detail views of RiskAssessment and EventChecklist @register.filter(needs_autoescape=True) # Used for accessing outside of a form, i.e. in detail views of RiskAssessment and EventChecklist
def get_field(obj, field, autoescape=True): def get_field(obj, field, autoescape=True):
value = getattr(obj, field) value = getattr(obj, field)
if(isinstance(value, bool)): if (isinstance(value, bool)):
value = yesnoi(value, field in obj.inverted_fields) value = yesnoi(value, field in obj.inverted_fields)
elif(isinstance(value, str)): elif (isinstance(value, str)):
value = truncatewords(value, 20) value = truncatewords(value, 20)
return mark_safe(value) return mark_safe(value)
@@ -144,7 +144,7 @@ def get_list(dictionary, key):
@register.filter @register.filter
def profile_by_index(value): def profile_by_index(value):
if(value): if (value):
return models.Profile.objects.get(pk=int(value)) return models.Profile.objects.get(pk=int(value))
else: else:
return "" return ""
@@ -196,8 +196,8 @@ def button(type, url=None, pk=None, clazz="", icon=None, text="", id=None, style
text = "Edit" text = "Edit"
elif type == 'print': elif type == 'print':
clazz += " btn-primary " clazz += " btn-primary "
icon = "fa-print" icon = "fa-download"
text = "Print" text = "Export"
elif type == 'duplicate': elif type == 'duplicate':
clazz += " btn-info " clazz += " btn-info "
icon = "fa-copy" icon = "fa-copy"
@@ -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

@@ -75,7 +75,7 @@ urlpatterns = [
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 +87,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'),

View File

@@ -342,12 +342,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 +357,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):

View File

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

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

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

View File

@@ -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

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

View File

@@ -28,13 +28,13 @@ def cable_type(db):
@pytest.fixture @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

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

View File

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

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

7105
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,14 +27,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.11"
}, },
"scripts": { "scripts": {
"gulp": "gulp", "gulp": "gulp",

View File

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

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

@@ -143,6 +143,15 @@ class Command(BaseCommand):
"Bin Diving", "Bin Diving",
"Wiki Editing"] "Wiki Editing"]
descriptions = [
"Physical training concentrates on mechanistic goals: training programs in this area develop specific motor skills, agility, strength or physical fitness, often with an intention of peaking at a particular time.",
"In military use, training means gaining the physical ability to perform and survive in combat, and learn the many skills needed in a time of war.",
"These include how to use a variety of weapons, outdoor survival skills, and how to survive being captured by the enemy, among many others. See military education and training.",
"While some studies have indicated relaxation training is useful for some medical conditions, autogenic training has limited results or has been the result of few studies.",
"Some occupations are inherently hazardous, and require a minimum level of competence before the practitioners can perform the work at an acceptable level of safety to themselves or others in the vicinity.",
"Occupational diving, rescue, firefighting and operation of certain types of machinery and vehicles may require assessment and certification of a minimum acceptable competence before the person is allowed to practice as a licensed instructor."
]
for i, name in enumerate(names): for i, name in enumerate(names):
category = random.choice(self.categories) category = random.choice(self.categories)
previous_item = models.TrainingItem.objects.filter(category=category).last() previous_item = models.TrainingItem.objects.filter(category=category).last()
@@ -150,7 +159,7 @@ class Command(BaseCommand):
number = previous_item.reference_number + 1 number = previous_item.reference_number + 1
else: else:
number = 0 number = 0
item = models.TrainingItem.objects.create(category=category, reference_number=number, description=name) item = models.TrainingItem.objects.create(category=category, reference_number=number, name=name, description=random.choice(descriptions) + random.choice(descriptions) + random.choice(descriptions))
self.items.append(item) self.items.append(item)
def setup_levels(self): def setup_levels(self):

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-02-19 14:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('training', '0005_auto_20220223_1535'),
]
operations = [
migrations.RenameField(
model_name='trainingitem',
old_name='description',
new_name='name',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-02-19 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('training', '0006_rename_description_trainingitem_name'),
]
operations = [
migrations.AddField(
model_name='trainingitem',
name='description',
field=models.TextField(blank=True),
),
]

View File

@@ -85,7 +85,7 @@ class TrainingItemManager(QueryablePropertiesManager):
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(description__icontains=query) | Q(display_id=query)) or_lookup = (Q(name__icontains=query) | Q(description__icontains=query) | Q(display_id=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
@@ -94,16 +94,13 @@ class TrainingItemManager(QueryablePropertiesManager):
class TrainingItem(models.Model): class TrainingItem(models.Model):
reference_number = models.IntegerField() reference_number = models.IntegerField()
category = models.ForeignKey('TrainingCategory', related_name='items', on_delete=models.CASCADE) category = models.ForeignKey('TrainingCategory', related_name='items', on_delete=models.CASCADE)
description = models.CharField(max_length=50) name = models.CharField(max_length=50)
description = models.TextField(blank=True)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
prerequisites = models.ManyToManyField('self', symmetrical=False, blank=True) prerequisites = models.ManyToManyField('self', symmetrical=False, blank=True)
objects = TrainingItemManager() objects = TrainingItemManager()
@property
def name(self):
return str(self)
@queryable_property @queryable_property
def display_id(self): def display_id(self):
return f"{self.category.reference_number}.{self.reference_number}" return f"{self.category.reference_number}.{self.reference_number}"
@@ -121,7 +118,7 @@ class TrainingItem(models.Model):
return models.Q() return models.Q()
def __str__(self): def __str__(self):
name = f"{self.display_id} {self.description}" name = f"{self.display_id} {self.name}"
if not self.active: if not self.active:
name += " (inactive)" name += " (inactive)"
return name return name
@@ -149,7 +146,7 @@ class TrainingItemQualificationManager(QueryablePropertiesManager):
def search(self, query=None): def search(self, query=None):
qs = self.get_queryset().select_related('item', 'supervisor', 'item__category') qs = self.get_queryset().select_related('item', 'supervisor', 'item__category')
if query is not None: if query is not None:
or_lookup = (Q(item__description__icontains=query) | Q(supervisor__first_name__icontains=query) | Q(supervisor__last_name__icontains=query) | Q(item__category__name__icontains=query) | Q(item__display_id=query)) or_lookup = (Q(item__name__icontains=query) | Q(supervisor__first_name__icontains=query) | Q(supervisor__last_name__icontains=query) | Q(item__category__name__icontains=query) | Q(item__display_id=query))
try: try:
or_lookup = Q(item__category__reference_number=int(query)) | or_lookup or_lookup = Q(item__category__reference_number=int(query)) | or_lookup
@@ -201,7 +198,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

@@ -1,6 +1,11 @@
{% extends 'base_training.html' %} {% extends 'base_training.html' %}
{% load button from filters %}
{% block content %} {% block content %}
<div class="col-12 text-right py-2 pr-0">
{% button 'print' 'item_list_export' %}
</div>
<div id="accordion"> <div id="accordion">
{% for category in categories %} {% for category in categories %}
<div class="card"> <div class="card">
@@ -13,7 +18,8 @@
<div class="card-body"> <div class="card-body">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for item in category.items.all %} {% for item in category.items.all %}
<li class="list-group-item {% if not item.active%}text-warning{%endif%}">{{ item }} <li class="list-group-item {% if not item.active%}text-warning{%endif%}">{{ item }} <a href="{% url 'item_qualification' item.pk %}" class="btn btn-info float-right"><span class="fas fa-user"></span> Qualified Users</a>
<br><small>{{ item.description }}</small>
{% if item.prerequisites.exists %} {% if item.prerequisites.exists %}
<div class="ml-3 font-italic"> <div class="ml-3 font-italic">
<p class="text-info mb-0">Passed Out Prerequisites:</p> <p class="text-info mb-0">Passed Out Prerequisites:</p>
@@ -24,7 +30,6 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
<a href="{% url 'item_qualification' item.pk %}" class="btn btn-info"><span class="fas fa-user"></span> Qualified Users</a>
</li> </li>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -0,0 +1,25 @@
{% extends 'base_print.xml' %}
{% block content %}
<h1 style="page-head">TEC Training Item List</h1>
<spacer length="15" />
{% for category in categories %}
<h2 {% if not forloop.first %}style="breakbefore"{%else%}style="emheader"{%endif%}>{{category}}</h2>
<spacer length="10" />
{% for item in category.items.all %}
<h3>{{ item }}</h3>
<spacer length="4" />
<para>{{ item.description }}</para>
{% if item.prerequisites.exists %}
<h4>Prerequisites:</h4>
<ul bulletFontSize="5">
{% for p in item.prerequisites.all %}
<li><para>{{p}}</para></li>
{% endfor %}
</ul>
{% endif %}
<spacer length="8" />
{% endfor %}
{% endfor %}
{% endblock %}

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,33 +1,33 @@
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
urlpatterns = [ urlpatterns = [
path('items/', login_required(views.ItemList.as_view()), name='item_list'), path('items/', login_required(views.ItemList.as_view()), name='item_list'),
path('items/export/', login_required(views.ItemListExport.as_view()), name='item_list_export'),
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

@@ -7,7 +7,7 @@ from django.db import transaction
from django.db.models import Q, Count from django.db.models import Q, Count
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from PyRIGS.views import is_ajax, ModalURLMixin, get_related from PyRIGS.views import is_ajax, ModalURLMixin, get_related, PrintListView
from training import models, forms from training import models, forms
from users import views from users import views
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
@@ -24,6 +24,17 @@ class ItemList(generic.ListView):
return context return context
class ItemListExport(PrintListView):
model = models.TrainingItem
template_name = 'item_list.xml'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filename'] = f"TrainingItemList.pdf"
context["categories"] = models.TrainingCategory.objects.all()
return context
class TraineeDetail(views.ProfileDetail): class TraineeDetail(views.ProfileDetail):
template_name = "trainee_detail.html" template_name = "trainee_detail.html"
model = models.Trainee model = models.Trainee

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()

View File

@@ -183,7 +183,7 @@ class ModelComparison:
def name(self): def name(self):
obj = self.new if self.new else self.old obj = self.new if self.new else self.old
if(hasattr(obj, 'activity_feed_string')): if (hasattr(obj, 'activity_feed_string')):
return obj.activity_feed_string return obj.activity_feed_string
else: else:
return str(obj) return str(obj)