mirror of
https://github.com/nottinghamtec/PyRIGS.git
synced 2026-02-09 00:09:44 +00:00
Compare commits
56 Commits
948a41f43a
...
subhire
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f240e581 | |||
| 2e4b84c94e | |||
| 8863d86ed0 | |||
|
|
6550ed2318 | ||
|
|
c9759a6339 | ||
|
|
b637c4e452 | ||
|
|
8986b94b07 | ||
| 87f2de46a1 | |||
| 1615e27767 | |||
| 773f55ac84 | |||
| 63a2f6d47b | |||
| 8393e85b74 | |||
| 311c02d554 | |||
| e100f5a1d4 | |||
| eb07990f4c | |||
| 7b7c1b86de | |||
| 2b8945c513 | |||
| eb3638b93a | |||
| 86c033ba97 | |||
| 52fd662340 | |||
| 9818ed995f | |||
| 5178614d71 | |||
|
1660f51e55
|
|||
| a7bf990666 | |||
|
9feea56211
|
|||
|
951227e68b
|
|||
|
a4a28a6130
|
|||
|
e3d8cf8978
|
|||
|
6e8779c81b
|
|||
|
e0da6a3120
|
|||
|
0c80ef1b72
|
|||
|
0f127d8ca4
|
|||
|
626779ef25
|
|||
| fa1dc31639 | |||
| d69543e309 | |||
|
04ec728972
|
|||
|
|
a24e6d4495 | ||
|
|
fa5792914a | ||
|
bede8b4176
|
|||
| 0117091f3e | |||
|
8cade512d1
|
|||
|
418219940b
|
|||
|
37101d3340
|
|||
| de4bed92a4 | |||
|
|
3767923175 | ||
|
|
1d77cf95d3 | ||
|
|
1f21d0b265 | ||
| 7846a6d31e | |||
| d28b73a0b8 | |||
|
|
5c2e8b391c | ||
|
|
548bc1df81 | ||
|
|
c1d2bce8fb | ||
|
c71beab278
|
|||
|
259932a548
|
|||
|
7526485837
|
|||
| 39ed5aefb4 |
151
.github/workflows/combine-prs.yml
vendored
Normal file
151
.github/workflows/combine-prs.yml
vendored
Normal 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
|
||||
});
|
||||
24
.github/workflows/django.yml
vendored
24
.github/workflows/django.yml
vendored
@@ -13,26 +13,20 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9.1
|
||||
- uses: actions/cache@v2
|
||||
id: pcache
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs
|
||||
key: ${{ runner.os }}-pipenv-${{ hashFiles('Pipfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pipenv-
|
||||
python-version: 3.9
|
||||
cache: 'pipenv'
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip pipenv
|
||||
python3 -m pip install --upgrade pip pipenv
|
||||
pipenv install -d
|
||||
# if: steps.pcache.outputs.cache-hit != 'true'
|
||||
- name: Cache Static Files
|
||||
id: static-cache
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: 'pipeline/built_assets'
|
||||
key: ${{ hashFiles('package-lock.json') }}-${{ hashFiles('pipeline/source_assets') }}
|
||||
@@ -43,9 +37,9 @@ jobs:
|
||||
- name: Basic Checks
|
||||
run: |
|
||||
pipenv run pycodestyle . --exclude=migrations,node_modules
|
||||
pipenv run python manage.py check
|
||||
pipenv run python manage.py makemigrations --check --dry-run
|
||||
pipenv run python manage.py collectstatic --noinput
|
||||
pipenv run python3 manage.py check
|
||||
pipenv run python3 manage.py makemigrations --check --dry-run
|
||||
pipenv run python3 manage.py collectstatic --noinput
|
||||
- name: Run Tests
|
||||
run: pipenv run pytest -n auto -vv --cov
|
||||
- uses: actions/upload-artifact@v2
|
||||
|
||||
18
Pipfile
18
Pipfile
@@ -11,7 +11,6 @@ asgiref = "~=3.3.1"
|
||||
beautifulsoup4 = "~=4.9.3"
|
||||
Brotli = "~=1.0.9"
|
||||
cachetools = "~=4.2.1"
|
||||
certifi = "*"
|
||||
chardet = "~=4.0.0"
|
||||
configparser = "~=5.0.1"
|
||||
contextlib2 = "~=0.6.0.post1"
|
||||
@@ -22,22 +21,19 @@ dj-static = "~=0.0.6"
|
||||
Django = "~=3.2"
|
||||
django-debug-toolbar = "~=3.2"
|
||||
django-filter = "~=2.4.0"
|
||||
django-ical = "~=1.7.1"
|
||||
django-recurrence = "~=1.10.3"
|
||||
django-ical = "~=1.8.3"
|
||||
django-registration-redux = "~=2.9"
|
||||
django-reversion = "~=3.0.9"
|
||||
django-toolbelt = "~=0.0.1"
|
||||
django-widget-tweaks = "~=1.4.8"
|
||||
django-htmlmin = "~=0.11.0"
|
||||
envparse = "~=0.2.0"
|
||||
envparse = "*"
|
||||
gunicorn = "~=20.0.4"
|
||||
icalendar = "~=4.0.7"
|
||||
idna = "~=2.10"
|
||||
lxml = "*"
|
||||
Markdown = "~=3.3.3"
|
||||
msgpack = "~=1.0.2"
|
||||
pep517 = "~=0.9.1"
|
||||
Pillow = "~=9.0.0"
|
||||
Pillow = "~=9.3.0"
|
||||
premailer = "~=3.7.0"
|
||||
progress = "~=1.5"
|
||||
psutil = "~=5.8.0"
|
||||
@@ -45,6 +41,7 @@ psycopg2 = "~=2.8.6"
|
||||
Pygments = "~=2.7.4"
|
||||
pyparsing = "~=2.4.7"
|
||||
PyPDF2 = "~=1.27.5"
|
||||
PyPOM = "~=2.2.4"
|
||||
python-dateutil = "~=2.8.1"
|
||||
pytoml = "~=0.1.21"
|
||||
pytz = "~=2020.5"
|
||||
@@ -78,13 +75,12 @@ django-hCaptcha = "*"
|
||||
importlib-metadata = "*"
|
||||
django-hcaptcha = "*"
|
||||
"z3c.rml" = "*"
|
||||
pikepdf = "*"
|
||||
django-queryable-properties = "*"
|
||||
django-mass-edit = "*"
|
||||
selenium = "~=3.141.0"
|
||||
|
||||
[dev-packages]
|
||||
selenium = "~=3.141.0"
|
||||
pycodestyle = "*"
|
||||
pycodestyle = "~=2.9.1"
|
||||
coveralls = "*"
|
||||
django-coverage-plugin = "*"
|
||||
pytest-cov = "*"
|
||||
@@ -97,7 +93,7 @@ pytest-xdist = {extras = [ "psutil",], version = "*"}
|
||||
PyPOM = {extras = [ "splinter",], version = "*"}
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
||||
python_version = "3.10"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
|
||||
951
Pipfile.lock
generated
951
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ STAGING = env('STAGING', cast=bool, default=False)
|
||||
CI = env('CI', cast=bool, default=False)
|
||||
|
||||
ALLOWED_HOSTS = ['pyrigs.nottinghamtec.co.uk', 'rigs.nottinghamtec.co.uk', 'pyrigs.herokuapp.com']
|
||||
CSRF_TRUSTED_ORIGINS = []
|
||||
|
||||
if STAGING:
|
||||
ALLOWED_HOSTS.append('.herokuapp.com')
|
||||
@@ -35,6 +36,7 @@ if DEBUG:
|
||||
ALLOWED_HOSTS.append('localhost')
|
||||
ALLOWED_HOSTS.append('example.com')
|
||||
ALLOWED_HOSTS.append('127.0.0.1')
|
||||
CSRF_TRUSTED_ORIGINS.append('.preview.app.github.dev')
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
if not DEBUG:
|
||||
@@ -42,8 +44,9 @@ if not DEBUG:
|
||||
|
||||
INTERNAL_IPS = ['127.0.0.1']
|
||||
|
||||
ADMINS = [('Tom Price', 'tomtom5152@gmail.com'), ('IT Manager', 'it@nottinghamtec.co.uk'),
|
||||
('Arona Jones', 'arona.jones@nottinghamtec.co.uk')]
|
||||
DOMAIN = env('DOMAIN', default='example.com')
|
||||
|
||||
ADMINS = [('IT Manager', f'it@{DOMAIN}'), ('Arona Jones', f'arona.jones@{DOMAIN}')]
|
||||
if DEBUG:
|
||||
ADMINS.append(('Testing Superuser', 'superuser@example.com'))
|
||||
|
||||
|
||||
@@ -321,45 +321,60 @@ class OEmbedView(generic.View):
|
||||
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):
|
||||
append_terms = False
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
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)
|
||||
|
||||
context = {
|
||||
'object': obj,
|
||||
'current_user': self.request.user,
|
||||
'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) + f"- {obj.current_version_id}]",
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
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)
|
||||
buffer = rml2pdf.parseString(rml)
|
||||
merger.append(PdfFileReader(buffer))
|
||||
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
|
||||
def get(self, request):
|
||||
self.object_list = self.get_queryset()
|
||||
return render_pdf_response(get_template(self.template_name), self.get_context_data(), False)
|
||||
|
||||
@@ -233,7 +233,7 @@ class EventChecklistForm(forms.ModelForm):
|
||||
for key in vehicles:
|
||||
pk = int(key.split('_')[1])
|
||||
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')
|
||||
else:
|
||||
try:
|
||||
|
||||
38
RIGS/management/commands/send_reminders.py
Normal file
38
RIGS/management/commands/send_reminders.py
Normal 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()
|
||||
18
RIGS/migrations/0045_alter_profile_is_approved.py
Normal file
18
RIGS/migrations/0045_alter_profile_is_approved.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 3.2.12 on 2022-10-15 19:36
|
||||
# Generated by Django 3.2.16 on 2022-12-16 14:41
|
||||
|
||||
import RIGS.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import versioning.versioning
|
||||
@@ -8,7 +9,7 @@ import versioning.versioning
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('RIGS', '0044_profile_is_supervisor'),
|
||||
('RIGS', '0045_alter_profile_is_approved'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -25,11 +26,13 @@ class Migration(migrations.Migration):
|
||||
('end_time', models.TimeField(blank=True, null=True)),
|
||||
('purchase_order', models.CharField(blank=True, default='', max_length=255, verbose_name='PO')),
|
||||
('insurance_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('quote', models.URLField(default='', validators=[RIGS.validators.validate_url])),
|
||||
('events', models.ManyToManyField(to='RIGS.Event')),
|
||||
('organisation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='RIGS.organisation')),
|
||||
('person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='RIGS.person')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'permissions': [('subhire_finance', 'Can see financial data for subhire - insurance values')],
|
||||
},
|
||||
bases=(models.Model, versioning.versioning.RevisionMixin),
|
||||
),
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.16 on 2022-10-20 11:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('RIGS', '0045_subhire'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subhire',
|
||||
name='events',
|
||||
field=models.ManyToManyField(to='RIGS.Event'),
|
||||
),
|
||||
]
|
||||
949
RIGS/models.py
949
RIGS/models.py
@@ -1,949 +0,0 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import Q, F
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from reversion import revisions as reversion
|
||||
from reversion.models import Version
|
||||
from versioning.versioning import RevisionMixin
|
||||
|
||||
|
||||
def filter_by_pk(filt, query):
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(query)
|
||||
filt = filt | Q(pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
return filt
|
||||
|
||||
|
||||
class Profile(AbstractUser):
|
||||
initials = models.CharField(max_length=5, null=True, blank=False)
|
||||
phone = models.CharField(max_length=13, blank=True, default='')
|
||||
api_key = models.CharField(max_length=40, blank=True, editable=False, default='')
|
||||
is_approved = models.BooleanField(default=False)
|
||||
# Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that...
|
||||
last_emailed = models.DateTimeField(blank=True, null=True)
|
||||
dark_theme = models.BooleanField(default=False)
|
||||
is_supervisor = models.BooleanField(default=False)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@classmethod
|
||||
def make_api_key(cls):
|
||||
size = 20
|
||||
chars = string.ascii_letters + string.digits
|
||||
new_api_key = ''.join(random.choice(chars) for x in range(size))
|
||||
return new_api_key
|
||||
|
||||
@property
|
||||
def profile_picture(self):
|
||||
url = ""
|
||||
if settings.USE_GRAVATAR or settings.USE_GRAVATAR is None:
|
||||
url = "https://www.gravatar.com/avatar/" + hashlib.md5(
|
||||
self.email.encode('utf-8')).hexdigest() + "?d=wavatar&s=500"
|
||||
return url
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
name = self.get_full_name()
|
||||
if self.initials:
|
||||
name += f' "{self.initials}"'
|
||||
return name
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic', 'riskassessment', 'invoice').prefetch_related('checklists')
|
||||
|
||||
@classmethod
|
||||
def admins(cls):
|
||||
return Profile.objects.filter(email__in=[y for x in settings.ADMINS for y in x])
|
||||
|
||||
@classmethod
|
||||
def users_awaiting_approval_count(cls):
|
||||
return Profile.objects.filter(models.Q(is_approved=False)).count()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ContactableManager(models.Manager):
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(name__icontains=query) | Q(email__icontains=query) | Q(address__icontains=query) | Q(notes__icontains=query) | Q(
|
||||
phone__startswith=query) | Q(phone__endswith=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
class Person(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
notes = models.TextField(blank=True, default='')
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes is not None:
|
||||
if len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def organisations(self):
|
||||
o = []
|
||||
for e in Event.objects.filter(person=self).select_related('organisation'):
|
||||
if e.organisation:
|
||||
o.append(e.organisation)
|
||||
|
||||
# Count up occurances and put them in descending order
|
||||
c = Counter(o)
|
||||
stats = c.most_common()
|
||||
return stats
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('person_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class Organisation(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
notes = models.TextField(blank=True, default='')
|
||||
union_account = models.BooleanField(default=False)
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes is not None:
|
||||
if len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def persons(self):
|
||||
p = []
|
||||
for e in Event.objects.filter(organisation=self).select_related('person'):
|
||||
if e.person:
|
||||
p.append(e.person)
|
||||
|
||||
# Count up occurances and put them in descending order
|
||||
c = Counter(p)
|
||||
stats = c.most_common()
|
||||
return stats
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('organisation_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class VatManager(models.Manager):
|
||||
def current_rate(self):
|
||||
return self.find_rate(timezone.now())
|
||||
|
||||
def find_rate(self, date):
|
||||
try:
|
||||
return self.filter(start_at__lte=date).latest()
|
||||
except VatRate.DoesNotExist:
|
||||
r = VatRate
|
||||
r.rate = 0
|
||||
return r
|
||||
|
||||
|
||||
@reversion.register
|
||||
class VatRate(models.Model, RevisionMixin):
|
||||
start_at = models.DateField()
|
||||
rate = models.DecimalField(max_digits=6, decimal_places=6)
|
||||
comment = models.CharField(max_length=255)
|
||||
|
||||
objects = VatManager()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@property
|
||||
def as_percent(self):
|
||||
return self.rate * 100
|
||||
|
||||
class Meta:
|
||||
ordering = ['-start_at']
|
||||
get_latest_by = 'start_at'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.comment} {self.start_at} @ {self.as_percent}%"
|
||||
|
||||
|
||||
class Venue(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
three_phase_available = models.BooleanField(default=False)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes and len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('venue_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class EventManager(models.Manager):
|
||||
def current_events(self):
|
||||
events = self.filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now().date(), dry_hire=False) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Ends after
|
||||
(models.Q(dry_hire=True, start_date__gte=timezone.now()) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Active dry hire
|
||||
(models.Q(dry_hire=True, checked_in_by__isnull=True) & (
|
||||
models.Q(status=Event.BOOKED) | models.Q(status=Event.CONFIRMED))) | # Active dry hire GT
|
||||
models.Q(status=Event.CANCELLED, start_date__gte=timezone.now()) # Canceled but not started
|
||||
).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
return events
|
||||
|
||||
def events_in_bounds(self, start, end):
|
||||
events = self.filter(
|
||||
(models.Q(start_date__gte=start.date(), start_date__lte=end.date())) | # Start date in bounds
|
||||
(models.Q(end_date__gte=start.date(), end_date__lte=end.date())) | # End date in bounds
|
||||
(models.Q(access_at__gte=start, access_at__lte=end)) | # Access at in bounds
|
||||
(models.Q(meet_at__gte=start, meet_at__lte=end)) | # Meet at in bounds
|
||||
|
||||
(models.Q(start_date__lte=start, end_date__gte=end)) | # Start before, end after
|
||||
(models.Q(access_at__lte=start, start_date__gte=end)) | # Access before, start after
|
||||
(models.Q(access_at__lte=start, end_date__gte=end)) | # Access before, end after
|
||||
(models.Q(meet_at__lte=start, start_date__gte=end)) | # Meet before, start after
|
||||
(models.Q(meet_at__lte=start, end_date__gte=end)) # Meet before, end after
|
||||
|
||||
).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person',
|
||||
'organisation',
|
||||
'venue', 'mic')
|
||||
return events
|
||||
|
||||
def rig_count(self):
|
||||
event_count = self.filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False,
|
||||
is_rig=True) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now(), dry_hire=False, is_rig=True) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Ends after
|
||||
(models.Q(dry_hire=True, start_date__gte=timezone.now(), is_rig=True) & ~models.Q(
|
||||
status=Event.CANCELLED)) # Active dry hire
|
||||
).count()
|
||||
return event_count
|
||||
|
||||
def waiting_invoices(self):
|
||||
events = self.filter(
|
||||
(
|
||||
models.Q(start_date__lte=datetime.date.today(), end_date__isnull=True) | # Starts before with no end
|
||||
models.Q(end_date__lte=datetime.date.today()) # Or has end date, finishes before
|
||||
) & models.Q(invoice__isnull=True) & # Has not already been invoiced
|
||||
models.Q(is_rig=True) # Is a rig (not non-rig)
|
||||
).order_by('start_date') \
|
||||
.select_related('person', 'organisation', 'venue', 'mic') \
|
||||
.prefetch_related('items')
|
||||
|
||||
return events
|
||||
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(name__icontains=query) | Q(description__icontains=query) | Q(notes__icontains=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
try:
|
||||
if query[0] == "N":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(pk=val) # If string is N###### then do a simple PK filter
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
def find_earliest_event_time(event, datetime_list):
|
||||
# If there is no start time defined, pretend it's midnight
|
||||
startTimeFaked = False
|
||||
if event.has_start_time:
|
||||
startDateTime = datetime.datetime.combine(event.start_date, event.start_time)
|
||||
else:
|
||||
startDateTime = datetime.datetime.combine(event.start_date, datetime.time(00, 00))
|
||||
startTimeFaked = True
|
||||
|
||||
# timezoneIssues - apply the default timezone to the naiive datetime
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
startDateTime = tz.localize(startDateTime)
|
||||
datetime_list.append(startDateTime) # then add it to the list
|
||||
|
||||
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
|
||||
|
||||
# if we faked it & it's the earliest, better own up
|
||||
if startTimeFaked and earliest == startDateTime:
|
||||
return event.start_date
|
||||
return earliest
|
||||
|
||||
|
||||
class BaseEvent(models.Model, RevisionMixin):
|
||||
# Done to make it much nicer on the database
|
||||
PROVISIONAL = 0
|
||||
CONFIRMED = 1
|
||||
BOOKED = 2
|
||||
CANCELLED = 3
|
||||
EVENT_STATUS_CHOICES = (
|
||||
(PROVISIONAL, 'Provisional'),
|
||||
(CONFIRMED, 'Confirmed'),
|
||||
(BOOKED, 'Booked'),
|
||||
(CANCELLED, 'Cancelled'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE)
|
||||
organisation = models.ForeignKey('Organisation', blank=True, null=True, on_delete=models.CASCADE)
|
||||
description = models.TextField(blank=True, default='')
|
||||
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL)
|
||||
|
||||
# Timing
|
||||
start_date = models.DateField()
|
||||
start_time = models.TimeField(blank=True, null=True)
|
||||
end_date = models.DateField(blank=True, null=True)
|
||||
end_time = models.TimeField(blank=True, null=True)
|
||||
|
||||
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def cancelled(self):
|
||||
return (self.status == self.CANCELLED)
|
||||
|
||||
@property
|
||||
def confirmed(self):
|
||||
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
|
||||
|
||||
@property
|
||||
def has_start_time(self):
|
||||
return self.start_time is not None
|
||||
|
||||
@property
|
||||
def has_end_time(self):
|
||||
return self.end_time is not None
|
||||
|
||||
@property
|
||||
def latest_time(self):
|
||||
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
endDate = self.end_date
|
||||
if endDate is None:
|
||||
endDate = self.start_date
|
||||
|
||||
if self.has_end_time:
|
||||
endDateTime = datetime.datetime.combine(endDate, self.end_time)
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
endDateTime = tz.localize(endDateTime)
|
||||
|
||||
return endDateTime
|
||||
|
||||
else:
|
||||
return endDate
|
||||
|
||||
def clean(self):
|
||||
errdict = {}
|
||||
if self.end_date and self.start_date > self.end_date:
|
||||
errdict['end_date'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
|
||||
|
||||
startEndSameDay = not self.end_date or self.end_date == self.start_date
|
||||
hasStartAndEnd = self.has_start_time and self.has_end_time
|
||||
if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time:
|
||||
errdict['end_time'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
|
||||
return errdict
|
||||
|
||||
|
||||
@reversion.register(follow=['items'])
|
||||
class Event(BaseEvent):
|
||||
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
|
||||
verbose_name="MIC", on_delete=models.CASCADE)
|
||||
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
dry_hire = models.BooleanField(default=False)
|
||||
is_rig = models.BooleanField(default=True)
|
||||
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
|
||||
null=True)
|
||||
|
||||
access_at = models.DateTimeField(blank=True, null=True)
|
||||
meet_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
# Dry-hire only
|
||||
checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True,
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
# Monies
|
||||
collector = models.CharField(max_length=255, blank=True, default='', verbose_name='collected by')
|
||||
|
||||
# Authorisation request details
|
||||
auth_request_by = models.ForeignKey('Profile', null=True, blank=True, on_delete=models.CASCADE)
|
||||
auth_request_at = models.DateTimeField(null=True, blank=True)
|
||||
auth_request_to = models.EmailField(blank=True, default='')
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
if self.pk:
|
||||
if self.is_rig:
|
||||
return f"N{self.pk:05d}"
|
||||
return self.pk
|
||||
return "????"
|
||||
|
||||
# Calculated values
|
||||
"""
|
||||
EX Vat
|
||||
"""
|
||||
|
||||
@property
|
||||
def sum_total(self):
|
||||
total = self.items.aggregate(
|
||||
sum_total=models.Sum(models.F('cost') * models.F('quantity'),
|
||||
output_field=models.DecimalField(max_digits=10, decimal_places=2))
|
||||
)['sum_total']
|
||||
if total:
|
||||
return total
|
||||
return Decimal("0.00")
|
||||
|
||||
@cached_property
|
||||
def vat_rate(self):
|
||||
return VatRate.objects.find_rate(self.start_date)
|
||||
|
||||
@property
|
||||
def vat(self):
|
||||
# No VAT is owed on internal transfers
|
||||
if self.internal:
|
||||
return 0
|
||||
return Decimal(self.sum_total * self.vat_rate.rate).quantize(Decimal('.01'))
|
||||
|
||||
"""
|
||||
Inc VAT
|
||||
"""
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return Decimal(self.sum_total + self.vat).quantize(Decimal('.01'))
|
||||
|
||||
@property
|
||||
def hs_done(self):
|
||||
return self.riskassessment is not None and len(self.checklists.all()) > 0
|
||||
|
||||
@property
|
||||
def earliest_time(self):
|
||||
"""Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
|
||||
|
||||
# Put all the datetimes in a list
|
||||
datetime_list = []
|
||||
|
||||
if self.access_at:
|
||||
datetime_list.append(self.access_at)
|
||||
|
||||
if self.meet_at:
|
||||
datetime_list.append(self.meet_at)
|
||||
|
||||
earliest = find_earliest_event_time(self, datetime_list)
|
||||
|
||||
return earliest
|
||||
|
||||
@property
|
||||
def internal(self):
|
||||
return bool(self.organisation and self.organisation.union_account)
|
||||
|
||||
@property
|
||||
def authorised(self):
|
||||
if self.internal:
|
||||
return self.authorisation.amount == self.total
|
||||
else:
|
||||
return bool(self.purchase_order)
|
||||
|
||||
objects = EventManager()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('event_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_id}: {self.name}"
|
||||
|
||||
def clean(self):
|
||||
errdict = super.clean()
|
||||
|
||||
if self.access_at is not None:
|
||||
if self.access_at.date() > self.start_date:
|
||||
errdict['access_at'] = ['Regardless of what some clients might think, access time cannot be after the event has started.']
|
||||
elif self.start_time is not None and self.start_date == self.access_at.date() and self.access_at.time() > self.start_time:
|
||||
errdict['access_at'] = ['Regardless of what some clients might think, access time cannot be after the event has started.']
|
||||
|
||||
if errdict != {}: # If there was an error when validation
|
||||
raise ValidationError(errdict)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call :meth:`full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super(Event, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventItem(models.Model, RevisionMixin):
|
||||
event = models.ForeignKey('Event', related_name='items', blank=True, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default='')
|
||||
quantity = models.IntegerField()
|
||||
cost = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
order = models.IntegerField()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
return self.cost * self.quantity
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_id}.{self.order}: {self.event.name} | {self.name}"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"item {self.name}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventAuthorisation(models.Model, RevisionMixin):
|
||||
event = models.OneToOneField('Event', related_name='authorisation', on_delete=models.CASCADE)
|
||||
email = models.EmailField()
|
||||
name = models.CharField(max_length=255)
|
||||
uni_id = models.CharField(max_length=10, blank=True, default='', verbose_name="University ID")
|
||||
account_code = models.CharField(max_length=50, default='', blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount")
|
||||
sent_by = models.ForeignKey('Profile', on_delete=models.CASCADE)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('event_detail', kwargs={'pk': self.event_id})
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"{self.event.display_id} (requested by {self.sent_by.initials})"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Subhire(BaseEvent):
|
||||
insurance_value = models.DecimalField(max_digits=10, decimal_places=2) # TODO Validate if this is over notifiable threshold
|
||||
events = models.ManyToManyField(Event)
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
return f"S{self.pk:05d}"
|
||||
|
||||
|
||||
class InvoiceManager(models.Manager):
|
||||
def outstanding_invoices(self):
|
||||
# Manual query is the only way I have found to do this efficiently. Not ideal but needs must
|
||||
sql = "SELECT * FROM " \
|
||||
"(SELECT " \
|
||||
"(SELECT COUNT(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payment_count\", " \
|
||||
"(SELECT SUM(ei.cost * ei.quantity) FROM \"RIGS_eventitem\" AS ei WHERE ei.event_id=\"RIGS_invoice\".event_id) AS \"cost\", " \
|
||||
"(SELECT SUM(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payments\", " \
|
||||
"\"RIGS_invoice\".\"id\", \"RIGS_invoice\".\"event_id\", \"RIGS_invoice\".\"invoice_date\", \"RIGS_invoice\".\"void\" FROM \"RIGS_invoice\") " \
|
||||
"AS sub " \
|
||||
"WHERE (((cost > 0.0) AND (payment_count=0)) OR (cost - payments) <> 0.0) AND void = '0'" \
|
||||
"ORDER BY invoice_date"
|
||||
|
||||
query = self.raw(sql)
|
||||
return query
|
||||
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(event__name__icontains=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(query)
|
||||
or_lookup = or_lookup | Q(event__pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
try:
|
||||
if query[0] == "N":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(event__pk=val) # If string is Nxxxxx then filter by event number
|
||||
elif query[0] == "#":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(pk=val) # If string is #xxxxx then filter by invoice number
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
@reversion.register(follow=['payment_set'])
|
||||
class Invoice(models.Model, RevisionMixin):
|
||||
event = models.OneToOneField('Event', on_delete=models.CASCADE)
|
||||
invoice_date = models.DateField(auto_now_add=True)
|
||||
void = models.BooleanField(default=False)
|
||||
|
||||
reversion_perm = 'RIGS.view_invoice'
|
||||
|
||||
objects = InvoiceManager()
|
||||
|
||||
@property
|
||||
def sum_total(self):
|
||||
return self.event.sum_total
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return self.event.total
|
||||
|
||||
@property
|
||||
def payment_total(self):
|
||||
total = self.payment_set.aggregate(total=models.Sum('amount'))['total']
|
||||
if total:
|
||||
return total
|
||||
return Decimal("0.00")
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
return self.sum_total - self.payment_total
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
return self.balance == 0 or self.void
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('invoice_detail', kwargs={'pk': self.pk})
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"{self.display_id} for Event {self.event.display_id}"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_id}: {self.event} (£{self.balance:.2f})"
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
return f"#{self.pk:05d}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['-invoice_date']
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Payment(models.Model, RevisionMixin):
|
||||
CASH = 'C'
|
||||
INTERNAL = 'I'
|
||||
EXTERNAL = 'E'
|
||||
SUCORE = 'SU'
|
||||
ADJUSTMENT = 'T'
|
||||
METHODS = (
|
||||
(CASH, 'Cash'),
|
||||
(INTERNAL, 'Internal'),
|
||||
(EXTERNAL, 'External'),
|
||||
(SUCORE, 'SU Core'),
|
||||
(ADJUSTMENT, 'TEC Adjustment'),
|
||||
)
|
||||
|
||||
invoice = models.ForeignKey('Invoice', on_delete=models.CASCADE)
|
||||
date = models.DateField()
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text='Please use ex. VAT')
|
||||
method = models.CharField(max_length=2, choices=METHODS, default='', blank=True)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_method_display()}: {self.amount}"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"payment of £{self.amount}"
|
||||
|
||||
|
||||
def validate_url(value):
|
||||
if not value:
|
||||
return # Required error is done the field
|
||||
obj = urlparse(value)
|
||||
if obj.hostname not in ('nottinghamtec.sharepoint.com'):
|
||||
raise ValidationError('URL must point to a location on the TEC Sharepoint')
|
||||
|
||||
|
||||
@reversion.register
|
||||
class RiskAssessment(models.Model, RevisionMixin):
|
||||
SMALL = (0, 'Small')
|
||||
MEDIUM = (1, 'Medium')
|
||||
LARGE = (2, 'Large')
|
||||
SIZES = (SMALL, MEDIUM, LARGE)
|
||||
|
||||
event = models.OneToOneField('Event', on_delete=models.CASCADE)
|
||||
# General
|
||||
nonstandard_equipment = models.BooleanField(help_text="Does the event require any hired in equipment or use of equipment that is not covered by <a href='https://nottinghamtec.sharepoint.com/:f:/g/HealthAndSafety/Eo4xED_DrqFFsfYIjKzMZIIB6Gm_ZfR-a8l84RnzxtBjrA?e=Bf0Haw'>"
|
||||
"TEC's standard risk assessments and method statements?</a>")
|
||||
nonstandard_use = models.BooleanField(help_text="Are TEC using their equipment in a way that is abnormal?<br><small>i.e. Not covered by TECs standard health and safety documentation</small>")
|
||||
contractors = models.BooleanField(help_text="Are you using any external contractors?<br><small>i.e. Freelancers/Crewing Companies</small>")
|
||||
other_companies = models.BooleanField(help_text="Are TEC working with any other companies on site?<br><small>e.g. TEC is providing the lighting while another company does sound</small>")
|
||||
crew_fatigue = models.BooleanField(help_text="Is crew fatigue likely to be a risk at any point during this event?")
|
||||
general_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
|
||||
# Power
|
||||
big_power = models.BooleanField(help_text="Does the event require larger power supplies than 13A or 16A single phase wall sockets, or draw more than 20A total current?")
|
||||
power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='power_mic', blank=True, null=True,
|
||||
verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC? (if yes to the above question, this person <em>must</em> be a Power Technician or Power Supervisor)")
|
||||
outside = models.BooleanField(help_text="Is the event outdoors?")
|
||||
generators = models.BooleanField(help_text="Will generators be used?")
|
||||
other_companies_power = models.BooleanField(help_text="Will TEC be supplying power to any other companies?")
|
||||
nonstandard_equipment_power = models.BooleanField(help_text="Does the power plan require the use of any power equipment (distros, dimmers, motor controllers, etc.) that does not belong to TEC?")
|
||||
multiple_electrical_environments = models.BooleanField(help_text="Will the electrical installation occupy more than one electrical environment?")
|
||||
power_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
power_plan = models.URLField(blank=True, default='', help_text="Upload your power plan to the <a href='https://nottinghamtec.sharepoint.com/'>Sharepoint</a> and submit a link", validators=[validate_url])
|
||||
|
||||
# Sound
|
||||
noise_monitoring = models.BooleanField(help_text="Does the event require noise monitoring or any non-standard procedures in order to comply with health and safety legislation or site rules?")
|
||||
sound_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
|
||||
# Site
|
||||
known_venue = models.BooleanField(help_text="Is this venue new to you (the MIC) or new to TEC?")
|
||||
safe_loading = models.BooleanField(help_text="Are there any issues preventing a safe load in or out? (e.g. sufficient lighting, flat, not in a crowded area etc.)")
|
||||
safe_storage = models.BooleanField(help_text="Are there any problems with safe and secure equipment storage?")
|
||||
area_outside_of_control = models.BooleanField(help_text="Is any part of the work area out of TEC's direct control or openly accessible during the build or breakdown period?")
|
||||
barrier_required = models.BooleanField(help_text="Is there a requirement for TEC to provide any barrier for security or protection of persons/equipment?")
|
||||
nonstandard_emergency_procedure = models.BooleanField(help_text="Does the emergency procedure for the event differ from TEC's standard procedures?")
|
||||
|
||||
# Structures
|
||||
special_structures = models.BooleanField(help_text="Does the event require use of winch stands, motors, MPT Towers, or staging?")
|
||||
suspended_structures = models.BooleanField(help_text="Are any structures (excluding projector screens and IWBs) being suspended from TEC's structures?")
|
||||
persons_responsible_structures = models.TextField(blank=True, default='', help_text="Who are the persons on site responsible for their use?")
|
||||
rigging_plan = models.URLField(blank=True, default='', help_text="Upload your rigging plan to the <a href='https://nottinghamtec.sharepoint.com/'>Sharepoint</a> and submit a link", validators=[validate_url])
|
||||
|
||||
# Blimey that was a lot of options
|
||||
|
||||
reviewed_at = models.DateTimeField(null=True)
|
||||
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
verbose_name="Reviewer", on_delete=models.CASCADE)
|
||||
|
||||
supervisor_consulted = models.BooleanField(null=True)
|
||||
|
||||
expected_values = {
|
||||
'nonstandard_equipment': False,
|
||||
'nonstandard_use': False,
|
||||
'contractors': False,
|
||||
'other_companies': False,
|
||||
'crew_fatigue': False,
|
||||
# 'big_power': False Doesn't require checking with a super either way
|
||||
'generators': False,
|
||||
'other_companies_power': False,
|
||||
'nonstandard_equipment_power': False,
|
||||
'multiple_electrical_environments': False,
|
||||
'noise_monitoring': False,
|
||||
'known_venue': False,
|
||||
'safe_loading': False,
|
||||
'safe_storage': False,
|
||||
'area_outside_of_control': False,
|
||||
'barrier_required': False,
|
||||
'nonstandard_emergency_procedure': False,
|
||||
'special_structures': False,
|
||||
'suspended_structures': False,
|
||||
}
|
||||
inverted_fields = {key: value for (key, value) in expected_values.items() if not value}.keys()
|
||||
|
||||
def clean(self):
|
||||
# Check for idiots
|
||||
if not self.outside and self.generators:
|
||||
raise forms.ValidationError("Engage brain, please. <strong>No generators indoors!(!)</strong>")
|
||||
|
||||
class Meta:
|
||||
ordering = ['event']
|
||||
permissions = [
|
||||
('review_riskassessment', 'Can review Risk Assessments')
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def fieldz(self):
|
||||
return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created]
|
||||
|
||||
@property
|
||||
def event_size(self):
|
||||
# Confirm event size. Check all except generators, since generators entails outside
|
||||
if self.outside or self.other_companies_power or self.nonstandard_equipment_power or self.multiple_electrical_environments:
|
||||
return self.LARGE[0]
|
||||
elif self.big_power:
|
||||
return self.MEDIUM[0]
|
||||
else:
|
||||
return self.SMALL[0]
|
||||
|
||||
def get_event_size_display(self):
|
||||
return self.SIZES[self.event_size][1] + " Event"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return str(self.event)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return str(self)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ra_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk} | {self.event}"
|
||||
|
||||
|
||||
@reversion.register(follow=['vehicles', 'crew'])
|
||||
class EventChecklist(models.Model, RevisionMixin):
|
||||
event = models.ForeignKey('Event', related_name='checklists', on_delete=models.CASCADE)
|
||||
|
||||
# General
|
||||
power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='checklists',
|
||||
verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC?")
|
||||
venue = models.ForeignKey('Venue', on_delete=models.CASCADE)
|
||||
date = models.DateField()
|
||||
|
||||
# Safety Checks
|
||||
safe_parking = models.BooleanField(blank=True, null=True, help_text="Vehicles parked safely?<br><small>(does not obstruct venue access)</small>")
|
||||
safe_packing = models.BooleanField(blank=True, null=True, help_text="Equipment packed away safely?<br><small>(including flightcases)</small>")
|
||||
exits = models.BooleanField(blank=True, null=True, help_text="Emergency exits clear?")
|
||||
trip_hazard = models.BooleanField(blank=True, null=True, help_text="Appropriate barriers around kit and cabling secured?")
|
||||
warning_signs = models.BooleanField(blank=True, help_text="Warning signs in place?<br><small>(strobe, smoke, power etc.)</small>")
|
||||
ear_plugs = models.BooleanField(blank=True, null=True, help_text="Ear plugs issued to crew where needed?")
|
||||
hs_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of Safety Bag/Box")
|
||||
extinguishers_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of fire extinguishers")
|
||||
|
||||
# Small Electrical Checks
|
||||
rcds = models.BooleanField(blank=True, null=True, help_text="RCDs installed where needed and tested?")
|
||||
supply_test = models.BooleanField(blank=True, null=True, help_text="Electrical supplies tested?<br><small>(using socket tester)</small>")
|
||||
|
||||
# Shared electrical checks
|
||||
earthing = models.BooleanField(blank=True, null=True, help_text="Equipment appropriately earthed?<br><small>(truss, stage, generators etc)</small>")
|
||||
pat = models.BooleanField(blank=True, null=True, help_text="All equipment in PAT period?")
|
||||
|
||||
# Medium Electrical Checks
|
||||
source_rcd = models.BooleanField(blank=True, null=True, help_text="Source RCD protected?<br><small>(if cable is more than 3m long) </small>")
|
||||
labelling = models.BooleanField(blank=True, null=True, help_text="Appropriate and clear labelling on distribution and cabling?")
|
||||
# First Distro
|
||||
fd_voltage_l1 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L1-N", help_text="L1 - N")
|
||||
fd_voltage_l2 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L2-N", help_text="L2 - N")
|
||||
fd_voltage_l3 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L3-N", help_text="L3 - N")
|
||||
fd_phase_rotation = models.BooleanField(blank=True, null=True, verbose_name="Phase Rotation", help_text="Phase Rotation<br><small>(if required)</small>")
|
||||
fd_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
fd_pssc = models.IntegerField(blank=True, null=True, verbose_name="PSCC", help_text="Prospective Short Circuit Current")
|
||||
# Worst case points
|
||||
w1_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w1_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w1_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w1_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
w2_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w2_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w2_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w2_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
w3_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w3_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w3_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w3_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
|
||||
all_rcds_tested = models.BooleanField(blank=True, null=True, help_text="All circuit RCDs tested?<br><small>(using test button)</small>")
|
||||
public_sockets_tested = models.BooleanField(blank=True, null=True, help_text="Public/Performer accessible circuits tested?<br><small>(using socket tester)</small>")
|
||||
|
||||
reviewed_at = models.DateTimeField(null=True)
|
||||
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
verbose_name="Reviewer", on_delete=models.CASCADE)
|
||||
|
||||
inverted_fields = []
|
||||
|
||||
class Meta:
|
||||
ordering = ['event']
|
||||
permissions = [
|
||||
('review_eventchecklist', 'Can review Event Checklists')
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def fieldz(self):
|
||||
return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created]
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return str(self.event)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ec_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk} - {self.event}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventChecklistVehicle(models.Model, RevisionMixin):
|
||||
checklist = models.ForeignKey('EventChecklist', related_name='vehicles', blank=True, on_delete=models.CASCADE)
|
||||
vehicle = models.CharField(max_length=255)
|
||||
driver = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='vehicles', on_delete=models.CASCADE)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vehicle} driven by {self.driver}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventChecklistCrew(models.Model, RevisionMixin):
|
||||
checklist = models.ForeignKey('EventChecklist', related_name='crew', blank=True, on_delete=models.CASCADE)
|
||||
crewmember = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='crewed', on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=255)
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def clean(self):
|
||||
if self.start > self.end:
|
||||
raise ValidationError('Unless you\'ve invented time travel, crew can\'t finish before they have started.')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.crewmember} ({self.role})"
|
||||
4
RIGS/models/__init__.py
Normal file
4
RIGS/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .models import *
|
||||
from .finance import *
|
||||
from .hs import *
|
||||
from .events import *
|
||||
467
RIGS/models/events.py
Normal file
467
RIGS/models/events.py
Normal file
@@ -0,0 +1,467 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from reversion import revisions as reversion
|
||||
from versioning.versioning import RevisionMixin
|
||||
|
||||
from RIGS.validators import validate_url
|
||||
from .utils import filter_by_pk
|
||||
from .finance import VatRate
|
||||
|
||||
|
||||
class BaseEventManager(models.Manager):
|
||||
def event_search(self, q, start, end, status):
|
||||
filt = Q()
|
||||
if end:
|
||||
filt &= Q(start_date__lte=end)
|
||||
if start:
|
||||
filt &= Q(start_date__gte=start)
|
||||
|
||||
objects = self.all()
|
||||
|
||||
if q:
|
||||
objects = self.search(q)
|
||||
|
||||
if len(status) > 0:
|
||||
filt &= Q(status__in=status)
|
||||
|
||||
qs = objects.filter(filt).order_by('-start_date')
|
||||
|
||||
# Preselect related for efficiency
|
||||
qs.select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
return qs
|
||||
|
||||
class EventManager(BaseEventManager):
|
||||
def current_events(self):
|
||||
events = self.filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now().date(), dry_hire=False) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Ends after
|
||||
(models.Q(dry_hire=True, start_date__gte=timezone.now()) & ~models.Q(
|
||||
status=Event.CANCELLED)) | # Active dry hire
|
||||
(models.Q(dry_hire=True, checked_in_by__isnull=True) & (
|
||||
models.Q(status=Event.BOOKED) | models.Q(status=Event.CONFIRMED))) | # Active dry hire GT
|
||||
models.Q(status=Event.CANCELLED, start_date__gte=timezone.now()) # Canceled but not started
|
||||
).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
return events
|
||||
|
||||
def events_in_bounds(self, start, end):
|
||||
events = self.filter(
|
||||
(models.Q(start_date__gte=start.date(), start_date__lte=end.date())) | # Start date in bounds
|
||||
(models.Q(end_date__gte=start.date(), end_date__lte=end.date())) | # End date in bounds
|
||||
(models.Q(access_at__gte=start, access_at__lte=end)) | # Access at in bounds
|
||||
(models.Q(meet_at__gte=start, meet_at__lte=end)) | # Meet at in bounds
|
||||
|
||||
(models.Q(start_date__lte=start, end_date__gte=end)) | # Start before, end after
|
||||
(models.Q(access_at__lte=start, start_date__gte=end)) | # Access before, start after
|
||||
(models.Q(access_at__lte=start, end_date__gte=end)) | # Access before, end after
|
||||
(models.Q(meet_at__lte=start, start_date__gte=end)) | # Meet before, start after
|
||||
(models.Q(meet_at__lte=start, end_date__gte=end)) # Meet before, end after
|
||||
|
||||
).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person',
|
||||
'organisation',
|
||||
'venue', 'mic')
|
||||
return events
|
||||
|
||||
def active_dry_hires(self):
|
||||
return self.filter(dry_hire=True, start_date__gte=timezone.now(), is_rig=True)
|
||||
|
||||
def rig_count(self):
|
||||
event_count = self.exclude(status=BaseEvent.CANCELLED).filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False,
|
||||
is_rig=True)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now(), dry_hire=False, is_rig=True)) | # Ends after
|
||||
(models.Q(dry_hire=True, start_date__gte=timezone.now(), is_rig=True)) # Active dry hire
|
||||
).count()
|
||||
return event_count
|
||||
|
||||
def waiting_invoices(self):
|
||||
events = self.filter(
|
||||
(
|
||||
models.Q(start_date__lte=datetime.date.today(), end_date__isnull=True) | # Starts before with no end
|
||||
models.Q(end_date__lte=datetime.date.today()) # Or has end date, finishes before
|
||||
) & models.Q(invoice__isnull=True) & # Has not already been invoiced
|
||||
models.Q(is_rig=True) # Is a rig (not non-rig)
|
||||
).order_by('start_date') \
|
||||
.select_related('person', 'organisation', 'venue', 'mic') \
|
||||
.prefetch_related('items')
|
||||
|
||||
return events
|
||||
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(name__icontains=query) | Q(description__icontains=query) | Q(notes__icontains=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
try:
|
||||
if query[0] == "N":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(pk=val) # If string is N###### then do a simple PK filter
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
def find_earliest_event_time(event, datetime_list):
|
||||
# If there is no start time defined, pretend it's midnight
|
||||
startTimeFaked = False
|
||||
if event.has_start_time:
|
||||
startDateTime = datetime.datetime.combine(event.start_date, event.start_time)
|
||||
else:
|
||||
startDateTime = datetime.datetime.combine(event.start_date, datetime.time(00, 00))
|
||||
startTimeFaked = True
|
||||
|
||||
# timezoneIssues - apply the default timezone to the naiive datetime
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
startDateTime = tz.localize(startDateTime)
|
||||
datetime_list.append(startDateTime) # then add it to the list
|
||||
|
||||
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
|
||||
|
||||
# if we faked it & it's the earliest, better own up
|
||||
if startTimeFaked and earliest == startDateTime:
|
||||
return event.start_date
|
||||
return earliest
|
||||
|
||||
|
||||
class BaseEvent(models.Model, RevisionMixin):
|
||||
# Done to make it much nicer on the database
|
||||
PROVISIONAL = 0
|
||||
CONFIRMED = 1
|
||||
BOOKED = 2
|
||||
CANCELLED = 3
|
||||
EVENT_STATUS_CHOICES = (
|
||||
(PROVISIONAL, 'Provisional'),
|
||||
(CONFIRMED, 'Confirmed'),
|
||||
(BOOKED, 'Booked'),
|
||||
(CANCELLED, 'Cancelled'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE)
|
||||
organisation = models.ForeignKey('Organisation', blank=True, null=True, on_delete=models.CASCADE)
|
||||
description = models.TextField(blank=True, default='')
|
||||
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL)
|
||||
|
||||
# Timing
|
||||
start_date = models.DateField()
|
||||
start_time = models.TimeField(blank=True, null=True)
|
||||
end_date = models.DateField(blank=True, null=True)
|
||||
end_time = models.TimeField(blank=True, null=True)
|
||||
|
||||
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def cancelled(self):
|
||||
return (self.status == self.CANCELLED)
|
||||
|
||||
@property
|
||||
def confirmed(self):
|
||||
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
|
||||
|
||||
@property
|
||||
def has_start_time(self):
|
||||
return self.start_time is not None
|
||||
|
||||
@property
|
||||
def has_end_time(self):
|
||||
return self.end_time is not None
|
||||
|
||||
@property
|
||||
def latest_time(self):
|
||||
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
endDate = self.end_date
|
||||
if endDate is None:
|
||||
endDate = self.start_date
|
||||
|
||||
if self.has_end_time:
|
||||
endDateTime = datetime.datetime.combine(endDate, self.end_time)
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
endDateTime = tz.localize(endDateTime)
|
||||
|
||||
return endDateTime
|
||||
|
||||
else:
|
||||
return endDate
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
start = self.earliest_time
|
||||
if isinstance(self.earliest_time, datetime.datetime):
|
||||
start = self.earliest_time.date()
|
||||
end = self.latest_time
|
||||
if isinstance(self.latest_time, datetime.datetime):
|
||||
end = self.latest_time.date()
|
||||
return (end - start).days + 1
|
||||
|
||||
def clean(self):
|
||||
errdict = {}
|
||||
if self.end_date and self.start_date > self.end_date:
|
||||
errdict['end_date'] = ["Unless you've invented time travel, the event can't finish before it has started."]
|
||||
|
||||
startEndSameDay = not self.end_date or self.end_date == self.start_date
|
||||
hasStartAndEnd = self.has_start_time and self.has_end_time
|
||||
if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time:
|
||||
errdict['end_time'] = ["Unless you've invented time travel, the event can't finish before it has started."]
|
||||
return errdict
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_id}: {self.name}"
|
||||
|
||||
@reversion.register(follow=['items'])
|
||||
class Event(BaseEvent):
|
||||
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
|
||||
verbose_name="MIC", on_delete=models.CASCADE)
|
||||
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
dry_hire = models.BooleanField(default=False)
|
||||
is_rig = models.BooleanField(default=True)
|
||||
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
|
||||
null=True)
|
||||
|
||||
access_at = models.DateTimeField(blank=True, null=True)
|
||||
meet_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
# Dry-hire only
|
||||
checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True,
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
# Monies
|
||||
collector = models.CharField(max_length=255, blank=True, default='', verbose_name='collected by')
|
||||
|
||||
# Authorisation request details
|
||||
auth_request_by = models.ForeignKey('Profile', null=True, blank=True, on_delete=models.CASCADE)
|
||||
auth_request_at = models.DateTimeField(null=True, blank=True)
|
||||
auth_request_to = models.EmailField(blank=True, default='')
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
if self.pk:
|
||||
if self.is_rig:
|
||||
return f"N{self.pk:05d}"
|
||||
return self.pk
|
||||
return "????"
|
||||
|
||||
# Calculated values
|
||||
"""
|
||||
EX Vat
|
||||
"""
|
||||
|
||||
@property
|
||||
def sum_total(self):
|
||||
total = self.items.aggregate(
|
||||
sum_total=models.Sum(models.F('cost') * models.F('quantity'),
|
||||
output_field=models.DecimalField(max_digits=10, decimal_places=2))
|
||||
)['sum_total']
|
||||
if total:
|
||||
return total
|
||||
return Decimal("0.00")
|
||||
|
||||
@cached_property
|
||||
def vat_rate(self):
|
||||
return VatRate.objects.find_rate(self.start_date)
|
||||
|
||||
@property
|
||||
def vat(self):
|
||||
# No VAT is owed on internal transfers
|
||||
if self.internal:
|
||||
return 0
|
||||
return Decimal(self.sum_total * self.vat_rate.rate).quantize(Decimal('.01'))
|
||||
|
||||
"""
|
||||
Inc VAT
|
||||
"""
|
||||
@property
|
||||
def total(self):
|
||||
return Decimal(self.sum_total + self.vat).quantize(Decimal('.01'))
|
||||
|
||||
@property
|
||||
def hs_done(self):
|
||||
return self.riskassessment is not None and len(self.checklists.all()) > 0
|
||||
|
||||
@property
|
||||
def earliest_time(self):
|
||||
"""Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
|
||||
|
||||
# Put all the datetimes in a list
|
||||
datetime_list = []
|
||||
|
||||
if self.access_at:
|
||||
datetime_list.append(self.access_at)
|
||||
|
||||
if self.meet_at:
|
||||
datetime_list.append(self.meet_at)
|
||||
|
||||
earliest = find_earliest_event_time(self, datetime_list)
|
||||
|
||||
return earliest
|
||||
|
||||
@property
|
||||
def internal(self):
|
||||
return bool(self.organisation and self.organisation.union_account)
|
||||
|
||||
@property
|
||||
def authorised(self):
|
||||
if self.internal and hasattr(self, 'authorisation'):
|
||||
return self.authorisation.amount == self.total
|
||||
else:
|
||||
return bool(self.purchase_order)
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
if self.cancelled:
|
||||
return "secondary"
|
||||
elif not self.is_rig:
|
||||
return "info"
|
||||
elif not self.mic:
|
||||
return "danger"
|
||||
elif self.confirmed and self.authorised:
|
||||
if self.dry_hire or self.riskassessment:
|
||||
return "success"
|
||||
return "warning"
|
||||
else:
|
||||
return "warning"
|
||||
|
||||
objects = EventManager()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('event_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def get_edit_url(self):
|
||||
return reverse('event_update', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
errdict = super().clean()
|
||||
|
||||
if self.access_at is not None:
|
||||
if self.access_at.date() > self.start_date:
|
||||
errdict['access_at'] = ['Regardless of what some clients might think, access time cannot be after the event has started.']
|
||||
elif self.start_time is not None and self.start_date == self.access_at.date() and self.access_at.time() > self.start_time:
|
||||
errdict['access_at'] = ['Regardless of what some clients might think, access time cannot be after the event has started.']
|
||||
|
||||
if errdict: # If there was an error when validation
|
||||
raise ValidationError(errdict)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call :meth:`full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super(Event, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventItem(models.Model, RevisionMixin):
|
||||
event = models.ForeignKey('Event', related_name='items', blank=True, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default='')
|
||||
quantity = models.IntegerField()
|
||||
cost = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
order = models.IntegerField()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
return self.cost * self.quantity
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_id}.{self.order}: {self.event.name} | {self.name}"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"item {self.name}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventAuthorisation(models.Model, RevisionMixin):
|
||||
event = models.OneToOneField('Event', related_name='authorisation', on_delete=models.CASCADE)
|
||||
email = models.EmailField()
|
||||
name = models.CharField(max_length=255)
|
||||
uni_id = models.CharField(max_length=10, blank=True, default='', verbose_name="University ID")
|
||||
account_code = models.CharField(max_length=50, default='', blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount")
|
||||
sent_by = models.ForeignKey('Profile', on_delete=models.CASCADE)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('event_detail', kwargs={'pk': self.event_id})
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"{self.event.display_id} (requested by {self.sent_by.initials})"
|
||||
|
||||
|
||||
class SubhireManager(BaseEventManager):
|
||||
def current_events(self):
|
||||
events = self.exclude(status=BaseEvent.CANCELLED).filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now().date())) # Ends after
|
||||
).order_by('start_date', 'end_date', 'start_time', 'end_time').select_related('person', 'organisation')
|
||||
|
||||
return events
|
||||
|
||||
def event_count(self):
|
||||
event_count = self.exclude(status=BaseEvent.CANCELLED).filter(
|
||||
(models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end
|
||||
(models.Q(end_date__gte=timezone.now()))
|
||||
).count()
|
||||
return event_count
|
||||
|
||||
@reversion.register
|
||||
class Subhire(BaseEvent):
|
||||
insurance_value = models.DecimalField(max_digits=10, decimal_places=2) # TODO Validate if this is over notifiable threshold
|
||||
events = models.ManyToManyField(Event)
|
||||
quote = models.URLField(default='', validators=[validate_url])
|
||||
|
||||
objects = SubhireManager()
|
||||
|
||||
@property
|
||||
def is_rig(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def dry_hire(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
return f"S{self.pk:05d}"
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return "purple"
|
||||
|
||||
def get_edit_url(self):
|
||||
return reverse('subhire_update', kwargs={'pk': self.pk})
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('subhire_detail', kwargs={'pk': self.pk})
|
||||
|
||||
@property
|
||||
def earliest_time(self):
|
||||
return find_earliest_event_time(self, [])
|
||||
|
||||
class Meta:
|
||||
permissions = [
|
||||
('subhire_finance', 'Can see financial data for subhire - insurance values')
|
||||
]
|
||||
170
RIGS/models/finance.py
Normal file
170
RIGS/models/finance.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from reversion import revisions as reversion
|
||||
from versioning.versioning import RevisionMixin
|
||||
from .utils import filter_by_pk
|
||||
|
||||
|
||||
class VatManager(models.Manager):
|
||||
def current_rate(self):
|
||||
return self.find_rate(timezone.now())
|
||||
|
||||
def find_rate(self, date):
|
||||
try:
|
||||
return self.filter(start_at__lte=date).latest()
|
||||
except VatRate.DoesNotExist:
|
||||
r = VatRate
|
||||
r.rate = 0
|
||||
return r
|
||||
|
||||
|
||||
@reversion.register
|
||||
class VatRate(models.Model, RevisionMixin):
|
||||
start_at = models.DateField()
|
||||
rate = models.DecimalField(max_digits=6, decimal_places=6)
|
||||
comment = models.CharField(max_length=255)
|
||||
|
||||
objects = VatManager()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@property
|
||||
def as_percent(self):
|
||||
return self.rate * 100
|
||||
|
||||
class Meta:
|
||||
ordering = ['-start_at']
|
||||
get_latest_by = 'start_at'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.comment} {self.start_at} @ {self.as_percent}%"
|
||||
|
||||
|
||||
class InvoiceManager(models.Manager):
|
||||
def outstanding_invoices(self):
|
||||
# Manual query is the only way I have found to do this efficiently. Not ideal but needs must
|
||||
sql = "SELECT * FROM " \
|
||||
"(SELECT " \
|
||||
"(SELECT COUNT(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payment_count\", " \
|
||||
"(SELECT SUM(ei.cost * ei.quantity) FROM \"RIGS_eventitem\" AS ei WHERE ei.event_id=\"RIGS_invoice\".event_id) AS \"cost\", " \
|
||||
"(SELECT SUM(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payments\", " \
|
||||
"\"RIGS_invoice\".\"id\", \"RIGS_invoice\".\"event_id\", \"RIGS_invoice\".\"invoice_date\", \"RIGS_invoice\".\"void\" FROM \"RIGS_invoice\") " \
|
||||
"AS sub " \
|
||||
"WHERE (((cost > 0.0) AND (payment_count=0)) OR (cost - payments) <> 0.0) AND void = '0'" \
|
||||
"ORDER BY invoice_date"
|
||||
|
||||
query = self.raw(sql)
|
||||
return query
|
||||
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(event__name__icontains=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(query)
|
||||
or_lookup = or_lookup | Q(event__pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
|
||||
try:
|
||||
if query[0] == "N":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(event__pk=val) # If string is Nxxxxx then filter by event number
|
||||
elif query[0] == "#":
|
||||
val = int(query[1:])
|
||||
or_lookup = Q(pk=val) # If string is #xxxxx then filter by invoice number
|
||||
except: # noqa
|
||||
pass
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
@reversion.register(follow=['payment_set'])
|
||||
class Invoice(models.Model, RevisionMixin):
|
||||
event = models.OneToOneField('Event', on_delete=models.CASCADE)
|
||||
invoice_date = models.DateField(auto_now_add=True)
|
||||
void = models.BooleanField(default=False)
|
||||
|
||||
reversion_perm = 'RIGS.view_invoice'
|
||||
|
||||
objects = InvoiceManager()
|
||||
|
||||
@property
|
||||
def sum_total(self):
|
||||
return self.event.sum_total
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return self.event.total
|
||||
|
||||
@property
|
||||
def payment_total(self):
|
||||
total = self.payment_set.aggregate(total=models.Sum('amount'))['total']
|
||||
if total:
|
||||
return total
|
||||
return Decimal("0.00")
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
return self.sum_total - self.payment_total
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
return self.balance == 0 or self.void
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('invoice_detail', kwargs={'pk': self.pk})
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"{self.display_id} for Event {self.event.display_id}"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_id}: {self.event} (£{self.balance:.2f})"
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
return f"#{self.pk:05d}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['-invoice_date']
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Payment(models.Model, RevisionMixin):
|
||||
CASH = 'C'
|
||||
INTERNAL = 'I'
|
||||
EXTERNAL = 'E'
|
||||
SUCORE = 'SU'
|
||||
ADJUSTMENT = 'T'
|
||||
METHODS = (
|
||||
(CASH, 'Cash'),
|
||||
(INTERNAL, 'Internal'),
|
||||
(EXTERNAL, 'External'),
|
||||
(SUCORE, 'SU Core'),
|
||||
(ADJUSTMENT, 'TEC Adjustment'),
|
||||
)
|
||||
|
||||
invoice = models.ForeignKey('Invoice', on_delete=models.CASCADE)
|
||||
date = models.DateField()
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text='Please use ex. VAT')
|
||||
method = models.CharField(max_length=2, choices=METHODS, default='', blank=True)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_method_display()}: {self.amount}"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"payment of £{self.amount}"
|
||||
243
RIGS/models/hs.py
Normal file
243
RIGS/models/hs.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from reversion import revisions as reversion
|
||||
from versioning.versioning import RevisionMixin
|
||||
|
||||
from RIGS.validators import validate_url
|
||||
|
||||
|
||||
@reversion.register
|
||||
class RiskAssessment(models.Model, RevisionMixin):
|
||||
SMALL = (0, 'Small')
|
||||
MEDIUM = (1, 'Medium')
|
||||
LARGE = (2, 'Large')
|
||||
SIZES = (SMALL, MEDIUM, LARGE)
|
||||
|
||||
event = models.OneToOneField('Event', on_delete=models.CASCADE)
|
||||
# General
|
||||
nonstandard_equipment = models.BooleanField(help_text="Does the event require any hired in equipment or use of equipment that is not covered by <a href='https://nottinghamtec.sharepoint.com/:f:/g/HealthAndSafety/Eo4xED_DrqFFsfYIjKzMZIIB6Gm_ZfR-a8l84RnzxtBjrA?e=Bf0Haw'>"
|
||||
"TEC's standard risk assessments and method statements?</a>")
|
||||
nonstandard_use = models.BooleanField(help_text="Are TEC using their equipment in a way that is abnormal?<br><small>i.e. Not covered by TECs standard health and safety documentation</small>")
|
||||
contractors = models.BooleanField(help_text="Are you using any external contractors?<br><small>i.e. Freelancers/Crewing Companies</small>")
|
||||
other_companies = models.BooleanField(help_text="Are TEC working with any other companies on site?<br><small>e.g. TEC is providing the lighting while another company does sound</small>")
|
||||
crew_fatigue = models.BooleanField(help_text="Is crew fatigue likely to be a risk at any point during this event?")
|
||||
general_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
|
||||
# Power
|
||||
big_power = models.BooleanField(help_text="Does the event require larger power supplies than 13A or 16A single phase wall sockets, or draw more than 20A total current?")
|
||||
power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='power_mic', blank=True, null=True,
|
||||
verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC? (if yes to the above question, this person <em>must</em> be a Power Technician or Power Supervisor)")
|
||||
outside = models.BooleanField(help_text="Is the event outdoors?")
|
||||
generators = models.BooleanField(help_text="Will generators be used?")
|
||||
other_companies_power = models.BooleanField(help_text="Will TEC be supplying power to any other companies?")
|
||||
nonstandard_equipment_power = models.BooleanField(help_text="Does the power plan require the use of any power equipment (distros, dimmers, motor controllers, etc.) that does not belong to TEC?")
|
||||
multiple_electrical_environments = models.BooleanField(help_text="Will the electrical installation occupy more than one electrical environment?")
|
||||
power_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
power_plan = models.URLField(blank=True, default='', help_text="Upload your power plan to the <a href='https://nottinghamtec.sharepoint.com/'>Sharepoint</a> and submit a link", validators=[validate_url])
|
||||
|
||||
# Sound
|
||||
noise_monitoring = models.BooleanField(help_text="Does the event require noise monitoring or any non-standard procedures in order to comply with health and safety legislation or site rules?")
|
||||
sound_notes = models.TextField(blank=True, default='', help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?")
|
||||
|
||||
# Site
|
||||
known_venue = models.BooleanField(help_text="Is this venue new to you (the MIC) or new to TEC?")
|
||||
safe_loading = models.BooleanField(help_text="Are there any issues preventing a safe load in or out? (e.g. sufficient lighting, flat, not in a crowded area etc.)")
|
||||
safe_storage = models.BooleanField(help_text="Are there any problems with safe and secure equipment storage?")
|
||||
area_outside_of_control = models.BooleanField(help_text="Is any part of the work area out of TEC's direct control or openly accessible during the build or breakdown period?")
|
||||
barrier_required = models.BooleanField(help_text="Is there a requirement for TEC to provide any barrier for security or protection of persons/equipment?")
|
||||
nonstandard_emergency_procedure = models.BooleanField(help_text="Does the emergency procedure for the event differ from TEC's standard procedures?")
|
||||
|
||||
# Structures
|
||||
special_structures = models.BooleanField(help_text="Does the event require use of winch stands, motors, MPT Towers, or staging?")
|
||||
suspended_structures = models.BooleanField(help_text="Are any structures (excluding projector screens and IWBs) being suspended from TEC's structures?")
|
||||
persons_responsible_structures = models.TextField(blank=True, default='', help_text="Who are the persons on site responsible for their use?")
|
||||
rigging_plan = models.URLField(blank=True, default='', help_text="Upload your rigging plan to the <a href='https://nottinghamtec.sharepoint.com/'>Sharepoint</a> and submit a link", validators=[validate_url])
|
||||
|
||||
# Blimey that was a lot of options
|
||||
|
||||
reviewed_at = models.DateTimeField(null=True)
|
||||
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
verbose_name="Reviewer", on_delete=models.CASCADE)
|
||||
|
||||
supervisor_consulted = models.BooleanField(null=True)
|
||||
|
||||
expected_values = {
|
||||
'nonstandard_equipment': False,
|
||||
'nonstandard_use': False,
|
||||
'contractors': False,
|
||||
'other_companies': False,
|
||||
'crew_fatigue': False,
|
||||
# 'big_power': False Doesn't require checking with a super either way
|
||||
'generators': False,
|
||||
'other_companies_power': False,
|
||||
'nonstandard_equipment_power': False,
|
||||
'multiple_electrical_environments': False,
|
||||
'noise_monitoring': False,
|
||||
'known_venue': False,
|
||||
'safe_loading': False,
|
||||
'safe_storage': False,
|
||||
'area_outside_of_control': False,
|
||||
'barrier_required': False,
|
||||
'nonstandard_emergency_procedure': False,
|
||||
'special_structures': False,
|
||||
'suspended_structures': False,
|
||||
}
|
||||
inverted_fields = {key: value for (key, value) in expected_values.items() if not value}.keys()
|
||||
|
||||
def clean(self):
|
||||
# Check for idiots
|
||||
if not self.outside and self.generators:
|
||||
raise forms.ValidationError("Engage brain, please. <strong>No generators indoors!(!)</strong>")
|
||||
|
||||
class Meta:
|
||||
ordering = ['event']
|
||||
permissions = [
|
||||
('review_riskassessment', 'Can review Risk Assessments')
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def fieldz(self):
|
||||
return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created]
|
||||
|
||||
@property
|
||||
def event_size(self):
|
||||
# Confirm event size. Check all except generators, since generators entails outside
|
||||
if self.outside or self.other_companies_power or self.nonstandard_equipment_power or self.multiple_electrical_environments:
|
||||
return self.LARGE[0]
|
||||
elif self.big_power:
|
||||
return self.MEDIUM[0]
|
||||
else:
|
||||
return self.SMALL[0]
|
||||
|
||||
def get_event_size_display(self):
|
||||
return self.SIZES[self.event_size][1] + " Event"
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return str(self.event)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return str(self)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ra_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk} | {self.event}"
|
||||
|
||||
|
||||
@reversion.register(follow=['vehicles', 'crew'])
|
||||
class EventChecklist(models.Model, RevisionMixin):
|
||||
event = models.ForeignKey('Event', related_name='checklists', on_delete=models.CASCADE)
|
||||
|
||||
# General
|
||||
power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='checklists',
|
||||
verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC?")
|
||||
venue = models.ForeignKey('Venue', on_delete=models.CASCADE)
|
||||
date = models.DateField()
|
||||
|
||||
# Safety Checks
|
||||
safe_parking = models.BooleanField(blank=True, null=True, help_text="Vehicles parked safely?<br><small>(does not obstruct venue access)</small>")
|
||||
safe_packing = models.BooleanField(blank=True, null=True, help_text="Equipment packed away safely?<br><small>(including flightcases)</small>")
|
||||
exits = models.BooleanField(blank=True, null=True, help_text="Emergency exits clear?")
|
||||
trip_hazard = models.BooleanField(blank=True, null=True, help_text="Appropriate barriers around kit and cabling secured?")
|
||||
warning_signs = models.BooleanField(blank=True, help_text="Warning signs in place?<br><small>(strobe, smoke, power etc.)</small>")
|
||||
ear_plugs = models.BooleanField(blank=True, null=True, help_text="Ear plugs issued to crew where needed?")
|
||||
hs_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of Safety Bag/Box")
|
||||
extinguishers_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of fire extinguishers")
|
||||
|
||||
# Small Electrical Checks
|
||||
rcds = models.BooleanField(blank=True, null=True, help_text="RCDs installed where needed and tested?")
|
||||
supply_test = models.BooleanField(blank=True, null=True, help_text="Electrical supplies tested?<br><small>(using socket tester)</small>")
|
||||
|
||||
# Shared electrical checks
|
||||
earthing = models.BooleanField(blank=True, null=True, help_text="Equipment appropriately earthed?<br><small>(truss, stage, generators etc)</small>")
|
||||
pat = models.BooleanField(blank=True, null=True, help_text="All equipment in PAT period?")
|
||||
|
||||
# Medium Electrical Checks
|
||||
source_rcd = models.BooleanField(blank=True, null=True, help_text="Source RCD protected?<br><small>(if cable is more than 3m long) </small>")
|
||||
labelling = models.BooleanField(blank=True, null=True, help_text="Appropriate and clear labelling on distribution and cabling?")
|
||||
# First Distro
|
||||
fd_voltage_l1 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L1-N", help_text="L1 - N")
|
||||
fd_voltage_l2 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L2-N", help_text="L2 - N")
|
||||
fd_voltage_l3 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L3-N", help_text="L3 - N")
|
||||
fd_phase_rotation = models.BooleanField(blank=True, null=True, verbose_name="Phase Rotation", help_text="Phase Rotation<br><small>(if required)</small>")
|
||||
fd_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
fd_pssc = models.IntegerField(blank=True, null=True, verbose_name="PSCC", help_text="Prospective Short Circuit Current")
|
||||
# Worst case points
|
||||
w1_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w1_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w1_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w1_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
w2_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w2_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w2_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w2_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
w3_description = models.CharField(blank=True, default='', max_length=255, help_text="Description")
|
||||
w3_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?")
|
||||
w3_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage")
|
||||
w3_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (Z<small>S</small>)")
|
||||
|
||||
all_rcds_tested = models.BooleanField(blank=True, null=True, help_text="All circuit RCDs tested?<br><small>(using test button)</small>")
|
||||
public_sockets_tested = models.BooleanField(blank=True, null=True, help_text="Public/Performer accessible circuits tested?<br><small>(using socket tester)</small>")
|
||||
|
||||
reviewed_at = models.DateTimeField(null=True)
|
||||
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
verbose_name="Reviewer", on_delete=models.CASCADE)
|
||||
|
||||
inverted_fields = []
|
||||
|
||||
class Meta:
|
||||
ordering = ['event']
|
||||
permissions = [
|
||||
('review_eventchecklist', 'Can review Event Checklists')
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def fieldz(self):
|
||||
return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created]
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return str(self.event)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ec_detail', kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk} | {self.event}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventChecklistVehicle(models.Model, RevisionMixin):
|
||||
checklist = models.ForeignKey('EventChecklist', related_name='vehicles', blank=True, on_delete=models.CASCADE)
|
||||
vehicle = models.CharField(max_length=255)
|
||||
driver = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='vehicles', on_delete=models.CASCADE)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vehicle} driven by {self.driver}"
|
||||
|
||||
|
||||
@reversion.register
|
||||
class EventChecklistCrew(models.Model, RevisionMixin):
|
||||
checklist = models.ForeignKey('EventChecklist', related_name='crew', blank=True, on_delete=models.CASCADE)
|
||||
crewmember = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='crewed', on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=255)
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
def clean(self):
|
||||
if self.start > self.end:
|
||||
raise ValidationError('Unless you\'ve invented time travel, crew can\'t finish before they have started.')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.crewmember} ({self.role})"
|
||||
173
RIGS/models/models.py
Normal file
173
RIGS/models/models.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from collections import Counter
|
||||
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from versioning.versioning import RevisionMixin
|
||||
from .events import Event
|
||||
from .utils import filter_by_pk
|
||||
|
||||
|
||||
class Profile(AbstractUser):
|
||||
initials = models.CharField(max_length=5, null=True, blank=False)
|
||||
phone = models.CharField(max_length=13, blank=True, default='')
|
||||
api_key = models.CharField(max_length=40, blank=True, editable=False, default='')
|
||||
is_approved = models.BooleanField(default=False, verbose_name="Approval Status", help_text="Designates whether a staff member has approved this user.")
|
||||
# Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that...
|
||||
last_emailed = models.DateTimeField(blank=True, null=True)
|
||||
dark_theme = models.BooleanField(default=False)
|
||||
is_supervisor = models.BooleanField(default=False)
|
||||
|
||||
reversion_hide = True
|
||||
|
||||
@classmethod
|
||||
def make_api_key(cls):
|
||||
size = 20
|
||||
chars = string.ascii_letters + string.digits
|
||||
new_api_key = ''.join(random.choice(chars) for x in range(size))
|
||||
return new_api_key
|
||||
|
||||
@property
|
||||
def profile_picture(self):
|
||||
url = ""
|
||||
if settings.USE_GRAVATAR or settings.USE_GRAVATAR is None:
|
||||
url = "https://www.gravatar.com/avatar/" + hashlib.md5(
|
||||
self.email.encode('utf-8')).hexdigest() + "?d=wavatar&s=500"
|
||||
return url
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
name = self.get_full_name()
|
||||
if self.initials:
|
||||
name += f' "{self.initials}"'
|
||||
return name
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic', 'riskassessment', 'invoice').prefetch_related('checklists')
|
||||
|
||||
@classmethod
|
||||
def admins(cls):
|
||||
return Profile.objects.filter(email__in=[y for x in settings.ADMINS for y in x])
|
||||
|
||||
@classmethod
|
||||
def users_awaiting_approval_count(cls):
|
||||
return Profile.objects.filter(models.Q(is_approved=False)).count()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ContactableManager(models.Manager):
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = Q(name__icontains=query) | Q(email__icontains=query) | Q(address__icontains=query) | Q(notes__icontains=query) | Q(
|
||||
phone__startswith=query) | Q(phone__endswith=query)
|
||||
|
||||
or_lookup = filter_by_pk(or_lookup, query)
|
||||
|
||||
qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups
|
||||
return qs
|
||||
|
||||
|
||||
class Person(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
notes = models.TextField(blank=True, default='')
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes is not None:
|
||||
if len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def organisations(self):
|
||||
o = []
|
||||
for e in Event.objects.filter(person=self).select_related('organisation'):
|
||||
if e.organisation:
|
||||
o.append(e.organisation)
|
||||
|
||||
# Count up occurances and put them in descending order
|
||||
c = Counter(o)
|
||||
stats = c.most_common()
|
||||
return stats
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('person_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class Organisation(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=50)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
notes = models.TextField(blank=True, default='')
|
||||
union_account = models.BooleanField(default=False)
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes is not None:
|
||||
if len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def persons(self):
|
||||
p = []
|
||||
for e in Event.objects.filter(organisation=self).select_related('person'):
|
||||
if e.person:
|
||||
p.append(e.person)
|
||||
|
||||
# Count up occurances and put them in descending order
|
||||
c = Counter(p)
|
||||
stats = c.most_common()
|
||||
return stats
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('organisation_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class Venue(models.Model, RevisionMixin):
|
||||
name = models.CharField(max_length=255)
|
||||
phone = models.CharField(max_length=15, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
three_phase_available = models.BooleanField(default=False)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
|
||||
objects = ContactableManager()
|
||||
|
||||
def __str__(self):
|
||||
string = self.name
|
||||
if self.notes and len(self.notes) > 0:
|
||||
string += "*"
|
||||
return string
|
||||
|
||||
@property
|
||||
def latest_events(self):
|
||||
return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('venue_detail', kwargs={'pk': self.pk})
|
||||
9
RIGS/models/utils.py
Normal file
9
RIGS/models/utils.py
Normal file
@@ -0,0 +1,9 @@
|
||||
def filter_by_pk(filt, query):
|
||||
# try and parse an int
|
||||
try:
|
||||
val = int(query)
|
||||
filt = filt | Q(pk=val)
|
||||
except: # noqa
|
||||
# not an integer
|
||||
pass
|
||||
return filt
|
||||
@@ -58,13 +58,13 @@ def send_eventauthorisation_success_email(instance):
|
||||
|
||||
client_email = EmailMultiAlternatives(
|
||||
subject,
|
||||
get_template("eventauthorisation_client_success.txt").render(context),
|
||||
get_template("email/eventauthorisation_client_success.txt").render(context),
|
||||
to=[instance.email],
|
||||
reply_to=[settings.AUTHORISATION_NOTIFICATION_ADDRESS],
|
||||
)
|
||||
|
||||
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()
|
||||
client_email.attach_alternative(html, 'text/html')
|
||||
|
||||
@@ -82,7 +82,7 @@ def send_eventauthorisation_success_email(instance):
|
||||
|
||||
mic_email = EmailMessage(
|
||||
subject,
|
||||
get_template("eventauthorisation_mic_success.txt").render(context),
|
||||
get_template("email/eventauthorisation_mic_success.txt").render(context),
|
||||
to=[mic_email_address]
|
||||
)
|
||||
|
||||
@@ -117,12 +117,12 @@ def send_admin_awaiting_approval_email(user, request, **kwargs):
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
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],
|
||||
reply_to=[user.email],
|
||||
)
|
||||
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()
|
||||
email.attach_alternative(html, 'text/html')
|
||||
email.send()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 63 KiB |
@@ -11,6 +11,7 @@
|
||||
<initialize>
|
||||
<color id="LightGray" RGB="#D3D3D3"/>
|
||||
<color id="DarkGray" RGB="#707070"/>
|
||||
<color id="Brand" RGB="#3853a4"/>
|
||||
</initialize>
|
||||
|
||||
<paraStyle name="style.para" fontName="OpenSans" />
|
||||
@@ -27,6 +28,8 @@
|
||||
<paraStyle name="style.times" fontName="OpenSans" fontSize="10" />
|
||||
<paraStyle name="style.head_titles" fontName="OpenSans-Bold" 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" borderPadding="4"/>
|
||||
<paraStyle name="style.breakbefore" parent="emheader" pageBreakBefore="1"/>
|
||||
|
||||
<blockTableStyle id="eventSpecifics">
|
||||
<blockValign value="top"/>
|
||||
@@ -84,7 +87,7 @@
|
||||
bulletFontSize="10"/>
|
||||
</stylesheet>
|
||||
|
||||
<template > {# Note: page is 595x842 points (1 point=1/72in) #}
|
||||
<template title="{{filename}}"> {# Note: page is 595x842 points (1 point=1/72in) #}
|
||||
<pageTemplate id="Headed" >
|
||||
<pageGraphics>
|
||||
<image file="static/imgs/paperwork/corner-tr-su.jpg" x="395" y="642" height="200" width="200"/>
|
||||
|
||||
@@ -1,197 +1,131 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Calendar{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<link href="{% static 'css/main.css' %}" rel='stylesheet' />
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'js/moment.js' %}"></script>
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script>
|
||||
viewToUrl = {
|
||||
'timeGridWeek':'week',
|
||||
'timeGridDay':'day',
|
||||
'dayGridMonth':'month'
|
||||
}
|
||||
viewFromUrl = {
|
||||
'week':'timeGridWeek',
|
||||
'day':'timeGridDay',
|
||||
'month':'dayGridMonth'
|
||||
}
|
||||
var calendar; //Need to access it from jquery ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
|
||||
calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
themeSystem: 'bootstrap',
|
||||
aspectRatio: 1.5,
|
||||
eventTimeFormat: {
|
||||
'hour': '2-digit',
|
||||
'minute': '2-digit',
|
||||
'hour12': false
|
||||
},
|
||||
headerToolbar: false,
|
||||
editable: false,
|
||||
dayMaxEventRows: true, // allow "more" link when too many events
|
||||
events: function(fetchInfo, successCallback, failureCallback) {
|
||||
$.ajax({
|
||||
url: '/api/event',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
start: moment(fetchInfo.startStr).format("YYYY-MM-DD[T]HH:mm:ss"),
|
||||
end: moment(fetchInfo.endStr).format("YYYY-MM-DD[T]HH:mm:ss")
|
||||
},
|
||||
success: function(doc) {
|
||||
var events = [];
|
||||
colours = {
|
||||
'Provisional': '#FFE89B',
|
||||
'Confirmed': '#3AB54A' ,
|
||||
'Booked': '#3AB54A' ,
|
||||
'Cancelled': 'grey' ,
|
||||
'non-rig': '#25AAE2'
|
||||
};
|
||||
$(doc).each(function() {
|
||||
end = $(this).attr('latest')
|
||||
allDay = false
|
||||
if(end.indexOf("T") < 0){ //If latest does not contain a time
|
||||
end = moment(end + " 23:59").format("YYYY-MM-DD[T]HH:mm:ss")
|
||||
allDay = true
|
||||
}
|
||||
|
||||
thisEvent = {
|
||||
'start': $(this).attr('earliest'),
|
||||
'end': end,
|
||||
'className': 'modal-href',
|
||||
'title': $(this).attr('title'),
|
||||
'url': $(this).attr('url'),
|
||||
'allDay': allDay
|
||||
}
|
||||
|
||||
if($(this).attr('is_rig')===true || $(this).attr('status') === "Cancelled"){
|
||||
thisEvent['color'] = colours[$(this).attr('status')];
|
||||
}else{
|
||||
thisEvent['color'] = colours['non-rig'];
|
||||
}
|
||||
events.push(thisEvent);
|
||||
});
|
||||
successCallback(events);
|
||||
}
|
||||
});
|
||||
},
|
||||
datesSet: function(info) {
|
||||
var view = info.view;
|
||||
// Set the title of the view
|
||||
$('#calendar-header').text(view.title);
|
||||
|
||||
// Enable/Disable "Today" button as required
|
||||
let $today = $('#today-button');
|
||||
if(moment().isBetween(view.currentStart, view.currentEnd)){
|
||||
//Today is within the current view
|
||||
$today.prop('disabled', true);
|
||||
}else{
|
||||
$today.prop('disabled', false);
|
||||
}
|
||||
|
||||
// Set active view select button
|
||||
let $month = $('#month-button');
|
||||
let $week = $('#week-button');
|
||||
let $day = $('#day-button');
|
||||
switch(view.type){
|
||||
case 'dayGridMonth':
|
||||
$month.addClass('active');
|
||||
$week.removeClass('active');
|
||||
$day.removeClass('active');
|
||||
break;
|
||||
|
||||
case 'timeGridWeek':
|
||||
$month.removeClass('active');
|
||||
$week.addClass('active');
|
||||
$day.removeClass('active');
|
||||
break;
|
||||
|
||||
case 'timeGridDay':
|
||||
$month.removeClass('active');
|
||||
$week.removeClass('active');
|
||||
$day.addClass('active');
|
||||
break;
|
||||
}
|
||||
history.replaceState(null,null,"{% url 'web_calendar' %}"+viewToUrl[view.type]+'/'+moment(view.currentStart).format('YYYY-MM-DD')+'/');
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
});
|
||||
$(document).ready(function() {
|
||||
<script src="{% static 'js/moment.js' %}"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// set some button listeners
|
||||
$('#next-button').click(function(){ calendar.next(); });
|
||||
$('#prev-button').click(function(){ calendar.prev(); });
|
||||
$('#today-button').click(function(){ calendar.today(); });
|
||||
$('#month-button').click(function(){ calendar.changeView('dayGridMonth'); });
|
||||
$('#week-button').click(function(){ calendar.changeView('timeGridWeek'); });
|
||||
$('#day-button').click(function(){ calendar.changeView('timeGridDay'); });
|
||||
$('#go-to-date-input').change(function(){
|
||||
if(moment($('#go-to-date-input').val()).isValid()){
|
||||
$('#go-to-date-button').prop('disabled', false);
|
||||
document.getElementById('go-to-date-button').classList.remove('disabled');
|
||||
document.getElementById('go-to-date-button').href = "?month=" + moment($('#go-to-date-input').val()).format("YYYY-MM");
|
||||
} else{
|
||||
$('#go-to-date-button').prop('disabled', true);
|
||||
document.getElementById('go-to-date-button').classList.add('disabled');
|
||||
}
|
||||
});
|
||||
$('#go-to-date-button').click(function(){
|
||||
day = moment($('#go-to-date-input').val());
|
||||
if(day.isValid()){
|
||||
calendar.gotoDate(day.format("YYYY-MM-DD"));
|
||||
} else{
|
||||
alert('Invalid Date');
|
||||
}
|
||||
});
|
||||
{% if view and date %}
|
||||
// Go to the initial settings, if they're valid
|
||||
view = viewFromUrl['{{view}}'];
|
||||
calendar.changeView(view);
|
||||
day = moment('{{date}}');
|
||||
if(day.isValid()){
|
||||
calendar.gotoDate(day.format("YYYY-MM-DD"));
|
||||
} else{
|
||||
console.log('Supplied date is invalid - using default')
|
||||
}
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.week {
|
||||
display:grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-flow: dense;
|
||||
grid-gap: 2px 10px;
|
||||
border: 1px solid black;
|
||||
height: 8em;
|
||||
align-content: start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.day {
|
||||
display:contents;
|
||||
}
|
||||
.day-label {
|
||||
grid-row-start: 1;
|
||||
text-align: right;
|
||||
margin:0;
|
||||
font-size: 1em !important;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.week-day, .day-label, .event {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.event {
|
||||
background-color: #CCC;
|
||||
font-size: 0.8em !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-end {
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.event-start {
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
|
||||
.week-day {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.event {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-span="1"] { grid-column-end: span 1; }
|
||||
[data-span="2"] { grid-column-end: span 2; }
|
||||
[data-span="3"] { grid-column-end: span 3; }
|
||||
[data-span="4"] { grid-column-end: span 4; }
|
||||
[data-span="5"] { grid-column-end: span 5; }
|
||||
[data-span="6"] { grid-column-end: span 6; }
|
||||
[data-span="7"] { grid-column-end: span 7; }
|
||||
|
||||
.day > a {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="pull-left">
|
||||
<span id="calendar-header" class="h2"></span>
|
||||
</div>
|
||||
<div class="form-inline float-right btn-page my-3">
|
||||
<div class="input-group mx-2">
|
||||
<input type="date" class="form-control" id="go-to-date-input" placeholder="Go to date...">
|
||||
<span class="input-group-append">
|
||||
<button class="btn btn-success" id="go-to-date-button" type="button" disabled>Go!</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="btn-group mx-2">
|
||||
<button type="button" class="btn btn-primary" id="today-button">Today</button>
|
||||
</div>
|
||||
<div class="btn-group mx-2">
|
||||
<button type="button" class="btn btn-secondary" id="prev-button"><span class="fas fa-chevron-left"></span></button>
|
||||
<button type="button" class="btn btn-secondary" id="next-button"><span class="fas fa-chevron-right"></span></button>
|
||||
</div>
|
||||
<div class="btn-group ml-2">
|
||||
<button type="button" class="btn btn-light" id="month-button">Month</button>
|
||||
<button type="button" class="btn btn-light" id="week-button">Week</button>
|
||||
<button type="button" class="btn btn-light" id="day-button">Day</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div id='calendar'></div>
|
||||
<div class="row justify-content-center mb-1">
|
||||
<a class="btn btn-info col-2" href="{% url 'web_calendar' %}?{{ prev_month }}"><span class="fas fa-chevron-left"></span> Previous Month</a>
|
||||
<div class="form-inline col-4">
|
||||
<div class="input-group">
|
||||
<input type="date" id="go-to-date-input" placeholder="Go to date..." class="form-control">
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-success" id="go-to-date-button">Go!</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary col-2" id="today-button">Today</button>
|
||||
<a class="btn btn-info mx-2 col-2" href="{% url 'web_calendar' %}?{{ next_month }}"><span class="fas fa-chevron-right"></span> Next Month</a>
|
||||
</div>
|
||||
|
||||
<div class="week" style="height: 2em;">
|
||||
<div class="week-day">Monday</div>
|
||||
<div class="week-day">Tuesday</div>
|
||||
<div class="week-day">Wednesday</div>
|
||||
<div class="week-day">Thursday</div>
|
||||
<div class="week-day">Friday</div>
|
||||
<div class="week-day">Saturday</div>
|
||||
<div class="week-day">Sunday</div>
|
||||
</div>
|
||||
{% for week in weeks %}
|
||||
<div class="week">
|
||||
{% for day in week %}
|
||||
{% if day.0 != 0 %}
|
||||
<div class="day" id="{{day.0}}">
|
||||
<h3 class="day-label text-muted">{{day.0}}</h3>
|
||||
{{ day.2|safe }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="day"><span style="grid-row-start: 1;"> <span></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
29
RIGS/templates/dashboards/productions.html
Normal file
29
RIGS/templates/dashboards/productions.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
|
||||
{% load button from filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Upcoming Events</div>
|
||||
<div class="card-body">{{ rig_count }}</div>
|
||||
<div class="card-footer"><a href={% url 'rigboard' %}>View</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Upcoming Subhire</div>
|
||||
<div class="card-body">{{ subhire_count }}</div>
|
||||
<div class="card-footer"><a href={% url 'subhire_list' %}>View</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Active Dry Hires</div>
|
||||
<div class="card-body">{{ hire_count }}</div>
|
||||
<div class="card-footer"><a href={% url 'rigboard' %}>View</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
5
RIGS/templates/email/eventauthorisation_mic_success.txt
Normal file
5
RIGS/templates/email/eventauthorisation_mic_success.txt
Normal 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
|
||||
16
RIGS/templates/email/ra_reminder.html
Normal file
16
RIGS/templates/email/ra_reminder.html
Normal 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 & Lighting</p>
|
||||
{% endblock %}
|
||||
9
RIGS/templates/email/ra_reminder.txt
Normal file
9
RIGS/templates/email/ra_reminder.txt
Normal 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
|
||||
@@ -15,36 +15,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12 py-2">
|
||||
<form class="form-inline" method="GET">
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Start</span>
|
||||
</div>
|
||||
<input type="date" name="start" id="start" value="{{ start|default_if_none:'' }}" placeholder="Start" class="form-control" />
|
||||
</div>
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">End</span>
|
||||
</div>
|
||||
<input type="date" name="end" id="end" value="{{ end|default_if_none:'' }}" placeholder="End" class="form-control" />
|
||||
</div>
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Keyword</span>
|
||||
</div>
|
||||
<input type="search" name="q" placeholder="Keyword" value="{{ request.GET.q }}" class="form-control" />
|
||||
</div>
|
||||
<select class="selectpicker pr-3" multiple data-actions-box="true" data-none-selected-text="Status" data-actions-box="true" id="status" name="status">
|
||||
{% for status in statuses %}
|
||||
<option value="{{status.0}}" {% if status.0|safe in request.GET|get_list:'status' %}selected=""{% endif %}>{{status.1}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% button 'search' %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'partials/archive_form.html' %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% with object_list as events %}
|
||||
|
||||
@@ -45,9 +45,15 @@
|
||||
<hr>
|
||||
<p class="dont-break-out">{{ event.notes|markdown }}</p>
|
||||
{% endif %}
|
||||
<br>
|
||||
{% include 'partials/item_table.html' %}
|
||||
<h4>Event Items</h4>
|
||||
</div>
|
||||
{% include 'partials/item_table.html' %}
|
||||
{% if event.subhire_set.count > 0 %}
|
||||
<div class="card-body"><h4>Associated Subhires</h4></div>
|
||||
{% with event.subhire_set.all as events %}
|
||||
{% include 'partials/event_table.html' %}
|
||||
{%endwith%}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not request.is_ajax and perms.RIGS.view_event %}
|
||||
|
||||
@@ -106,6 +106,10 @@
|
||||
title="Things that aren't service-based, like training, meetings and site visits.">
|
||||
<button type="button" class="btn btn-info w-25" data-is_rig="0">Non-Rig</button>
|
||||
</span>
|
||||
<span data-toggle="tooltip"
|
||||
title="Record equipment hired in from other companies">
|
||||
<a href="{% url 'subhire_create' %}" class="btn bg-warning w-25">Subhire</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
32
RIGS/templates/partials/archive_form.html
Normal file
32
RIGS/templates/partials/archive_form.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% load get_list from filters %}
|
||||
{% load button from filters %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12 py-2">
|
||||
<form class="form-inline" method="GET">
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Start</span>
|
||||
</div>
|
||||
<input type="date" name="start" id="start" value="{{ start|default_if_none:'' }}" placeholder="Start" class="form-control" />
|
||||
</div>
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">End</span>
|
||||
</div>
|
||||
<input type="date" name="end" id="end" value="{{ end|default_if_none:'' }}" placeholder="End" class="form-control" />
|
||||
</div>
|
||||
<div class="input-group mx-2">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">Keyword</span>
|
||||
</div>
|
||||
<input type="search" name="q" placeholder="Keyword" value="{{ request.GET.q }}" class="form-control" />
|
||||
</div>
|
||||
<select class="selectpicker pr-3" multiple data-actions-box="true" data-none-selected-text="Status" data-actions-box="true" id="status" name="status">
|
||||
{% for status in statuses %}
|
||||
<option value="{{status.0}}" {% if status.0|safe in request.GET|get_list:'status' %}selected=""{% endif %}>{{status.1}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% button 'search' %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,7 +47,5 @@
|
||||
class="fas fa-pound-sign"></span>
|
||||
<span class="d-none d-sm-inline">Invoice</span></a>
|
||||
{% endif %}
|
||||
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSf-TBOuJZCTYc2L8DWdAaC3_Werq0ulsUs8-6G85I6pA9WVsg/viewform" class="btn btn-danger"><span class="fas fa-file-invoice-dollar"></span> <span class="d-none d-sm-inline">Subhire Insurance Form</span></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -12,21 +12,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr class="{% if event.cancelled %}
|
||||
table-secondary
|
||||
{% elif not event.is_rig %}
|
||||
table-info
|
||||
{% elif not event.mic %}
|
||||
table-danger
|
||||
{% elif event.confirmed and event.authorised %}
|
||||
{% if event.dry_hire or event.riskassessment %}
|
||||
table-success
|
||||
{% else %}
|
||||
table-warning
|
||||
{% endif %}
|
||||
{% else %}
|
||||
table-warning
|
||||
{% endif %}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
|
||||
<tr class="table-{{event.color}}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
|
||||
<!---Number-->
|
||||
<th scope="row" id="event_number">{{ event.display_id }}</th>
|
||||
<!--Dates & Times-->
|
||||
@@ -56,7 +42,7 @@
|
||||
<!---Details-->
|
||||
<td id="event_details" class="w-100">
|
||||
<h4>
|
||||
<a href="{% url 'event_detail' event.pk %}">
|
||||
<a href="{{event.get_absolute_url}}">
|
||||
{{ event.name }}
|
||||
</a>
|
||||
{% if event.venue %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<button type="button" class="btn btn-success btn-sm item-add"
|
||||
data-toggle="modal"
|
||||
data-target="#itemModal">
|
||||
<i class="fas fa-plus"></i> Add Item
|
||||
<span class="fas fa-plus"></span> Add Item
|
||||
</button>
|
||||
</th>
|
||||
{% endif %}
|
||||
|
||||
84
RIGS/templates/partials/subhire_table.html
Normal file
84
RIGS/templates/partials/subhire_table.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% load linked_name from filters %}
|
||||
{% load markdown_tags %}
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0" id="event_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Dates & Times</th>
|
||||
<th scope="col">Hire Details</th>
|
||||
<th scope="col">Associated Event(s)</th>
|
||||
{% if perms.RIGS.subhire_finance %}
|
||||
<th scope="col">Insurance Value</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
|
||||
<!---Number-->
|
||||
<th scope="row" id="event_number">{{ event.display_id }}</th>
|
||||
<!--Dates & Times-->
|
||||
<td id="event_dates" style="text-align: justify;">
|
||||
<span class="text-nowrap">Start: <strong>{{ event.start_date|date:"D d/m/Y" }}
|
||||
{% if event.has_start_time %}
|
||||
{{ event.start_time|date:"H:i" }}
|
||||
{% endif %}</strong>
|
||||
</span>
|
||||
{% if event.end_date %}
|
||||
<br>
|
||||
<span class="text-nowrap">End: {% if event.end_date != event.start_date %}<strong>{{ event.end_date|date:"D d/m/Y" }}{% endif %}
|
||||
{% if event.has_end_time %}
|
||||
{{ event.end_time|date:"H:i" }}
|
||||
{% endif %}</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<!---Details-->
|
||||
<td id="event_details" class="w-100">
|
||||
<h4>
|
||||
<a href="{{event.get_absolute_url}}">
|
||||
{{ event.name }}
|
||||
</a>
|
||||
</h4>
|
||||
<h5>
|
||||
Primary Contact: {{ event.person|linked_name }}
|
||||
{% if event.organisation %}
|
||||
({{ event.organisation|linked_name }})
|
||||
{% endif %}
|
||||
</h5>
|
||||
{% if not event.cancelled and event.description %}
|
||||
<p>{{ event.description|markdown }}</p>
|
||||
{% endif %}
|
||||
{% include 'partials/event_status.html' %}
|
||||
</td>
|
||||
<td class="p-0 text-nowrap">
|
||||
<ul class="list-group">
|
||||
{% for event in event.events.all %}
|
||||
<li class="list-group-item"><a href="{{event.get_absolute_url}}">{{ event }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
{% if perms.RIGS.subhire_finance %}
|
||||
<td id="insurance_value" class="text-nowrap">
|
||||
£{{ event.insurance_value }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr class="bg-warning">
|
||||
<td colspan="4">No events found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>Total Value:</td>
|
||||
<td>£{{ total_value }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,14 +1,62 @@
|
||||
{% extends request.is_ajax|yesno:"base_ajax.html,base_rigs.html" %}
|
||||
|
||||
{% load markdown_tags %}
|
||||
{% load button from filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row my-3 py-3">
|
||||
<div class="col-sm-12 text-right mb-2">
|
||||
{% button 'edit' 'subhire_update' object.pk %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% include 'partials/contact_details.html' %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% include 'partials/event_details.html' %}
|
||||
<div class="card card-default">
|
||||
<div class="card-header">Hire Details</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Name</dt>
|
||||
<dd class="col-sm-6">{{ object.name }}</dd>
|
||||
<dt class="col-sm-6">Event Starts</dt>
|
||||
<dd class="col-sm-6">{{ object.start_date|date:"D d M Y" }} {{ object.start_time|date:"H:i" }}</dd>
|
||||
|
||||
<dt class="col-sm-6">Event Ends</dt>
|
||||
<dd class="col-sm-6">{{ object.end_date|date:"D d M Y" }} {{ object.end_time|date:"H:i" }}</dd>
|
||||
|
||||
<dt class="col-sm-6">Status</dt>
|
||||
<dd class="col-sm-6">{{ object.get_status_display }}</dd>
|
||||
|
||||
<dt class="col-sm-6">PO</dt>
|
||||
<dd class="col-sm-6">{{ object.po }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-2">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">Equipment Information</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Description</dt>
|
||||
<dd class="col-sm-6">{{ object.description }}</dd>
|
||||
{% if perms.RIGS.subhire_finance %}
|
||||
<dt class="col-sm-6">Insurance Value</dt>
|
||||
<dd class="col-sm-6">£{{ object.insurance_value }}</dd>
|
||||
{% endif %}
|
||||
<dt class="col-sm-6">Quote</dt>
|
||||
<dd class="col-sm-6"><a href="{{ object.quote }}">View</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12 mt-2">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">Associated Event(s)</div>
|
||||
{% with object.events.all as events %}
|
||||
{% include 'partials/event_table.html' %}
|
||||
{%endwith%}
|
||||
</div>
|
||||
</div>
|
||||
{% if not request.is_ajax and perms.RIGS.view_event %}
|
||||
<div class="col-sm-12 text-right">
|
||||
@@ -23,5 +71,6 @@
|
||||
{% if perms.RIGS.view_event %}
|
||||
{% include 'partials/last_edited.html' with target="event_history" %}
|
||||
{% endif %}
|
||||
<a href="{% url 'subhire_detail' object.pk %}" class="btn btn-primary">Open Event Page <span class="fas fa-eye"></span></a>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{% include 'form_errors.html' %}
|
||||
</div>
|
||||
{# Contact details #}
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="card">
|
||||
<div class="card-header">Contact Details</div>
|
||||
<div class="card-body">
|
||||
@@ -93,6 +93,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="card">
|
||||
<div class="card-header">Associated Event(s)</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<select multiple name="events" id="events_id" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='event' %}">
|
||||
{% if object.events.count > 0 %}
|
||||
{% for event in object.events.all %}
|
||||
<option value="{{event.id}}" selected>{{ event }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# Event details #}
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="card card-default">
|
||||
@@ -150,33 +166,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Associated Event(s)</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<select multiple name="events" id="events_id" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='event' %}"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Equipment Information</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="{{ form.description.id_for_label }}"
|
||||
class="col-sm-4 col-form-label">{{ form.description.label }}</label>
|
||||
<div class="col-sm-12">
|
||||
{% render_field form.description class+="form-control" %}
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="card">
|
||||
<div class="card-header">Equipment Information</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="{{ form.description.id_for_label }}"
|
||||
class="col-sm-4 col-form-label">{{ form.description.label }}</label>
|
||||
<div class="col-sm-12">
|
||||
{% render_field form.description class+="form-control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.insurance_value.id_for_label }}"
|
||||
class="col-sm-6 col-form-label">{{ form.insurance_value.label }}</label>
|
||||
<div class="col-sm-8 input-group">
|
||||
<div class="input-group-prepend"><span class="input-group-text">£</span></div>
|
||||
{% render_field form.insurance_value class+="form-control" %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.insurance_value.id_for_label }}"
|
||||
class="col-sm-6 col-form-label">{{ form.insurance_value.label }}</label>
|
||||
<div class="col-sm-8 input-group">
|
||||
<div class="input-group-prepend"><span class="input-group-text">£</span></div>
|
||||
{% render_field form.insurance_value class+="form-control" %}
|
||||
</div>
|
||||
<div class="border border-info p-2 rounded mt-1 font-weight-bold" style="border-width: thin thin thin thick !important;">
|
||||
If this value is greater than £50,000 then please email productions@nottinghamtec.co.uk in addition to complete the additional insurance requirements
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-info p-2 rounded mt-1" style="border-width: thin thin thin thick !important;">
|
||||
If this value is greater than £50,000 then please email productions@nottinghamtec.co.uk in addition to complete the additional insurance requirements
|
||||
<div class="form-group">
|
||||
<label for="{{ form.quote.id_for_label }}" class="col-sm-6 col-form-label">{{ form.quote.label }} (TEC SharePoint link)</label>
|
||||
<div class="col-sm-12">{% render_field form.quote class+="form-control" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
RIGS/templates/subhire_list.html
Normal file
27
RIGS/templates/subhire_list.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends 'base_rigs.html' %}
|
||||
{% load paginator from filters %}
|
||||
{% load static %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'css/selects.css' %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block preload_js %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'js/selects.js' %}" async></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'partials/archive_form.html' %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{% with object_list as events %}
|
||||
{% include 'partials/subhire_table.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% paginator %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
def get_field(obj, field, autoescape=True):
|
||||
value = getattr(obj, field)
|
||||
if(isinstance(value, bool)):
|
||||
if (isinstance(value, bool)):
|
||||
value = yesnoi(value, field in obj.inverted_fields)
|
||||
elif(isinstance(value, str)):
|
||||
elif (isinstance(value, str)):
|
||||
value = truncatewords(value, 20)
|
||||
return mark_safe(value)
|
||||
|
||||
@@ -144,7 +144,7 @@ def get_list(dictionary, key):
|
||||
|
||||
@register.filter
|
||||
def profile_by_index(value):
|
||||
if(value):
|
||||
if (value):
|
||||
return models.Profile.objects.get(pk=int(value))
|
||||
else:
|
||||
return ""
|
||||
@@ -175,6 +175,9 @@ def namewithnotes(obj, url, autoescape=True):
|
||||
else:
|
||||
return obj.name
|
||||
|
||||
@register.filter(needs_autoescape=True)
|
||||
def linked_name(object, autoescape=True):
|
||||
return mark_safe(f"<a href='{object.get_absolute_url()}'>{object.name}</a>")
|
||||
|
||||
@register.filter(needs_autoescape=True)
|
||||
def linkornone(target, namespace=None, autoescape=True):
|
||||
@@ -196,8 +199,8 @@ def button(type, url=None, pk=None, clazz="", icon=None, text="", id=None, style
|
||||
text = "Edit"
|
||||
elif type == 'print':
|
||||
clazz += " btn-primary "
|
||||
icon = "fa-print"
|
||||
text = "Print"
|
||||
icon = "fa-download"
|
||||
text = "Export"
|
||||
elif type == 'duplicate':
|
||||
clazz += " btn-info "
|
||||
icon = "fa-copy"
|
||||
@@ -216,6 +219,8 @@ def button(type, url=None, pk=None, clazz="", icon=None, text="", id=None, style
|
||||
return {'submit': True, 'class': 'btn-info', 'icon': 'fa-search', 'text': 'Search', 'id': id, 'style': style}
|
||||
elif type == 'submit':
|
||||
return {'submit': True, 'class': 'btn-primary', 'icon': 'fa-save', 'text': 'Save', 'id': id, 'style': style}
|
||||
elif type == 'today':
|
||||
return {'today': True, 'id': id}
|
||||
return {'target': url, 'pk': pk, 'class': clazz, 'icon': icon, 'text': text, 'id': id, 'style': style}
|
||||
|
||||
|
||||
|
||||
22
RIGS/urls.py
22
RIGS/urls.py
@@ -43,12 +43,8 @@ urlpatterns = [
|
||||
|
||||
# Rigboard
|
||||
path('rigboard/', login_required(views.RigboardIndex.as_view()), name='rigboard'),
|
||||
path('rigboard/calendar/', login_required()(views.WebCalendar.as_view()),
|
||||
re_path(r'^rigboard/calendar/$', login_required()(views.WebCalendar.as_view()),
|
||||
name='web_calendar'),
|
||||
re_path(r'^rigboard/calendar/(?P<view>(month|week|day))/$',
|
||||
login_required()(views.WebCalendar.as_view()), name='web_calendar'),
|
||||
re_path(r'^rigboard/calendar/(?P<view>(month|week|day))/(?P<date>(\d{4}-\d{2}-\d{2}))/$',
|
||||
login_required()(views.WebCalendar.as_view()), name='web_calendar'),
|
||||
path('rigboard/archive/', RedirectView.as_view(permanent=True, pattern_name='event_archive')),
|
||||
|
||||
|
||||
@@ -72,12 +68,18 @@ urlpatterns = [
|
||||
|
||||
|
||||
# Subhire
|
||||
path('subhire/<int:pk>/', views.SubhireDetail.as_view(),
|
||||
path('subhire/<int:pk>/', login_required(views.SubhireDetail.as_view()),
|
||||
name='subhire_detail'),
|
||||
path('subhire/create/', permission_required_with_403('RIGS.add_event')(views.SubhireCreate.as_view()),
|
||||
name='subhire_create'),
|
||||
path('subhire/<int:pk>/edit', views.SubhireEdit.as_view(),
|
||||
name='subhire_edit'),
|
||||
path('subhire/<int:pk>/edit', permission_required_with_403('RIGS.change_event')(views.SubhireEdit.as_view()),
|
||||
name='subhire_update'),
|
||||
path('subhire/list/', login_required(views.SubhireList.as_view()),
|
||||
name='subhire_list'),
|
||||
|
||||
# Dashboards
|
||||
path('dashboard/productions/', views.ProductionsDashboard.as_view(),
|
||||
name='productions_dashboard'),
|
||||
|
||||
|
||||
# Event H&S
|
||||
@@ -85,7 +87,7 @@ urlpatterns = [
|
||||
|
||||
path('event/<int:pk>/ra/', permission_required_with_403('RIGS.add_riskassessment')(views.EventRiskAssessmentCreate.as_view()),
|
||||
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'),
|
||||
path('event/ra/<int:pk>/edit/', permission_required_with_403('RIGS.change_riskassessment')(views.EventRiskAssessmentEdit.as_view()),
|
||||
name='ra_edit'),
|
||||
@@ -97,7 +99,7 @@ urlpatterns = [
|
||||
|
||||
path('event/<int:pk>/checklist/', permission_required_with_403('RIGS.add_eventchecklist')(views.EventChecklistCreate.as_view()),
|
||||
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'),
|
||||
path('event/checklist/<int:pk>/edit/', permission_required_with_403('RIGS.change_eventchecklist')(views.EventChecklistEdit.as_view()),
|
||||
name='ec_edit'),
|
||||
|
||||
56
RIGS/utils.py
Normal file
56
RIGS/utils.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime, timedelta, date
|
||||
import calendar
|
||||
from calendar import HTMLCalendar
|
||||
from RIGS.models import Event, Subhire
|
||||
|
||||
|
||||
class Calendar(HTMLCalendar):
|
||||
def __init__(self, year=None, month=None):
|
||||
self.year = year
|
||||
self.month = month
|
||||
super(Calendar, self).__init__()
|
||||
|
||||
def get_html(self, day, event):
|
||||
return f"<a href='{event.get_absolute_url()}' class='modal-href' style='display: contents;'><div class='event event-start event-end bg-{event.color}' data-span='{event.length}' style='grid-column-start: calc({day[1]} + 1)'>{event}</div></a>"
|
||||
|
||||
def formatmonth(self, withyear=True):
|
||||
events = Event.objects.filter(start_date__year=self.year, start_date__month=self.month).order_by("start_date")
|
||||
subhires = Subhire.objects.filter(start_date__year=self.year, start_date__month=self.month).order_by("start_date")
|
||||
weeks = self.monthdays2calendar(self.year, self.month)
|
||||
data = []
|
||||
|
||||
for week in weeks:
|
||||
weeks_events = []
|
||||
|
||||
for day in week:
|
||||
# Events that have started this week
|
||||
events_per_day = events.filter(start_date__day=day[0])
|
||||
subhires_per_day = subhires.filter(start_date__day=day[0])
|
||||
event_html = ""
|
||||
for event in events_per_day:
|
||||
event_html += self.get_html(day, event)
|
||||
for sh in subhires_per_day:
|
||||
event_html += self.get_html(day, sh)
|
||||
weeks_events.append((day[0], day[1], event_html))
|
||||
data.append(weeks_events)
|
||||
return data
|
||||
|
||||
|
||||
def get_date(req_day):
|
||||
if req_day:
|
||||
year, month = (int(x) for x in req_day.split('-'))
|
||||
return date(year, month, day=1)
|
||||
return datetime.today()
|
||||
|
||||
def prev_month(d):
|
||||
first = d.replace(day=1)
|
||||
prev_month = first - timedelta(days=1)
|
||||
month = f'month={str(prev_month.year)}-{str(prev_month.month)}'
|
||||
return month
|
||||
|
||||
def next_month(d):
|
||||
days_in_month = calendar.monthrange(d.year, d.month)[1]
|
||||
last = d.replace(day=days_in_month)
|
||||
next_month = last + timedelta(days=1)
|
||||
month = f'month={str(next_month.year)}-{str(next_month.month)}'
|
||||
return month
|
||||
10
RIGS/validators.py
Normal file
10
RIGS/validators.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from urllib.parse import urlparse
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
def validate_url(value):
|
||||
if not value:
|
||||
return # Required error is done the field
|
||||
obj = urlparse(value)
|
||||
if obj.hostname not in ('nottinghamtec.sharepoint.com'):
|
||||
raise ValidationError('URL must point to a location on the TEC Sharepoint')
|
||||
@@ -4,3 +4,4 @@ from .hs import *
|
||||
from .ical import *
|
||||
from .rigboard import *
|
||||
from .subhire import *
|
||||
from .dashboards import *
|
||||
14
RIGS/views/dashboards.py
Normal file
14
RIGS/views/dashboards.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.views import generic
|
||||
from RIGS import models
|
||||
|
||||
|
||||
class ProductionsDashboard(generic.TemplateView):
|
||||
template_name = 'dashboards/productions.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['page_title'] = "Productions Dashboard"
|
||||
context['rig_count'] = models.Event.objects.rig_count()
|
||||
context['subhire_count'] = models.Subhire.objects.event_count()
|
||||
context['hire_count'] = models.Event.objects.active_dry_hires().count()
|
||||
return context
|
||||
@@ -1,12 +1,14 @@
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django_ical.views import ICalFeed
|
||||
|
||||
from RIGS import models
|
||||
|
||||
from itertools import chain
|
||||
|
||||
|
||||
class CalendarICS(ICalFeed):
|
||||
"""
|
||||
@@ -31,6 +33,7 @@ class CalendarICS(ICalFeed):
|
||||
params['dry-hire'] = request.GET.get('dry-hire', 'true') == 'true'
|
||||
params['non-rig'] = request.GET.get('non-rig', 'true') == 'true'
|
||||
params['rig'] = request.GET.get('rig', 'true') == 'true'
|
||||
params['subhire'] = request.GET.get('subhire', 'true') == 'true'
|
||||
|
||||
params['cancelled'] = request.GET.get('cancelled', 'false') == 'true'
|
||||
params['provisional'] = request.GET.get('provisional', 'true') == 'true'
|
||||
@@ -40,42 +43,46 @@ class CalendarICS(ICalFeed):
|
||||
|
||||
def description(self, params):
|
||||
desc = "Calendar generated by RIGS system. This includes event types: " + ('Rig, ' if params['rig'] else '') + (
|
||||
'Non-rig, ' if params['non-rig'] else '') + ('Dry Hire ' if params['dry-hire'] else '') + '\n'
|
||||
desc = desc + "Includes events with status: " + ('Cancelled, ' if params['cancelled'] else '') + (
|
||||
'Non-rig, ' if params['non-rig'] else '') + ('Dry Hire, ' if params['dry-hire'] else '') + ('Subhires' if params['subhire'] else '') + '\n'
|
||||
desc += "Includes events with status: " + ('Cancelled, ' if params['cancelled'] else '') + (
|
||||
'Provisional, ' if params['provisional'] else '') + ('Confirmed/Booked, ' if params['confirmed'] else '')
|
||||
|
||||
return desc
|
||||
|
||||
def items(self, params):
|
||||
# include events from up to 1 year ago
|
||||
start = datetime.datetime.now() - datetime.timedelta(days=365)
|
||||
start = timezone.now() - datetime.timedelta(days=365)
|
||||
filter = Q(start_date__gte=start)
|
||||
|
||||
typeFilters = Q(pk=None) # Need something that is false for every entry
|
||||
type_filters = Q(pk=None) # Need something that is false for every entry
|
||||
|
||||
if params['dry-hire']:
|
||||
typeFilters = typeFilters | Q(dry_hire=True, is_rig=True)
|
||||
type_filters = type_filters | Q(dry_hire=True, is_rig=True)
|
||||
|
||||
if params['non-rig']:
|
||||
typeFilters = typeFilters | Q(is_rig=False)
|
||||
type_filters = type_filters | Q(is_rig=False)
|
||||
|
||||
if params['rig']:
|
||||
typeFilters = typeFilters | Q(is_rig=True, dry_hire=False)
|
||||
type_filters = type_filters | Q(is_rig=True, dry_hire=False)
|
||||
|
||||
statusFilters = Q(pk=None) # Need something that is false for every entry
|
||||
status_filters = Q(pk=None) # Need something that is false for every entry
|
||||
|
||||
if params['cancelled']:
|
||||
statusFilters = statusFilters | Q(status=models.Event.CANCELLED)
|
||||
status_filters = status_filters | Q(status=models.Event.CANCELLED)
|
||||
if params['provisional']:
|
||||
statusFilters = statusFilters | Q(status=models.Event.PROVISIONAL)
|
||||
status_filters = status_filters | Q(status=models.Event.PROVISIONAL)
|
||||
if params['confirmed']:
|
||||
statusFilters = statusFilters | Q(status=models.Event.CONFIRMED) | Q(status=models.Event.BOOKED)
|
||||
status_filters = status_filters | Q(status=models.Event.CONFIRMED) | Q(status=models.Event.BOOKED)
|
||||
|
||||
filter = filter & typeFilters & statusFilters
|
||||
filter = filter & type_filters & status_filters
|
||||
|
||||
return models.Event.objects.filter(filter).order_by('-start_date').select_related('person', 'organisation',
|
||||
events = models.Event.objects.filter(filter).order_by('-start_date').select_related('person', 'organisation',
|
||||
'venue', 'mic')
|
||||
|
||||
subhires = models.Subhire.objects.filter(status_filters).order_by('-start_date').select_related('person', 'organisation')
|
||||
|
||||
return list(chain(events, subhires))
|
||||
|
||||
def item_title(self, item):
|
||||
title = ''
|
||||
|
||||
@@ -106,30 +113,32 @@ class CalendarICS(ICalFeed):
|
||||
return item.latest_time
|
||||
|
||||
def item_location(self, item):
|
||||
return item.venue
|
||||
if hasattr(item, 'venue'):
|
||||
return item.venue
|
||||
return ""
|
||||
|
||||
def item_description(self, item):
|
||||
# Create a nice information-rich description
|
||||
# note: only making use of information available to "non-keyholders"
|
||||
|
||||
tz = pytz.timezone(self.timezone)
|
||||
|
||||
desc = f'Rig ID = {item.display_id}\n'
|
||||
desc += f'Event = {item.name}\n'
|
||||
desc += 'Venue = ' + (item.venue.name if item.venue else '---') + '\n'
|
||||
if hasattr(item, 'venue'):
|
||||
desc += 'Venue = ' + (item.venue.name if item.venue else '---') + '\n'
|
||||
if item.is_rig and item.person:
|
||||
desc += 'Client = ' + item.person.name + (
|
||||
(' for ' + item.organisation.name) if item.organisation else '') + '\n'
|
||||
desc += f'Status = {item.get_status_display()}\n'
|
||||
desc += 'MIC = ' + (item.mic.name if item.mic else '---') + '\n'
|
||||
if hasattr(item, 'mic'):
|
||||
desc += 'MIC = ' + (item.mic.name if item.mic else '---') + '\n'
|
||||
|
||||
desc += '\n'
|
||||
if item.meet_at:
|
||||
if hasattr(item, 'meet_at') and item.meet_at:
|
||||
desc += 'Crew Meet = ' + (
|
||||
item.meet_at.astimezone(tz).strftime('%Y-%m-%d %H:%M') if item.meet_at else '---') + '\n'
|
||||
if item.access_at:
|
||||
timezone.make_aware(item.meet_at).strftime('%Y-%m-%d %H:%M') if item.meet_at else '---') + '\n'
|
||||
if hasattr(item, 'access_at') and item.access_at:
|
||||
desc += 'Access At = ' + (
|
||||
item.access_at.astimezone(tz).strftime('%Y-%m-%d %H:%M') if item.access_at else '---') + '\n'
|
||||
timezone.make_aware(item.access_at).strftime('%Y-%m-%d %H:%M') if item.access_at else '---') + '\n'
|
||||
if item.start_date:
|
||||
desc += 'Event Start = ' + item.start_date.strftime('%Y-%m-%d') + (
|
||||
(' ' + item.start_time.strftime('%H:%M')) if item.has_start_time else '') + '\n'
|
||||
@@ -140,8 +149,6 @@ class CalendarICS(ICalFeed):
|
||||
desc += '\n'
|
||||
if item.description:
|
||||
desc += f'Event Description:\n{item.description}\n\n'
|
||||
# if item.notes: // Need to add proper keyholder checks before this gets put back
|
||||
# desc += 'Notes:\n'+item.notes+'\n\n'
|
||||
|
||||
desc += f'URL = https://rigs.nottinghamtec.co.uk{item.get_absolute_url()}'
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import copy
|
||||
import datetime
|
||||
import re
|
||||
import premailer
|
||||
import simplejson
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -12,9 +11,7 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import get_template
|
||||
from django.urls import reverse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -22,7 +19,7 @@ from django.views import generic
|
||||
|
||||
from PyRIGS import decorators
|
||||
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
|
||||
from RIGS import models, forms
|
||||
from RIGS import models, forms, utils
|
||||
|
||||
__author__ = 'ghost'
|
||||
|
||||
@@ -40,14 +37,25 @@ class RigboardIndex(generic.TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class WebCalendar(generic.TemplateView):
|
||||
class WebCalendar(generic.ListView):
|
||||
model = models.Event
|
||||
template_name = 'calendar.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['view'] = kwargs.get('view', '')
|
||||
context['date'] = kwargs.get('date', '')
|
||||
# context['page_title'] = "Calendar"
|
||||
# use today's date for the calendar
|
||||
d = utils.get_date(self.request.GET.get('month', None))
|
||||
context['prev_month'] = utils.prev_month(d)
|
||||
context['next_month'] = utils.next_month(d)
|
||||
|
||||
# Instantiate our calendar class with today's year and date
|
||||
cal = utils.Calendar(d.year, d.month)
|
||||
|
||||
# Call the formatmonth method, which returns our calendar as a table
|
||||
html_cal = cal.formatmonth(withyear=True)
|
||||
# context['calendar'] = mark_safe(html_cal)
|
||||
context['weeks'] = html_cal
|
||||
context['page_title'] = d.strftime("%B %Y")
|
||||
return context
|
||||
|
||||
|
||||
@@ -196,27 +204,7 @@ class EventArchive(generic.ListView):
|
||||
"Muppet! Check the dates, it has been fixed for you.")
|
||||
start, end = end, start # Stop the impending fail
|
||||
|
||||
filter = Q()
|
||||
if end != "":
|
||||
filter &= Q(start_date__lte=end)
|
||||
if start:
|
||||
filter &= Q(start_date__gte=start)
|
||||
|
||||
q = self.request.GET.get('q', "")
|
||||
objects = self.model.objects.all()
|
||||
|
||||
if q:
|
||||
objects = self.model.objects.search(q)
|
||||
|
||||
status = self.request.GET.getlist('status', "")
|
||||
|
||||
if len(status) > 0:
|
||||
filter &= Q(status__in=status)
|
||||
|
||||
qs = objects.filter(filter).order_by('-start_date')
|
||||
|
||||
# Preselect related for efficiency
|
||||
qs.select_related('person', 'organisation', 'venue', 'mic')
|
||||
qs = self.model.objects.event_search(self.request.GET.get('q', None), start, end, self.request.GET.get('status', ""))
|
||||
|
||||
if not qs.exists():
|
||||
messages.add_message(self.request, messages.WARNING, "No events have been found matching those criteria.")
|
||||
@@ -338,12 +326,12 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
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],
|
||||
reply_to=[self.request.user.email],
|
||||
)
|
||||
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()
|
||||
msg.attach_alternative(html, 'text/html')
|
||||
|
||||
@@ -353,7 +341,7 @@ class EventAuthorisationRequest(generic.FormView, generic.detail.SingleObjectMix
|
||||
|
||||
|
||||
class EventAuthoriseRequestEmailPreview(generic.DetailView):
|
||||
template_name = "eventauthorisation_client_request.html"
|
||||
template_name = "email/eventauthorisation_client_request.html"
|
||||
model = models.Event
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import generic
|
||||
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
|
||||
from django.db.models import Sum
|
||||
from PyRIGS.views import ModalURLMixin, get_related
|
||||
from RIGS import models, forms
|
||||
from RIGS.views import EventArchive
|
||||
|
||||
|
||||
class SubhireDetail(generic.DetailView, ModalURLMixin):
|
||||
@@ -46,3 +48,15 @@ class SubhireEdit(generic.UpdateView):
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('subhire_detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class SubhireList(EventArchive):
|
||||
template_name = 'subhire_list.html'
|
||||
model = models.Subhire
|
||||
paginate_by = 25
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['total_value'] = self.get_queryset().aggregate(sum=Sum('insurance_value'))['sum']
|
||||
context['page_title'] = "Subhire List"
|
||||
return context
|
||||
|
||||
@@ -105,8 +105,7 @@ class Command(BaseCommand):
|
||||
|
||||
for i in range(100):
|
||||
prefix = random.choice(asset_prefixes)
|
||||
asset_id = str(get_available_asset_id(wanted_prefix=prefix))
|
||||
asset_id = prefix + asset_id
|
||||
asset_id = get_available_asset_id(wanted_prefix=prefix)
|
||||
asset = models.Asset(
|
||||
asset_id=asset_id,
|
||||
description=random.choice(asset_description),
|
||||
|
||||
18
assets/migrations/0027_asset_nickname.py
Normal file
18
assets/migrations/0027_asset_nickname.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -95,14 +95,15 @@ class AssetManager(models.Manager):
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
if query is not None:
|
||||
or_lookup = (Q(asset_id__exact=query.upper()) | Q(description__icontains=query) | Q(serial_number__exact=query))
|
||||
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
|
||||
return qs
|
||||
|
||||
|
||||
def get_available_asset_id(wanted_prefix=""):
|
||||
last_asset = Asset.objects.filter(asset_id_prefix=wanted_prefix).last()
|
||||
return 9000 if last_asset is None else wanted_prefix + str(last_asset.asset_id_number + 1)
|
||||
last_asset_id = last_asset.asset_id_number if last_asset else 0
|
||||
return wanted_prefix + str(last_asset_id + 1)
|
||||
|
||||
|
||||
def validate_positive(value):
|
||||
@@ -125,6 +126,7 @@ class Asset(models.Model, RevisionMixin):
|
||||
purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
|
||||
replacement_cost = models.DecimalField(null=True, decimal_places=2, max_digits=10, validators=[validate_positive])
|
||||
comments = models.TextField(blank=True)
|
||||
nickname = models.CharField(max_length=120, blank=True)
|
||||
|
||||
# Audit
|
||||
last_audited_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<label for="{{ form.description.id_for_label }}">Description</label>
|
||||
{% render_field form.description|add_class:'form-control' value=object.description %}
|
||||
</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">
|
||||
<label for="{{ form.category.id_for_label }}" >Category</label>
|
||||
{% render_field form.category|add_class:'form-control'%}
|
||||
@@ -45,7 +49,10 @@
|
||||
{% else %}
|
||||
<dt>Asset ID</dt>
|
||||
<dd>{{ object.asset_id }}</dd>
|
||||
|
||||
{% if object.nickname %}
|
||||
<dt>Nickname</dt>
|
||||
<dd>"{{ object.nickname }}"</dd>
|
||||
{% endif %}
|
||||
<dt>Description</dt>
|
||||
<dd>{{ object.description }}</dd>
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ class DuplicateMixin:
|
||||
class AssetDuplicate(DuplicateMixin, AssetIDUrlMixin, AssetCreate):
|
||||
def get_initial(self, *args, **kwargs):
|
||||
initial = super().get_initial(*args, **kwargs)
|
||||
initial["asset_id"] = models.get_available_asset_id(wanted_prefix=self.get_object().asset_id_prefix)
|
||||
initial["asset_id"] = models.get_available_asset_id()
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -28,6 +28,7 @@ def admin_user(admin_user):
|
||||
admin_user.last_name = "Test"
|
||||
admin_user.initials = "ETU"
|
||||
admin_user.is_approved = True
|
||||
admin_user.is_supervisor = True
|
||||
admin_user.save()
|
||||
return admin_user
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ function fonts(done) {
|
||||
function styles(done) {
|
||||
const bs_select = ["bootstrap-select.css", "ajax-bootstrap-select.css"]
|
||||
return gulp.src(['pipeline/source_assets/scss/**/*.scss',
|
||||
'node_modules/fullcalendar/main.css',
|
||||
'node_modules/bootstrap-select/dist/css/bootstrap-select.css',
|
||||
'node_modules/ajax-bootstrap-select/dist/css/ajax-bootstrap-select.css',
|
||||
'node_modules/easymde/dist/easymde.min.css'
|
||||
@@ -59,7 +58,6 @@ function scripts() {
|
||||
'node_modules/html5sortable/dist/html5sortable.min.js',
|
||||
'node_modules/clipboard/dist/clipboard.min.js',
|
||||
'node_modules/moment/moment.js',
|
||||
'node_modules/fullcalendar/main.js',
|
||||
'node_modules/bootstrap-select/dist/js/bootstrap-select.js',
|
||||
'node_modules/ajax-bootstrap-select/dist/js/ajax-bootstrap-select.js',
|
||||
'node_modules/easymde/dist/easymde.min.js',
|
||||
|
||||
7103
package-lock.json
generated
7103
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@
|
||||
"clipboard": "^2.0.8",
|
||||
"cssnano": "^5.0.13",
|
||||
"easymde": "^2.16.1",
|
||||
"fullcalendar": "^5.10.1",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-flatten": "^0.4.0",
|
||||
@@ -27,14 +26,14 @@
|
||||
"html5sortable": "^0.13.3",
|
||||
"jquery": "^3.6.0",
|
||||
"konami": "^1.6.3",
|
||||
"moment": "^2.29.2",
|
||||
"node-sass": "^7.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"node-sass": "^7.0.3",
|
||||
"popper.js": "^1.16.1",
|
||||
"postcss": "^8.4.5",
|
||||
"uglify-js": "^3.14.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"browser-sync": "^2.27.7"
|
||||
"browser-sync": "^2.27.11"
|
||||
},
|
||||
"scripts": {
|
||||
"gulp": "gulp",
|
||||
|
||||
@@ -47,14 +47,16 @@ function initPicker(obj) {
|
||||
//log: 3,
|
||||
preprocessData: function (data) {
|
||||
var i, l = data.length, array = [];
|
||||
array.push({
|
||||
text: clearSelectionLabel,
|
||||
value: '',
|
||||
data:{
|
||||
update_url: '',
|
||||
subtext:''
|
||||
}
|
||||
});
|
||||
if (!obj.data('noclear')) {
|
||||
array.push({
|
||||
text: clearSelectionLabel,
|
||||
value: '',
|
||||
data:{
|
||||
update_url: '',
|
||||
subtext:''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (l) {
|
||||
for(i = 0; i < l; i++){
|
||||
@@ -71,11 +73,12 @@ function initPicker(obj) {
|
||||
return array;
|
||||
}
|
||||
};
|
||||
|
||||
obj.prepend($("<option></option>")
|
||||
.attr("value",'')
|
||||
.text(clearSelectionLabel)
|
||||
.data('update_url','')); //Add "clear selection" option
|
||||
if (!obj.data('noclear')) {
|
||||
obj.prepend($("<option></option>")
|
||||
.attr("value",'')
|
||||
.text(clearSelectionLabel)
|
||||
.data('update_url','')); //Add "clear selection" option
|
||||
}
|
||||
|
||||
|
||||
obj.selectpicker().ajaxSelectPicker(options); //Initiaise selectPicker
|
||||
|
||||
@@ -4,16 +4,16 @@ Date.prototype.getISOString = function () {
|
||||
var dd = this.getDate().toString();
|
||||
return yyyy + '-' + (mm[1] ? mm : "0" + mm[0]) + '-' + (dd[1] ? dd : "0" + dd[0]); // padding
|
||||
};
|
||||
jQuery(document).ready(function () {
|
||||
jQuery(document).on('click', '.modal-href', function (e) {
|
||||
$link = jQuery(this);
|
||||
$(document).ready(function () {
|
||||
$(document).on('click', '.modal-href', function (e) {
|
||||
$link = $(this);
|
||||
// Anti modal inception
|
||||
if ($link.parents('#modal').length == 0) {
|
||||
e.preventDefault();
|
||||
modaltarget = $link.data('target');
|
||||
modalobject = "";
|
||||
jQuery('#modal').load($link.attr('href'), function (e) {
|
||||
jQuery('#modal').modal();
|
||||
$('#modal').load($link.attr('href'), function (e) {
|
||||
$('#modal').modal();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -23,7 +23,6 @@ jQuery(document).ready(function () {
|
||||
s.type = 'text/javascript';
|
||||
document.body.appendChild(s);
|
||||
s.src = '{% static "js/asteroids.min.js"%}';
|
||||
ga('send', 'event', 'easter_egg', 'activated');
|
||||
}
|
||||
easter_egg.load();
|
||||
});
|
||||
|
||||
@@ -281,3 +281,7 @@ html.embedded {
|
||||
.bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light {
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
|
||||
.badge-purple, .bg-purple {
|
||||
background-color: #800080 !important;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{% load nice_errors from filters %}
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger alert-dismissable">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<div class="alert alert-danger mb-0">
|
||||
<dl>
|
||||
{% with form|nice_errors as qq %}
|
||||
{% for error_name,desc in qq.items %}
|
||||
<span class="row">
|
||||
<dt class="col-4">{{error_name}}</dt>
|
||||
<dd class="col-8">{{desc}}</dd>
|
||||
<dt class="col-3">{{error_name}}</dt>
|
||||
<dd class="col-9">{{desc}}</dd>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<a href="{% url target pk %}" class="btn {{ class }}" {% if id %}id="{{id}}"{%endif%} {% if style %}style="{{style}}"{%endif%} {% if text == 'Print' %}target="_blank"{%endif%}><span class="fas {{ icon }} align-middle"></span> <span class="d-none d-sm-inline align-middle">{{ text }}</span></a>
|
||||
{% elif copy %}
|
||||
<button class="btn btn-secondary btn-sm mr-1" data-clipboard-target="{{id}}" data-content="Copied to clipboard!"><span class="fas fa-copy"></span></button>
|
||||
{% elif today %}
|
||||
<button class="btn btn-info col-sm-2" onclick="var date = new Date(); $('#{{id}}').val([date.getFullYear(), ('0' + (date.getMonth()+1)).slice(-2), ('0' + date.getDate()).slice(-2)].join('-'))" tabindex="-1" type="button">Today</button>
|
||||
{% else %}
|
||||
<a href="{% url target %}" class="btn {{ class }}" {% if id %}id="{{id}}"{%endif%} {% if style %}style="{{style}}"{%endif%}><span class="fas {{ icon }} align-middle"></span> <span class="d-none d-sm-inline align-middle">{{ text }}</span></a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{% load widget_tweaks %}
|
||||
{% include 'form_errors.html' %}
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-info">
|
||||
<p><strong>Please note:</strong> If it has been more than a year since you last logged in, your account will have been automatically deactivated. Contact <a href="mailto:it@nottinghamtec.co.uk">it@nottinghamtec.co.uk</a> for assistance.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-6 offset-sm-3 col-lg-4 offset-lg-4">
|
||||
<form action="{% url 'login' %}" method="post" role="form" target="_self">{% csrf_token %}
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PyRIGS.decorators import user_passes_test_with_403
|
||||
|
||||
|
||||
def has_perm_or_supervisor(perm, login_url=None, oembed_view=None):
|
||||
return user_passes_test_with_403(lambda u: (hasattr(u, 'is_supervisor') and u.is_supervisor) or u.has_perm(perm), login_url=login_url, oembed_view=oembed_view)
|
||||
def is_supervisor(login_url=None, oembed_view=None):
|
||||
return user_passes_test_with_403(lambda u: (hasattr(u, 'is_supervisor') and u.is_supervisor))
|
||||
|
||||
@@ -143,6 +143,15 @@ class Command(BaseCommand):
|
||||
"Bin Diving",
|
||||
"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):
|
||||
category = random.choice(self.categories)
|
||||
previous_item = models.TrainingItem.objects.filter(category=category).last()
|
||||
@@ -150,7 +159,7 @@ class Command(BaseCommand):
|
||||
number = previous_item.reference_number + 1
|
||||
else:
|
||||
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)
|
||||
|
||||
def setup_levels(self):
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
18
training/migrations/0007_trainingitem_description.py
Normal file
18
training/migrations/0007_trainingitem_description.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -85,7 +85,7 @@ class TrainingItemManager(QueryablePropertiesManager):
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset()
|
||||
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
|
||||
return qs
|
||||
|
||||
@@ -94,16 +94,13 @@ class TrainingItemManager(QueryablePropertiesManager):
|
||||
class TrainingItem(models.Model):
|
||||
reference_number = models.IntegerField()
|
||||
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)
|
||||
prerequisites = models.ManyToManyField('self', symmetrical=False, blank=True)
|
||||
|
||||
objects = TrainingItemManager()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return str(self)
|
||||
|
||||
@queryable_property
|
||||
def display_id(self):
|
||||
return f"{self.category.reference_number}.{self.reference_number}"
|
||||
@@ -121,7 +118,7 @@ class TrainingItem(models.Model):
|
||||
return models.Q()
|
||||
|
||||
def __str__(self):
|
||||
name = f"{self.display_id} {self.description}"
|
||||
name = f"{self.display_id} {self.name}"
|
||||
if not self.active:
|
||||
name += " (inactive)"
|
||||
return name
|
||||
@@ -149,7 +146,7 @@ class TrainingItemQualificationManager(QueryablePropertiesManager):
|
||||
def search(self, query=None):
|
||||
qs = self.get_queryset().select_related('item', 'supervisor', 'item__category')
|
||||
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:
|
||||
or_lookup = Q(item__category__reference_number=int(query)) | or_lookup
|
||||
@@ -201,7 +198,7 @@ class TrainingItemQualification(models.Model, RevisionMixin):
|
||||
|
||||
@property
|
||||
def activity_feed_string(self):
|
||||
return f"{self.trainee} {self.get_depth_display().lower()} {self.get_depth_display()} in {self.item}"
|
||||
return f"{self.trainee} {self.get_depth_display().lower()} in {self.item}"
|
||||
|
||||
@classmethod
|
||||
def get_colour_from_depth(cls, depth):
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<a class="dropdown-item" href="{% url 'item_list' %}"><span class="fas fa-sitemap"></span> Item List</a>
|
||||
</div>
|
||||
</li>
|
||||
{% if perms.training.add_trainingitemqualification or request.user.is_supervisor %}
|
||||
{% if request.user.is_supervisor %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'session_log' %}"><span class="fas fa-users"></span> Log Session</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'training_activity_table' %}"><span class="fas fa-random"></span> Recent Changes</a></li>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{% render_field form.date|add_class:'form-control'|attr:'type="date"' value=training_date %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<button class="btn btn-info col-sm-2" onclick="var date = new Date(); $('#id_date').val([date.getFullYear(), ('0' + (date.getMonth()+1)).slice(-2), ('0' + date.getDate()).slice(-2)].join('-'))" tabindex="-1" type="button">Today</button>
|
||||
{% button 'today' id='id_date' %}
|
||||
</div>
|
||||
<div class="form-group form-row">
|
||||
<label for="id_notes" class="col-sm-2 col-form-label">Notes</label>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{% extends 'base_training.html' %}
|
||||
|
||||
{% load button from filters %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-12 text-right py-2 pr-0">
|
||||
{% button 'print' 'item_list_export' %}
|
||||
</div>
|
||||
<div id="accordion">
|
||||
{% for category in categories %}
|
||||
<div class="card">
|
||||
@@ -13,7 +18,8 @@
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush">
|
||||
{% 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 %}
|
||||
<div class="ml-3 font-italic">
|
||||
<p class="text-info mb-0">Passed Out Prerequisites:</p>
|
||||
@@ -24,7 +30,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url 'item_qualification' item.pk %}" class="btn btn-info"><span class="fas fa-user"></span> Qualified Users</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
25
training/templates/item_list.xml
Normal file
25
training/templates/item_list.xml
Normal 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 %}
|
||||
<namedString id="lastPage"><pageNumber/></namedString>
|
||||
{% endblock %}
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if request.user.is_supervisor or perms.training.add_traininglevelrequirement %}
|
||||
{% if request.user.is_supervisor %}
|
||||
<div class="col-sm-12 text-right pr-0">
|
||||
<a type="button" class="btn btn-success mb-3" href="{% url 'add_requirement' pk=object.pk %}" id="requirement_button">
|
||||
<span class="fas fa-plus"></span> Add New Requirement
|
||||
@@ -79,9 +79,9 @@
|
||||
{% endfor %}
|
||||
<tr><th colspan="3" class="text-center">{{object}}</th></tr>
|
||||
<tr>
|
||||
<td><ul class="list-unstyled">{% for req in object.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
<td><ul class="list-unstyled">{% for req in object.complete_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 1 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
<td><ul class="list-unstyled">{% for req in object.passed_out_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 2 %} {% if request.user.is_supervisor or perms.training.delete_traininglevelrequirement %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline"" href="{% url 'remove_requirement' pk=req.pk %}" title="Delete requirement"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
<td><ul class="list-unstyled">{% for req in object.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
<td><ul class="list-unstyled">{% for req in object.complete_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 1 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline" href="{% url 'remove_requirement' pk=req.pk %}"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
<td><ul class="list-unstyled">{% for req in object.passed_out_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 2 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline"" href="{% url 'remove_requirement' pk=req.pk %}" title="Delete requirement"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% if request.user.is_supervisor or perms.training.add_trainingitemqualification %}
|
||||
{% if request.user.is_supervisor %}
|
||||
<a type="button" class="btn btn-success" href="{% url 'add_qualification' object.pk %}" id="add_record">
|
||||
<span class="fas fa-plus"></span> Add New Training Record
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<label for="supervisor" class="col-sm-2 col-form-label">Supervisor</label>
|
||||
<select name="supervisor" id="supervisor_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" required>
|
||||
<select name="supervisor" id="supervisor_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" required data-noclear="true">
|
||||
{% if supervisor %}
|
||||
<option value="{{form.supervisor.value}}" selected>{{ supervisor }}</option>
|
||||
{% endif %}
|
||||
|
||||
@@ -28,25 +28,26 @@
|
||||
{% include 'form_errors.html' %}
|
||||
{% csrf_token %}
|
||||
<h3>People</h3>
|
||||
<div class="form-group row">
|
||||
<div class="form-group row" id="supervisor_group">
|
||||
{% include 'partials/supervisor_field.html' %}
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="form-group row" id="trainees_group">
|
||||
<label for="trainees_id" class="col-sm-2">Select Attendees</label>
|
||||
<select multiple name="trainees" id="trainees_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials">
|
||||
<select multiple name="trainees" id="trainees_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" data-noclear="true">
|
||||
</select>
|
||||
</div>
|
||||
<h3>Training Items</h3>
|
||||
{% for depth in depths %}
|
||||
<div class="form-group row">
|
||||
<div class="form-group row" id="{{depth.0}}">
|
||||
<label for="selectpicker" class="col-sm-2 text-{% colour_from_depth depth.0 %} py-1">{{ depth.1 }} Items</label>
|
||||
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active">
|
||||
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" data-noclear="true">
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<h3>Session Information</h3>
|
||||
<div class="form-group">
|
||||
{% include 'partials/form_field.html' with field=form.date %}
|
||||
<div class="form-group row">
|
||||
{% include 'partials/form_field.html' with field=form.date col='col-sm-6' %}
|
||||
{% button 'today' id='id_date' %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{% include 'partials/form_field.html' with field=form.notes %}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<th>Date</th>
|
||||
<th>Supervisor</th>
|
||||
<th>Notes</th>
|
||||
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
|
||||
{% if request.user.is_supervisor %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
@@ -67,7 +67,7 @@
|
||||
<td>{{ object.date }}</td>
|
||||
<td><a href="{{ object.supervisor.get_absolute_url}}">{{ object.supervisor }}</a></td>
|
||||
<td>{{ object.notes }}</td>
|
||||
{% if request.user.is_supervisor or perms.training.change_trainingitemqualification %}
|
||||
{% if request.user.is_supervisor %}
|
||||
<td>{% button 'edit' 'edit_qualification' object.pk id="edit" %}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
||||
@@ -30,6 +30,15 @@ def training_item(db):
|
||||
training_item.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def training_item_2(db):
|
||||
training_category = models.TrainingCategory.objects.create(reference_number=2, name="Sound")
|
||||
training_item = models.TrainingItem.objects.create(category=training_category, reference_number=1, description="Fundamentals of Audio")
|
||||
yield training_item
|
||||
training_category.delete()
|
||||
training_item.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def level(db):
|
||||
level = models.TrainingLevel.objects.create(description="There is no description.", level=models.TrainingLevel.TECHNICIAN)
|
||||
|
||||
@@ -40,3 +40,42 @@ class AddQualification(FormPage):
|
||||
@property
|
||||
def success(self):
|
||||
return 'add' not in self.driver.current_url
|
||||
|
||||
|
||||
class SessionLog(FormPage):
|
||||
URL_TEMPLATE = 'training/session_log'
|
||||
|
||||
_supervisor_selector = (By.CSS_SELECTOR, 'div#supervisor_group>div.bootstrap-select')
|
||||
_trainees_selector = (By.CSS_SELECTOR, 'div#trainees_group>div.bootstrap-select')
|
||||
_training_started_selector = (By.XPATH, '//div[1]/div/div/form/div[4]/div')
|
||||
_training_complete_selector = (By.XPATH, '//div[1]/div/div/form/div[4]/div')
|
||||
_competency_assessed_selector = (By.XPATH, '//div[1]/div/div/form/div[5]/div')
|
||||
|
||||
form_items = {
|
||||
'date': (regions.DatePicker, (By.ID, 'id_date')),
|
||||
'notes': (regions.TextBox, (By.ID, 'id_notes')),
|
||||
}
|
||||
|
||||
@property
|
||||
def supervisor_selector(self):
|
||||
return regions.BootstrapSelectElement(self, self.find_element(*self._supervisor_selector))
|
||||
|
||||
@property
|
||||
def trainees_selector(self):
|
||||
return regions.BootstrapSelectElement(self, self.find_element(*self._trainees_selector))
|
||||
|
||||
@property
|
||||
def training_started_selector(self):
|
||||
return regions.BootstrapSelectElement(self, self.find_element(*self._training_started_selector))
|
||||
|
||||
@property
|
||||
def training_complete_selector(self):
|
||||
return regions.BootstrapSelectElement(self, self.find_element(*self._training_complete_selector))
|
||||
|
||||
@property
|
||||
def competency_assessed_selector(self):
|
||||
return regions.BootstrapSelectElement(self, self.find_element(*self._competency_assessed_selector))
|
||||
|
||||
@property
|
||||
def success(self):
|
||||
return 'log' not in self.driver.current_url
|
||||
|
||||
@@ -12,6 +12,15 @@ from training import models
|
||||
from training.tests import pages
|
||||
|
||||
|
||||
def select_super(page, supervisor):
|
||||
page.supervisor_selector.toggle()
|
||||
assert page.supervisor_selector.is_open
|
||||
page.supervisor_selector.search(supervisor.name[:-6])
|
||||
time.sleep(2) # Slow down for javascript
|
||||
assert page.supervisor_selector.options[0].selected
|
||||
page.supervisor_selector.toggle()
|
||||
|
||||
|
||||
def test_add_qualification(logged_in_browser, live_server, trainee, supervisor, training_item):
|
||||
page = pages.AddQualification(logged_in_browser.driver, live_server.url, pk=trainee.pk).open()
|
||||
# assert page.name in str(trainee)
|
||||
@@ -30,12 +39,7 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
|
||||
assert page.item_selector.options[0].selected
|
||||
page.item_selector.toggle()
|
||||
|
||||
page.supervisor_selector.toggle()
|
||||
assert page.supervisor_selector.is_open
|
||||
page.supervisor_selector.search(supervisor.name[:-6])
|
||||
time.sleep(2) # Slow down for javascript
|
||||
assert page.supervisor_selector.options[0].selected
|
||||
page.supervisor_selector.toggle()
|
||||
select_super(page, supervisor)
|
||||
|
||||
page.submit()
|
||||
assert page.success
|
||||
@@ -44,3 +48,32 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
|
||||
assert qualification.date == date
|
||||
assert qualification.notes == "A note"
|
||||
assert qualification.depth == models.TrainingItemQualification.STARTED
|
||||
|
||||
|
||||
def test_session_log(logged_in_browser, live_server, trainee, supervisor, training_item, training_item_2):
|
||||
page = pages.SessionLog(logged_in_browser.driver, live_server.url).open()
|
||||
|
||||
page.date = date = datetime.date(2001, 1, 10)
|
||||
page.notes = note = "A general note"
|
||||
|
||||
time.sleep(2) # Slow down for javascript
|
||||
|
||||
select_super(page, supervisor)
|
||||
|
||||
page.trainees_selector.toggle()
|
||||
assert page.trainees_selector.is_open
|
||||
page.trainees_selector.search(trainee.first_name)
|
||||
time.sleep(2) # Slow down for javascript
|
||||
page.trainees_selector.set_option(trainee.name, True)
|
||||
# assert page.trainees_selector.options[0].selected
|
||||
page.trainees_selector.toggle()
|
||||
|
||||
page.training_started_selector.toggle()
|
||||
assert page.training_started_selector.is_open
|
||||
page.training_started_selector.search(training_item.description[:-2])
|
||||
time.sleep(2) # Slow down for javascript
|
||||
# assert page.training_started_selector.options[0].selected
|
||||
page.training_started_selector.toggle()
|
||||
|
||||
page.submit()
|
||||
assert page.success
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_add_qualification(admin_client, trainee, admin_user, training_item):
|
||||
response = admin_client.post(url, {'date': date, 'trainee': trainee.pk, 'supervisor': trainee.pk, 'item': training_item.pk})
|
||||
assertFormError(response, 'form', 'date', 'Qualification date may not be in the future')
|
||||
assertFormError(response, 'form', 'supervisor', 'One may not supervise oneself...')
|
||||
response = admin_client.post(url, {'date': date, 'trainee': trainee.pk, 'supervisor': admin_user.pk, 'item': training_item.pk})
|
||||
response = admin_client.post(url, {'date': date, 'trainee': admin_user.pk, 'supervisor': trainee.pk, 'item': training_item.pk})
|
||||
print(response.content)
|
||||
assertFormError(response, 'form', 'supervisor', 'Selected supervisor must actually *be* a supervisor...')
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
from django.urls import path
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from training.decorators import has_perm_or_supervisor
|
||||
from training.decorators import is_supervisor
|
||||
|
||||
from training import views, models
|
||||
from versioning.views import VersionHistory
|
||||
|
||||
urlpatterns = [
|
||||
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('trainee/list/', login_required(views.TraineeList.as_view()), name='trainee_list'),
|
||||
path('trainee/<int:pk>/',
|
||||
has_perm_or_supervisor('RIGS.view_profile')(views.TraineeDetail.as_view()),
|
||||
path('trainee/<int:pk>/', login_required(views.TraineeDetail.as_view()),
|
||||
name='trainee_detail'),
|
||||
path('trainee/<int:pk>/history', has_perm_or_supervisor('RIGS.view_profile')(VersionHistory.as_view()), name='trainee_history', kwargs={'model': models.Trainee, 'app': 'training'}), # Not picked up automatically because proxy model (I think)
|
||||
path('trainee/<int:pk>/add_qualification/', has_perm_or_supervisor('training.add_trainingitemqualification')(views.AddQualification.as_view()),
|
||||
path('trainee/<int:pk>/history', 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/', is_supervisor()(views.AddQualification.as_view()),
|
||||
name='add_qualification'),
|
||||
path('trainee/edit_qualification/<int:pk>/', has_perm_or_supervisor('training.change_trainingitemqualification')(views.EditQualification.as_view()),
|
||||
path('trainee/edit_qualification/<int:pk>/', is_supervisor()(views.EditQualification.as_view()),
|
||||
name='edit_qualification'),
|
||||
|
||||
path('levels/', login_required(views.LevelList.as_view()), name='level_list'),
|
||||
path('level/<int:pk>/', login_required(views.LevelDetail.as_view()), name='level_detail'),
|
||||
path('level/<int:pk>/user/<int:u>/', login_required(views.LevelDetail.as_view()), name='level_detail'),
|
||||
path('level/<int:pk>/add_requirement/', login_required(views.AddLevelRequirement.as_view()), name='add_requirement'),
|
||||
path('level/remove_requirement/<int:pk>/', login_required(views.RemoveRequirement.as_view()), name='remove_requirement'),
|
||||
path('level/<int:pk>/add_requirement/', is_supervisor()(views.AddLevelRequirement.as_view()), name='add_requirement'),
|
||||
path('level/remove_requirement/<int:pk>/', is_supervisor()(views.RemoveRequirement.as_view()), name='remove_requirement'),
|
||||
|
||||
path('trainee/<int:pk>/level/<int:level_pk>/confirm', login_required(views.ConfirmLevel.as_view()), name='confirm_level'),
|
||||
path('trainee/<int:pk>/level/<int:level_pk>/confirm', is_supervisor()(views.ConfirmLevel.as_view()), name='confirm_level'),
|
||||
path('trainee/<int:pk>/item_record', login_required(views.TraineeItemDetail.as_view()), name='trainee_item_detail'),
|
||||
|
||||
path('session_log', has_perm_or_supervisor('training.add_trainingitemqualification')(views.SessionLog.as_view()), name='session_log'),
|
||||
path('session_log', is_supervisor()(views.SessionLog.as_view()), name='session_log'),
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.db import transaction
|
||||
from django.db.models import Q, Count
|
||||
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 users import views
|
||||
from reversion.views import RevisionMixin
|
||||
@@ -24,6 +24,17 @@ class ItemList(generic.ListView):
|
||||
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'] = "TrainingItemList.pdf"
|
||||
context["categories"] = models.TrainingCategory.objects.all()
|
||||
return context
|
||||
|
||||
|
||||
class TraineeDetail(views.ProfileDetail):
|
||||
template_name = "trainee_detail.html"
|
||||
model = models.Trainee
|
||||
|
||||
26
users/management/commands/usercleanup.py
Normal file
26
users/management/commands/usercleanup.py
Normal 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()
|
||||
@@ -118,6 +118,11 @@
|
||||
<input type="checkbox" value="dry-hire" data-default="true" checked> Dry-Hires
|
||||
</label>
|
||||
<label class="checkbox-inline mx-lg-2">
|
||||
<input type="checkbox" value="subhire" data-default="false"> Subhires
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group d-flex flex-column flex-lg-row">
|
||||
<label class="checkbox-inline mr-lg-2">
|
||||
<input type="checkbox" value="cancelled" data-default="false" > Cancelled
|
||||
</label>
|
||||
<label class="checkbox-inline mx-lg-2">
|
||||
|
||||
@@ -183,7 +183,7 @@ class ModelComparison:
|
||||
def name(self):
|
||||
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
|
||||
else:
|
||||
return str(obj)
|
||||
|
||||
Reference in New Issue
Block a user