Compare commits

...

8 Commits

Author SHA1 Message Date
948a41f43a Initial work on associating events with subhires 2022-10-20 12:56:42 +01:00
4449efcced Add subhire detail view 2022-10-20 00:00:57 +01:00
8b0cd13159 Initially create subhire model and form 2022-10-15 19:09:51 +01:00
e7e760de2e Fix copy to clipboard buttons on authorisation request form 2022-10-15 17:58:07 +01:00
9091197639 Minor audit fix 2022-06-02 15:36:53 +01:00
4f4baa62c1 Fix tests 2022-05-30 22:33:55 +01:00
b9f8621e1a Migrations fix 2022-05-26 16:23:51 +01:00
4b1dc37a7f Rename 'salvage value' to 'replacement cost'
This more accurately reflects historical use of the field, and what the insurers actually want. Ref #439
2022-05-26 10:29:53 +01:00
26 changed files with 905 additions and 853 deletions

16
Pipfile
View File

@@ -11,7 +11,7 @@ asgiref = "~=3.3.1"
beautifulsoup4 = "~=4.9.3"
Brotli = "~=1.0.9"
cachetools = "~=4.2.1"
certifi = "~=2020.12.5"
certifi = "*"
chardet = "~=4.0.0"
configparser = "~=5.0.1"
contextlib2 = "~=0.6.0.post1"
@@ -33,7 +33,7 @@ envparse = "~=0.2.0"
gunicorn = "~=20.0.4"
icalendar = "~=4.0.7"
idna = "~=2.10"
lxml = "~=4.7.1"
lxml = "*"
Markdown = "~=3.3.3"
msgpack = "~=1.0.2"
pep517 = "~=0.9.1"
@@ -45,7 +45,6 @@ psycopg2 = "~=2.8.6"
Pygments = "~=2.7.4"
pyparsing = "~=2.4.7"
PyPDF2 = "~=1.27.5"
PyPOM = "~=2.2.0"
python-dateutil = "~=2.8.1"
pytoml = "~=0.1.21"
pytz = "~=2020.5"
@@ -94,14 +93,11 @@ pluggy = "*"
pytest-splinter = "*"
pytest = "*"
pytest-reverse = "*"
pytest-xdist = {extras = [ "psutil",], version = "*"}
PyPOM = {extras = [ "splinter",], version = "*"}
[requires]
python_version = "3.9"
[dev-packages.pytest-xdist]
extras = [ "psutil",]
version = "*"
[dev-packages.PyPOM]
extras = [ "splinter",]
version = "*"
[pipenv]
allow_prereleases = true

1065
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -304,8 +304,29 @@ class EventManager(models.Manager):
return qs
@reversion.register(follow=['items'])
class Event(models.Model, RevisionMixin):
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
@@ -321,31 +342,85 @@ class Event(models.Model, RevisionMixin):
name = models.CharField(max_length=255)
person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE)
organisation = models.ForeignKey('Organisation', blank=True, null=True, on_delete=models.CASCADE)
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
description = models.TextField(blank=True, default='')
notes = models.TextField(blank=True, default='')
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL)
dry_hire = models.BooleanField(default=False)
is_rig = models.BooleanField(default=True)
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
null=True)
# Timing
start_date = models.DateField()
start_time = models.TimeField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
end_time = models.TimeField(blank=True, null=True)
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
class Meta:
abstract = True
@property
def cancelled(self):
return (self.status == self.CANCELLED)
@property
def confirmed(self):
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
@property
def has_start_time(self):
return self.start_time is not None
@property
def has_end_time(self):
return self.end_time is not None
@property
def latest_time(self):
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
tz = pytz.timezone(settings.TIME_ZONE)
endDate = self.end_date
if endDate is None:
endDate = self.start_date
if self.has_end_time:
endDateTime = datetime.datetime.combine(endDate, self.end_time)
tz = pytz.timezone(settings.TIME_ZONE)
endDateTime = tz.localize(endDateTime)
return endDateTime
else:
return endDate
def clean(self):
errdict = {}
if self.end_date and self.start_date > self.end_date:
errdict['end_date'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
startEndSameDay = not self.end_date or self.end_date == self.start_date
hasStartAndEnd = self.has_start_time and self.has_end_time
if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time:
errdict['end_time'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.']
return errdict
@reversion.register(follow=['items'])
class Event(BaseEvent):
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
verbose_name="MIC", on_delete=models.CASCADE)
venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE)
notes = models.TextField(blank=True, default='')
dry_hire = models.BooleanField(default=False)
is_rig = models.BooleanField(default=True)
based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True,
null=True)
access_at = models.DateTimeField(blank=True, null=True)
meet_at = models.DateTimeField(blank=True, null=True)
# Crew management
# 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)
mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True,
verbose_name="MIC", on_delete=models.CASCADE)
# Monies
purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO')
collector = models.CharField(max_length=255, blank=True, default='', verbose_name='collected by')
# Authorisation request details
@@ -395,26 +470,10 @@ class Event(models.Model, RevisionMixin):
def total(self):
return Decimal(self.sum_total + self.vat).quantize(Decimal('.01'))
@property
def cancelled(self):
return (self.status == self.CANCELLED)
@property
def confirmed(self):
return (self.status == self.BOOKED or self.status == self.CONFIRMED)
@property
def hs_done(self):
return self.riskassessment is not None and len(self.checklists.all()) > 0
@property
def has_start_time(self):
return self.start_time is not None
@property
def has_end_time(self):
return self.end_time is not None
@property
def earliest_time(self):
"""Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
@@ -428,45 +487,10 @@ class Event(models.Model, RevisionMixin):
if self.meet_at:
datetime_list.append(self.meet_at)
# If there is no start time defined, pretend it's midnight
startTimeFaked = False
if self.has_start_time:
startDateTime = datetime.datetime.combine(self.start_date, self.start_time)
else:
startDateTime = datetime.datetime.combine(self.start_date, datetime.time(00, 00))
startTimeFaked = True
# timezoneIssues - apply the default timezone to the naiive datetime
tz = pytz.timezone(settings.TIME_ZONE)
startDateTime = tz.localize(startDateTime)
datetime_list.append(startDateTime) # then add it to the list
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
# if we faked it & it's the earliest, better own up
if startTimeFaked and earliest == startDateTime:
return self.start_date
earliest = find_earliest_event_time(self, datetime_list)
return earliest
@property
def latest_time(self):
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
tz = pytz.timezone(settings.TIME_ZONE)
endDate = self.end_date
if endDate is None:
endDate = self.start_date
if self.has_end_time:
endDateTime = datetime.datetime.combine(endDate, self.end_time)
tz = pytz.timezone(settings.TIME_ZONE)
endDateTime = tz.localize(endDateTime)
return endDateTime
else:
return endDate
@property
def internal(self):
return bool(self.organisation and self.organisation.union_account)
@@ -487,14 +511,7 @@ class Event(models.Model, RevisionMixin):
return f"{self.display_id}: {self.name}"
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.']
errdict = super.clean()
if self.access_at is not None:
if self.access_at.date() > self.start_date:
@@ -555,6 +572,16 @@ class EventAuthorisation(models.Model, RevisionMixin):
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,10 +61,6 @@ class EventDetail(generic.DetailView, ModalURLMixin):
if self.object.dry_hire:
title += " <span class='badge badge-secondary'>Dry Hire</span>"
context['page_title'] = title
if is_ajax(self.request):
context['override'] = "base_ajax.html"
else:
context['override'] = 'base_assets.html'
return context

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

@@ -0,0 +1,48 @@
from django.urls import reverse_lazy
from django.views import generic
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
from RIGS import models, forms
class SubhireDetail(generic.DetailView, ModalURLMixin):
template_name = 'subhire_detail.html'
model = models.Subhire
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = f"{self.object.display_id} | {self.object.name}"
return context
class SubhireCreate(generic.CreateView):
model = models.Subhire
form_class = forms.SubhireForm
template_name = 'subhire_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = "New Subhire"
context['edit'] = True
form = context['form']
get_related(form, context)
return context
def get_success_url(self):
return reverse_lazy('subhire_detail', kwargs={'pk': self.object.pk})
class SubhireEdit(generic.UpdateView):
model = models.Subhire
form_class = forms.SubhireForm
template_name = 'subhire_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['page_title'] = f"Edit Subhire: {self.object.display_id} | {self.object.name}"
context['edit'] = True
form = context['form']
get_related(form, context)
return context
def get_success_url(self):
return reverse_lazy('subhire_detail', kwargs={'pk': self.object.pk})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
package-lock.json generated
View File

@@ -5,6 +5,7 @@
"requires": true,
"packages": {
"": {
"name": "PyRIGS",
"version": "1.0.0",
"license": "Custom",
"dependencies": {