Compare commits

..

11 Commits

Author SHA1 Message Date
90ae87b1b2 Implement some suggestions from the Doctor 2023-05-20 17:02:33 +01:00
0b22669a29 Default power MIC to MIC 2023-05-20 16:41:35 +01:00
020d43d5f8 Do not set power plan field to required on RA
"This might be a problem if the risk assessment is being done by one person and the power plan by another."
2023-05-20 16:39:21 +01:00
b4c1ada05c Add link to create power test from EC detail 2023-05-20 16:28:04 +01:00
aff911493f Fix population of initial venue values for EC/PT 2023-05-20 16:25:48 +01:00
020b08f9b0 Default venue to event venue in EC/PT 2023-05-20 15:24:24 +01:00
aae9a45d82 Allow a higher value in PSSC fields 2023-05-20 15:20:34 +01:00
773c07af81 Add units to power test record detail and form
I'm a bad scientist (coz I'm an engineer)
2023-05-20 10:40:22 +00:00
0cbba9d436 Return user to current page when clicking 'mark reviewed' 2023-05-19 18:20:49 +00:00
d47322a959 Update to target Python 3.10 2023-05-19 18:20:44 +00:00
74b8948daa Add absolute URL to power tests 2023-05-19 18:08:35 +00:00
57 changed files with 7513 additions and 3666 deletions

View File

@@ -12,11 +12,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PYTHONDONTWRITEBYTECODE: 1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9
cache: 'pipenv' cache: 'pipenv'
@@ -27,7 +26,7 @@ jobs:
# if: steps.pcache.outputs.cache-hit != 'true' # if: steps.pcache.outputs.cache-hit != 'true'
- name: Cache Static Files - name: Cache Static Files
id: static-cache id: static-cache
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: 'pipeline/built_assets' path: 'pipeline/built_assets'
key: ${{ hashFiles('package-lock.json') }}-${{ hashFiles('pipeline/source_assets') }} key: ${{ hashFiles('package-lock.json') }}-${{ hashFiles('pipeline/source_assets') }}
@@ -42,8 +41,8 @@ jobs:
pipenv run python3 manage.py makemigrations --check --dry-run pipenv run python3 manage.py makemigrations --check --dry-run
pipenv run python3 manage.py collectstatic --noinput pipenv run python3 manage.py collectstatic --noinput
- name: Run Tests - name: Run Tests
run: pipenv run pytest -n auto --cov run: pipenv run pytest -n auto -vv --cov
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v2
if: failure() if: failure()
with: with:
name: failure-screenshots ${{ matrix.test-group }} name: failure-screenshots ${{ matrix.test-group }}

18
Pipfile
View File

@@ -28,18 +28,18 @@ django-reversion = "~=3.0.9"
django-widget-tweaks = "~=1.4.8" django-widget-tweaks = "~=1.4.8"
django-htmlmin = "~=0.11.0" django-htmlmin = "~=0.11.0"
envparse = "*" envparse = "*"
gunicorn = "~=22.0.0" gunicorn = "~=20.0.4"
icalendar = "~=4.0.7" icalendar = "~=4.0.7"
idna = "~=3.7" idna = "~=2.10"
Markdown = "~=3.3.3" Markdown = "~=3.3.3"
msgpack = "~=1.0.2" msgpack = "~=1.0.2"
pep517 = "~=0.9.1" pep517 = "~=0.9.1"
Pillow = "~=10.0.1" Pillow = "~=9.3.0"
premailer = "~=3.7.0" premailer = "~=3.7.0"
progress = "~=1.5" progress = "~=1.5"
psutil = "~=5.8.0" psutil = "~=5.8.0"
psycopg2 = "~=2.8.6" psycopg2 = "~=2.8.6"
Pygments = "~=2.15.0" Pygments = "~=2.7.4"
pyparsing = "~=2.4.7" pyparsing = "~=2.4.7"
PyPDF2 = "~=1.27.5" PyPDF2 = "~=1.27.5"
PyPOM = "~=2.2.4" PyPOM = "~=2.2.4"
@@ -47,20 +47,20 @@ python-dateutil = "~=2.8.1"
pytoml = "~=0.1.21" pytoml = "~=0.1.21"
pytz = "~=2020.5" pytz = "~=2020.5"
reportlab = "*" reportlab = "*"
requests = "~=2.32.0" requests = "~=2.25.1"
retrying = "~=1.3.3" retrying = "~=1.3.3"
simplejson = "~=3.17.2" simplejson = "~=3.17.2"
six = "~=1.15.0" six = "~=1.15.0"
soupsieve = "~=2.1" soupsieve = "~=2.1"
sqlparse = "~=0.5.0" sqlparse = "~=0.4.2"
static3 = "~=0.7.0" static3 = "~=0.7.0"
svg2rlg = "~=0.3" svg2rlg = "~=0.3"
tini = "~=3.0.1" tini = "~=3.0.1"
tornado = "~=6.3" tornado = "~=6.1"
urllib3 = "~=1.26.19" urllib3 = "~=1.26.5"
whitenoise = "~=5.2.0" whitenoise = "~=5.2.0"
yolk = "~=0.4.3" yolk = "~=0.4.3"
zipp = "~=3.19.1" zipp = "~=3.4.0"
"zope.component" = "~=4.6.2" "zope.component" = "~=4.6.2"
"zope.deferredimport" = "~=4.3.1" "zope.deferredimport" = "~=4.3.1"
"zope.deprecation" = "~=4.4.0" "zope.deprecation" = "~=4.4.0"

814
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,8 +35,6 @@ if DEBUG:
ALLOWED_HOSTS.append('localhost') ALLOWED_HOSTS.append('localhost')
ALLOWED_HOSTS.append('example.com') ALLOWED_HOSTS.append('example.com')
ALLOWED_HOSTS.append('127.0.0.1') ALLOWED_HOSTS.append('127.0.0.1')
ALLOWED_HOSTS.append('.app.github.dev')
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
if not DEBUG: if not DEBUG:

View File

@@ -48,7 +48,7 @@ class Index(generic.TemplateView): # Displays the current rig count along with
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['rig_count'] = models.Event.objects.rig_count() context['rig_count'] = models.Event.objects.rig_count()
context['now'] = models.Event.objects.events_in_bounds(timezone.now(), timezone.now()).exclude(status=models.Event.CANCELLED).filter(is_rig=True, dry_hire=False) context['now'] = models.Event.objects.events_in_bounds(timezone.now(), timezone.now())
return context return context
@@ -134,15 +134,11 @@ class SecureAPIRequest(generic.View):
results = [] results = []
query = reduce(operator.and_, queries) query = reduce(operator.and_, queries)
objects = self.models[model].objects.filter(query) objects = self.models[model].objects.filter(query)
# Returning unactivated or unapproved users when they are elsewhere filtered out of the default queryset leads to some *very* unexpected results
if model == "profile":
objects = objects.filter(is_active=True, is_approved=True)
for o in objects: for o in objects:
name = o.display_name if hasattr(o, 'display_name') else o.name
data = { data = {
'pk': o.pk, 'pk': o.pk,
'value': o.pk, 'value': o.pk,
'text': name, 'text': o.name,
} }
try: # See if there is a valid update URL try: # See if there is a valid update URL
data['update'] = reverse(f"{model}_update", kwargs={'pk': o.pk}) data['update'] = reverse(f"{model}_update", kwargs={'pk': o.pk})
@@ -187,7 +183,7 @@ class ModalURLMixin:
url = reverse_lazy('closemodal') url = reverse_lazy('closemodal')
update_url = str(reverse_lazy(update, kwargs={'pk': self.object.pk})) update_url = str(reverse_lazy(update, kwargs={'pk': self.object.pk}))
messages.info(self.request, "modalobject=" + serializers.serialize("json", [self.object])) messages.info(self.request, "modalobject=" + serializers.serialize("json", [self.object]))
messages.info(self.request, f"modalobject[0]['update_url']='{update_url}'") messages.info(self.request, "modalobject[0]['update_url']='" + update_url + "'")
else: else:
url = reverse_lazy(detail, kwargs={ url = reverse_lazy(detail, kwargs={
'pk': self.object.pk, 'pk': self.object.pk,

View File

@@ -11,9 +11,8 @@ For setup information and other such helpful stuff check the [Wiki](https://gith
- PyRIGS: Base app, stores 'global' information - PyRIGS: Base app, stores 'global' information
- RIGS: Rigboard stuff - event calendar etc - RIGS: Rigboard stuff - event calendar etc
- assets: Database of our kit, testing data etc - assets: Database of our kit, testing data etc
- training: Logs in-house training within various "departments" (sound, lighting etc).
- versioning: Our custom logic built on top of django-reversion. Semi-modular. - versioning: Our custom logic built on top of django-reversion. Semi-modular.
- users: Our custom logic for registration and profiles. Semi-modular. - users: Our custom logic for registration and profiles. Semi-modular.
- training: SoonTM
[![forthebadge](https://forthebadge.com/images/badges/built-with-resentment.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/contains-technical-debt.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/built-with-resentment.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/contains-technical-debt.svg)](https://forthebadge.com)

View File

@@ -154,9 +154,8 @@ class AssociateAdmin(VersionAdmin):
@admin.register(models.Profile) @admin.register(models.Profile)
class ProfileAdmin(UserAdmin, AssociateAdmin): class ProfileAdmin(UserAdmin, AssociateAdmin):
list_display = ('username', 'name', 'is_approved', 'is_superuser', 'is_supervisor', 'number_of_events', 'last_login') list_display = ('username', 'name', 'is_approved', 'is_staff', 'is_superuser', 'is_supervisor', 'number_of_events')
list_display_links = ['username'] list_display_links = ['username']
list_filter = UserAdmin.list_filter + ('is_approved',)
fieldsets = ( fieldsets = (
(None, {'fields': ('username', 'password')}), (None, {'fields': ('username', 'password')}),
(_('Personal info'), { (_('Personal info'), {

View File

@@ -121,7 +121,7 @@ class EventForm(forms.ModelForm):
fields = ['is_rig', 'name', 'venue', 'start_time', 'end_date', 'start_date', fields = ['is_rig', 'name', 'venue', 'start_time', 'end_date', 'start_date',
'end_time', 'meet_at', 'access_at', 'description', 'notes', 'mic', 'end_time', 'meet_at', 'access_at', 'description', 'notes', 'mic',
'person', 'organisation', 'dry_hire', 'checked_in_by', 'status', 'person', 'organisation', 'dry_hire', 'checked_in_by', 'status',
'purchase_order', 'collector', 'forum_url'] 'purchase_order', 'collector']
class BaseClientEventAuthorisationForm(forms.ModelForm): class BaseClientEventAuthorisationForm(forms.ModelForm):

View File

@@ -1,4 +1,4 @@
# Generated by Django 3.2.19 on 2023-05-29 10:23 # Generated by Django 3.2.19 on 2023-05-20 10:36
from django.db import migrations, models from django.db import migrations, models
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
name='fd_earth_fault', name='fd_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'), field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=5, null=True, verbose_name='Earth Fault Loop Impedance'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
name='w1_earth_fault', name='w1_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'), field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=5, null=True, verbose_name='Earth Fault Loop Impedance'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
@@ -33,7 +33,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
name='w2_earth_fault', name='w2_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'), field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=5, null=True, verbose_name='Earth Fault Loop Impedance'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',
name='w3_earth_fault', name='w3_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'), field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=5, null=True, verbose_name='Earth Fault Loop Impedance'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='powertestrecord', model_name='powertestrecord',

View File

@@ -0,0 +1,33 @@
# Generated by Django 3.2.19 on 2023-05-20 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0049_auto_20230520_1136'),
]
operations = [
migrations.AlterField(
model_name='powertestrecord',
name='fd_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'),
),
migrations.AlterField(
model_name='powertestrecord',
name='w1_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'),
),
migrations.AlterField(
model_name='powertestrecord',
name='w2_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'),
),
migrations.AlterField(
model_name='powertestrecord',
name='w3_earth_fault',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Earth Fault Loop Impedance (Z<small>S</small>) / Ω', max_digits=6, null=True, verbose_name='Earth Fault Loop Impedance'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-27 11:28
import RIGS.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0049_auto_20230529_1123'),
]
operations = [
migrations.AddField(
model_name='event',
name='forum_url',
field=models.URLField(blank=True, default='', validators=[RIGS.models.validate_forum_url]),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.19 on 2023-07-09 21:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('RIGS', '0050_event_forum_url'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='method',
field=models.CharField(blank=True, choices=[('C', 'Cash'), ('I', 'Internal'), ('E', 'External'), ('T', 'TEC Adjustment')], default='', max_length=2),
),
]

View File

@@ -76,8 +76,7 @@ class Profile(AbstractUser):
@classmethod @classmethod
def users_awaiting_approval_count(cls): def users_awaiting_approval_count(cls):
# last_login = None ensures we only pick up genuinely new users, not those that have been deactivated for inactivity return Profile.objects.filter(models.Q(is_approved=False)).count()
return Profile.objects.filter(is_approved=False, last_login=None, date_joined_date=timezone.now().date()).count()
def __str__(self): def __str__(self):
return self.name return self.name
@@ -309,14 +308,6 @@ class EventManager(models.Manager):
return qs return qs
def validate_forum_url(value):
if not value:
return # Required error is done the field
obj = urlparse(value)
if obj.hostname not in ('forum.nottinghamtec.co.uk'):
raise ValidationError('URL must point to a location on the TEC Forum')
@reversion.register(follow=['items']) @reversion.register(follow=['items'])
class Event(models.Model, RevisionMixin): class Event(models.Model, RevisionMixin):
# Done to make it much nicer on the database # Done to make it much nicer on the database
@@ -366,8 +357,6 @@ class Event(models.Model, RevisionMixin):
auth_request_at = models.DateTimeField(null=True, blank=True) auth_request_at = models.DateTimeField(null=True, blank=True)
auth_request_to = models.EmailField(blank=True, default='') auth_request_to = models.EmailField(blank=True, default='')
forum_url = models.URLField(default='', blank=True, validators=[validate_forum_url])
@property @property
def display_id(self): def display_id(self):
if self.pk: if self.pk:
@@ -508,7 +497,7 @@ class Event(models.Model, RevisionMixin):
earliest = datetime.datetime.combine(self.start_date, datetime.time(00, 00)) earliest = datetime.datetime.combine(self.start_date, datetime.time(00, 00))
tz = pytz.timezone(settings.TIME_ZONE) tz = pytz.timezone(settings.TIME_ZONE)
earliest = tz.localize(earliest) earliest = tz.localize(earliest)
return not self.dry_hire and not self.status == Event.CANCELLED and earliest <= timezone.now() return not self.dry_hire and earliest <= timezone.now()
objects = EventManager() objects = EventManager()
@@ -516,7 +505,7 @@ class Event(models.Model, RevisionMixin):
return reverse('event_detail', kwargs={'pk': self.pk}) return reverse('event_detail', kwargs={'pk': self.pk})
def __str__(self): def __str__(self):
return f"{self.display_id} | {self.name}" return f"{self.display_id}: {self.name}"
def clean(self): def clean(self):
errdict = {} errdict = {}
@@ -688,11 +677,13 @@ class Payment(models.Model, RevisionMixin):
CASH = 'C' CASH = 'C'
INTERNAL = 'I' INTERNAL = 'I'
EXTERNAL = 'E' EXTERNAL = 'E'
SUCORE = 'SU'
ADJUSTMENT = 'T' ADJUSTMENT = 'T'
METHODS = ( METHODS = (
(CASH, 'Cash'), (CASH, 'Cash'),
(INTERNAL, 'Internal'), (INTERNAL, 'Internal'),
(EXTERNAL, 'External'), (EXTERNAL, 'External'),
(SUCORE, 'SU Core'),
(ADJUSTMENT, 'TEC Adjustment'), (ADJUSTMENT, 'TEC Adjustment'),
) )

View File

@@ -3,7 +3,6 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from io import BytesIO from io import BytesIO
import datetime
from PyPDF2 import PdfFileReader, PdfFileMerger from PyPDF2 import PdfFileReader, PdfFileMerger
from django.conf import settings from django.conf import settings
@@ -111,7 +110,7 @@ def send_admin_awaiting_approval_email(user, request, **kwargs):
if admin.last_emailed is None or admin.last_emailed + settings.EMAIL_COOLDOWN <= timezone.now(): if admin.last_emailed is None or admin.last_emailed + settings.EMAIL_COOLDOWN <= timezone.now():
context = { context = {
'request': request, 'request': request,
'link_suffix': reverse("admin:RIGS_profile_changelist") + f'?is_approved__exact=0&date_joined__date={timezone.now().date()}', 'link_suffix': reverse("admin:RIGS_profile_changelist") + '?is_approved__exact=0',
'number_of_users': models.Profile.users_awaiting_approval_count(), 'number_of_users': models.Profile.users_awaiting_approval_count(),
'to_name': admin.first_name 'to_name': admin.first_name
} }

View File

@@ -26,7 +26,6 @@
var calendarEl = document.getElementById('calendar'); var calendarEl = document.getElementById('calendar');
calendar = new FullCalendar.Calendar(calendarEl, { calendar = new FullCalendar.Calendar(calendarEl, {
firstDay: 1,
themeSystem: 'bootstrap', themeSystem: 'bootstrap',
aspectRatio: 1.5, aspectRatio: 1.5,
eventTimeFormat: { eventTimeFormat: {

View File

@@ -46,7 +46,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-12" style="container-type: inline-size;"> <div class="col-sm-12">
{% with object_list as events %} {% with object_list as events %}
{% include 'partials/event_table.html' %} {% include 'partials/event_table.html' %}
{% endwith %} {% endwith %}

View File

@@ -209,7 +209,7 @@
<div class="col-sm-9 col-md-7 col-lg-8"> <div class="col-sm-9 col-md-7 col-lg-8">
<select id="{{ form.venue.id_for_label }}" name="{{ form.venue.name }}" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='venue' %}"> <select id="{{ form.venue.id_for_label }}" name="{{ form.venue.name }}" class="selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='venue' %}">
{% if venue %} {% if venue %}
<option value="{{venue.id}}" selected="selected" data-update_url="{% url 'venue_update' venue.id %}">{{ venue }}</option> <option value="{{venue.value}}" selected="selected" data-update_url="{% url 'venue_update' venue.value %}">{{ venue }}</option>
{% endif %} {% endif %}
</select> </select>
</div> </div>
@@ -231,7 +231,7 @@
<label for="{{ form.start_date.id_for_label }}" <label for="{{ form.start_date.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.start_date.label }}</label> class="col-sm-4 col-form-label">{{ form.start_date.label }}</label>
<div class="col-sm-10"> <div class="col-sm-8">
<div class="row"> <div class="row">
<div class="col-sm-12 col-md-7" data-toggle="tooltip" title="Start date for event, required"> <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" %} {% render_field form.start_date class+="form-control" %}
@@ -246,7 +246,7 @@
<label for="{{ form.end_date.id_for_label }}" <label for="{{ form.end_date.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.end_date.label }}</label> class="col-sm-4 col-form-label">{{ form.end_date.label }}</label>
<div class="col-sm-10"> <div class="col-sm-8">
<div class="row"> <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"> <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" %} {% render_field form.end_date class+="form-control" %}
@@ -334,26 +334,12 @@
<div class="form-group" data-toggle="tooltip" title="The purchase order number (for external clients)"> <div class="form-group" data-toggle="tooltip" title="The purchase order number (for external clients)">
<label for="{{ form.purchase_order.id_for_label }}" <label for="{{ form.purchase_order.id_for_label }}"
class="col-sm-4 col-form-label">{{ form.purchase_order.label }}</label> class="col-sm-4 col-fitem_tableorm-label">{{ form.purchase_order.label }}</label>
<div class="col-sm-8"> <div class="col-sm-8">
{% render_field form.purchase_order class+="form-control" %} {% render_field form.purchase_order class+="form-control" %}
</div> </div>
</div> </div>
<div class="form-group" data-toggle="tooltip" title="The thread for this event on the TEC Forum">
<label for="{{ form.forum_url.id_for_label }}"
class="col-sm-4 col-form-label">Forum Thread</label>
<div class="col-sm-12">
<p class="small mb-0">Paste URL</p>
{% render_field form.forum_url class+="form-control" %}
{% if object.pk %}
<p class="small mb-0">or</p>
<a href="{% url 'event_thread' object.pk %}" class="btn btn-primary" title="Create Forum Thread" target="_blank">
<span class="fas fa-plus"></span> <span class="hidden-xs">Create Forum Thread</span></a>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -165,11 +165,11 @@
</div> </div>
</div> </div>
<div class="col-12 text-right"> <div class="col-12 text-right">
{% button 'edit' url='pt_edit' pk=object.pk %} {% button 'edit' url='ec_edit' pk=object.pk %}
{% button 'view' url='event_detail' pk=object.event.pk text="Event" %} {% button 'view' url='event_detail' pk=object.pk text="Event" %}
{% include 'partials/review_status.html' with perm=perms.RIGS.review_power review='pt_review' %} {% include 'partials/review_status.html' with perm=perms.RIGS.review_eventchecklist review='ec_review' %}
</div> </div>
<div class="col-12 text-right"> <div class="col-12 text-right">
{% include 'partials/last_edited.html' with target="powertestrecord_history" %} {% include 'partials/last_edited.html' with target="eventchecklist_history" %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,6 @@
<h5 class="py-3"><a class="btn btn-info" data-toggle="collapse" href="#values" aria-expanded="false" aria-controls="values">View Threshold Values</a></h5> <h5 class="py-3"><a class="btn btn-info" data-toggle="collapse" href="#values" aria-expanded="false" aria-controls="values">View Threshold Values</a></h5>
<div class="row collapse" id="values"> <div class="row collapse" id="values">
<div class="col-md-6 col-sm-12"> <div class="table-responsive">
<table class="table table-bordered"> <table class="table table-bordered">
<thead> <thead>
<tr> <tr>
@@ -33,20 +33,17 @@
<thead> <thead>
<tr> <tr>
<th scope="row">Distro</th> <th scope="row">Distro</th>
<th scope="row">Max PSCC with Single Phase Supply (kA)</th> <th scope="row">Max PSSC (kA)</th>
<th scope="row">Max PSCC with Three Phase Supply (kA)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Intel & Toblerone distros</td> <td>Intel & Toblerone distros</td>
<td>6</td> <td>6</td>
<td>3</td>
</tr> </tr>
<tr> <tr>
<td>All other distros</td> <td>All other distros</td>
<td>10</td> <td>10</td>
<td>5</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -77,15 +77,6 @@
<dt class="col-sm-6">PO</dt> <dt class="col-sm-6">PO</dt>
<dd class="col-sm-6">{{ object.purchase_order }}</dd> <dd class="col-sm-6">{{ object.purchase_order }}</dd>
{% endif %} {% endif %}
<dt class="col-6">Forum Thread</dt>
{% if object.forum_url %}
<dd class="col-6"><a href="{{object.forum_url}}">{{object.forum_url}}</a></dd>
{% else %}
<a href="{% url 'event_thread' object.pk %}" class="btn btn-primary" title="Create Forum Thread" target="_blank"><span
class="fas fa-plus"></span> <span
class="hidden-xs">Create Forum Thread</span></a>
{% endif %}
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
<div id="event_status"> <div>
<span class="badge badge-{% if event.confirmed %}success{% elif event.cancelled %}dark{% else %}warning{% endif %}">Status: {{ event.get_status_display }}</span> <span class="badge badge-{% if event.confirmed %}success{% elif event.cancelled %}dark{% else %}warning{% endif %}">Status: {{ event.get_status_display }}</span>
{% if event.is_rig %} {% if event.is_rig %}
{% if event.sum_total > 0 %} {% if event.sum_total > 0 %}

View File

@@ -1,195 +1,105 @@
{% load namewithnotes from filters %} {% load namewithnotes from filters %}
{% load markdown_tags %} {% load markdown_tags %}
<style> <div class="table-responsive">
#event_table { <table class="table mb-0" id="event_table">
display: grid; <thead>
grid-template-columns: max-content min-content minmax(max-content, 1fr) max-content; <tr>
column-gap: 1em; <th scope="col">#</th>
} <th scope="col">Dates & Times</th>
.eventgrid { <th scope="col">Event Details</th>
display: inherit; <th scope="col">MIC</th>
grid-column: 1/5; </tr>
grid-template-columns: subgrid; </thead>
padding: 1em; <tbody>
dt, dd { display: block; float: left; } {% for event in events %}
dt { clear: both; } <tr class="{% if event.cancelled %}
dd { float: right; } table-secondary
} {% elif not event.is_rig %}
.grid-header { table-info
border-bottom: 1px solid grey; {% elif not event.mic %}
border-top: 1px solid grey; table-danger
} {% elif event.confirmed and event.authorised %}
#event_status { {% if event.dry_hire or event.riskassessment %}
grid-column-start: 3; table-success
} {% else %}
#event_mic { table-warning
grid-row-start: 1; {% endif %}
grid-column-start: 4; {% else %}
} table-warning
.c-none { {% endif %}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
display: none; <!---Number-->
} <th scope="row" id="event_number">{{ event.display_id }}</th>
.c-inline { <!--Dates & Times-->
display: inline; <td id="event_dates" style="text-align: justify;">
} {% if not event.cancelled %}
@container (width <= 500px) { {% if event.meet_at %}
#event_table { <span class="text-nowrap">Meet: <strong>{{ event.meet_at|date:"D d/m/Y H:i" }}</strong></span>
grid-template-columns: 1fr !important; {% endif %}
} {% if event.access_at %}
.eventgrid { <br><span class="text-nowrap">Access: <strong>{{ event.access_at|date:"D d/m/Y H:i" }}</strong></span>
grid-column: 1/1 !important; {% endif %}
padding: 0.5em; {% endif %}
} <span class="text-nowrap">Start: <strong>{{ event.start_date|date:"D d/m/Y" }}
.grid-header { {% if event.has_start_time %}
display: none; {{ event.start_time|date:"H:i" }}
} {% endif %}</strong>
#event_dates { </span>
order: 2; {% if event.end_date %}
} <br>
#event_status { <span class="text-nowrap">End: {% if event.end_date != event.start_date %}<strong>{{ event.end_date|date:"D d/m/Y" }}{% endif %}
order: 3; {% if event.has_end_time %}
} {{ event.end_time|date:"H:i" }}
#event_mic { {% endif %}</strong>
grid-row-start: auto; </span>
grid-column-start: 4; {% endif %}
} </td>
} <!---Details-->
@container (width <= 700px) { <td id="event_details" class="w-100">
#event_table { <h4>
grid-template-columns: max-content; <a href="{% url 'event_detail' event.pk %}">
column-gap: 0.5em; {{ event.name }}
} </a>
.eventgrid { {% if event.venue %}
grid-column: 1/3; <small>at {{ event.venue|namewithnotes:'venue_detail' }}</small>
border: 1px solid grey; {% endif %}
} {% if event.dry_hire %}
#event_dates { <span class="badge badge-secondary">Dry Hire</span>
grid-row: 2; {% endif %}
grid-column: 1; </h4>
} {% if event.is_rig and not event.cancelled %}
#event_number { <h5>
grid-row: 1; <a href="{{ event.person.get_absolute_url }}">{{ event.person.name }}</a>
grid-column: 1; {% if event.organisation %}
} for <a href="{{ event.organisation.get_absolute_url }}">{{ event.organisation.name }}</a>
#event_mic { {% endif %}
grid-column: 2; </h5>
} {% endif %}
#event_status { {% if not event.cancelled and event.description %}
grid-column: span 2; <p>{{ event.description|markdown }}</p>
} {% endif %}
.grid-header, .c-md-none { {% include 'partials/event_status.html' %}
display: none; </td>
} <!---MIC-->
} <td id="event_mic" class="text-nowrap">
@container (width > 700px) { {% if event.mic %}
.c-lg-block { {% if perms.RIGS.view_profile %}
display: block; <a href="{% url 'profile_detail' event.mic.pk %}" class="modal-href">
} {% endif %}
.c-lg-inline { <img src="{{ event.mic.profile_picture }}" class="event-mic-photo"/>
display: inline; {{ event.mic }}
} {% if perms.RIGS.view_profile %}
.c-lg-none, .c-md-none { </a>
display: none; {% endif %}
} {% elif event.is_rig %}
} <span class="fas fa-user-slash"></span>
</style> {% endif %}
<div id="event_table"> </td>
<div class="eventgrid grid-header font-weight-bold"> </tr>
<div id="event_number">#</div> {% empty %}
<div id="event_dates">Dates & Times</div> <tr class="bg-warning">
<div>Event Details</div> <td colspan="4">No events found</td>
<div id="event_mic">MIC</div> </tr>
</div> {% endfor %}
{% for event in events %} </tbody>
<div class="eventgrid {% if event.cancelled %} </table>
table-secondary
{% elif not event.is_rig %}
table-info
{% elif not event.mic %}
table-danger
{% elif event.confirmed and event.authorised %}
{% if event.dry_hire or event.riskassessment %}
table-success
{% else %}
table-warning
{% endif %}
{% else %}
table-warning
{% endif %}" {% if event.cancelled %}style="opacity: 50% !important;"{% endif %} id="event_row">
<!---Number-->
<div class="font-weight-bold c-none c-lg-block" id="event_number">{{ event.display_id }}</div>
<!--Dates & Times-->
<div id="event_dates" style="min-width: 180px;">
<dl>
{% if not event.cancelled %}
{% if event.meet_at %}
<dt class="font-weight-normal">Meet:</dt>
<dd class="text-nowrap font-weight-bold text-lg-right">{{ event.meet_at|date:"D d/m/Y H:i" }}</dd>
{% endif %}
{% if event.access_at %}
<dt class="font-weight-normal">Access:</dt>
<dd class="text-nowrap font-weight-bold text-lg-right">{{ event.access_at|date:"D d/m/Y H:i" }}</dd>
{% endif %}
{% endif %}
<dt class="font-weight-normal">Start:</dt>
<dd class="text-nowrap font-weight-bold text-lg-right">{{ event.start_date|date:"D d/m/Y" }}
{% if event.has_start_time %}
{{ event.start_time|date:"H:i" }}
{% endif %}
</dd>
{% if event.end_date %}
<dt class="font-weight-normal">End:</dt>
<dd class="text-nowrap font-weight-bold text-lg-right">{{ event.end_date|date:"D d/m/Y" }}
{% if event.has_end_time %}
{{ event.end_time|date:"H:i" }}
{% endif %}
</dd>
{% endif %}
</dl>
</div>
<!---Details-->
<div id="event_details" class="w-100">
<h4>
<a href="{% url 'event_detail' event.pk %}">
<span class="c-inline c-lg-none">{{ event }}</span><span class="c-none c-lg-inline">{{ event.name }}</span>
</a>
{% if event.dry_hire %}
<span class="badge badge-secondary">Dry Hire</span>
{% endif %}
<br class="c-none c-lg-inline">
{% if event.venue %}
<small>at {{ event.venue|namewithnotes:'venue_detail' }}</small>
{% endif %}
</h4>
{% if event.is_rig and not event.cancelled %}
<h5>
<a href="{{ event.person.get_absolute_url }}">{{ event.person.name }}</a>
{% if event.organisation %}
for <a href="{{ event.organisation.get_absolute_url }}">{{ event.organisation.name }}</a>
{% endif %}
</h5>
{% endif %}
{% if not event.cancelled and event.description %}
<p>{{ event.description|markdown }}</p>
{% endif %}
</div>
{% include 'partials/event_status.html' %}
<!---MIC-->
<div id="event_mic" class="text-nowrap">
<span class="c-md-none align-middle">MIC:</span>
{% if event.mic %}
{% if perms.RIGS.view_profile %}
<a href="{% url 'profile_detail' event.mic.pk %}" class="modal-href">
{% endif %}
<img src="{{ event.mic.profile_picture }}" class="event-mic-photo"/>
{{ event.mic }}
{% if perms.RIGS.view_profile %}
</a>
{% endif %}
{% elif event.is_rig %}
<span class="fas fa-exclamation"></span>
{% endif %}
</div>
</div>
{% endfor %}
</div> </div>

View File

@@ -29,15 +29,7 @@
</div> </div>
<div class="row pt-3"> <div class="row pt-3">
<label class="col-sm-4 col-form-label" <label class="col-sm-4 col-form-label"
for="{{ form.method.id_for_label }}">{{ form.method.label }} for="{{ form.method.id_for_label }}">{{ form.method.label }}</label>
<span class="fas fa-info-circle text-info" data-toggle="collapse" data-target="#collapse" aria-expanded="false" aria-controls="collapse"></span>
<ul class="collapse" id="collapse">
<li>Cash - Self Explanatory</li>
<li>Internal - Transfers within the Students' Union only</li>
<li>External - All other transfers (<em>including</em> the University)</li>
<li>TEC Adjustment - Manual corrections</li>
</ul>
</label>
<div class="col-sm-8"> <div class="col-sm-8">
{% render_field form.method class+="form-control" %} {% render_field form.method class+="form-control" %}
</div> </div>

View File

@@ -3,8 +3,8 @@
{% block content %} {% block content %}
<div class="row align-items-center justify-content-between py-2 align-middle"> <div class="row align-items-center justify-content-between py-2 align-middle">
<div class="col-sm-12 col-md align-middle d-flex flex-wrap"> <div class="col-sm-12 col-md align-middle">
Key: <span class="table-success mr-1 px-2 rounded">Ready</span><span class="table-warning mr-1 px-2 rounded text-nowrap">Action Required</span><span class="table-danger mr-1 px-2 rounded text-nowrap">Needs MIC</span><span class="table-secondary mr-1 px-2 rounded">Cancelled</span><span class="table-info px-2 rounded text-nowrap">Non-Rig</span> Key: <span class="table-success mr-1 px-2 rounded">Ready</span><span class="table-warning mr-1 px-2 rounded">Action Required</span><span class="table-danger mr-1 px-2 rounded">Needs MIC</span><span class="table-secondary mr-1 px-2 rounded">Cancelled</span><span class="table-info px-2 rounded">Non-Rig</span>
</div> </div>
{% if perms.RIGS.add_event %} {% if perms.RIGS.add_event %}
<div class="col text-right"> <div class="col text-right">
@@ -12,7 +12,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div style="container-type: inline-size;">
{% include 'partials/event_table.html' %} {% include 'partials/event_table.html' %}
</div>
{% endblock %} {% endblock %}

View File

@@ -372,7 +372,7 @@ def test_ra_redirect(admin_client, admin_user, ra):
class TestMarkdownTemplateTags(TestCase): class TestMarkdownTemplateTags(TestCase):
with open(os.path.join(settings.BASE_DIR, "RIGS/tests/sample.md"), encoding="utf-8") as f: with open(os.path.join(settings.BASE_DIR, "RIGS/tests/sample.md")) as f:
markdown = f.read() markdown = f.read()
def test_html_safe(self): def test_html_safe(self):

View File

@@ -110,9 +110,6 @@ urlpatterns = [
path('event/<int:pk>/checkin/add/', login_required(views.EventCheckInOverride.as_view()), path('event/<int:pk>/checkin/add/', login_required(views.EventCheckInOverride.as_view()),
name='event_checkin_override'), name='event_checkin_override'),
path('event/<int:pk>/thread/', permission_required_with_403('RIGS.change_event')(views.CreateForumThread.as_view()), name='event_thread'),
path('event/webhook/', views.RecieveForumWebhook.as_view(), name='webhook_recieve'),
# Finance # Finance
path('invoice/', permission_required_with_403('RIGS.view_invoice')(views.InvoiceIndex.as_view()), path('invoice/', permission_required_with_403('RIGS.view_invoice')(views.InvoiceIndex.as_view()),
name='invoice_list'), name='invoice_list'),

View File

@@ -53,6 +53,12 @@ class EventRiskAssessmentCreate(HSCreateView):
def get_success_url(self): def get_success_url(self):
return reverse('ra_detail', kwargs={'pk': self.object.pk}) return reverse('ra_detail', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if context['event'].mic:
context['power_mic'] = context['event'].mic
return context
class EventRiskAssessmentEdit(generic.UpdateView): class EventRiskAssessmentEdit(generic.UpdateView):
model = models.RiskAssessment model = models.RiskAssessment
@@ -166,7 +172,7 @@ class PowerTestEdit(generic.UpdateView):
ec.reviewed_by = None ec.reviewed_by = None
ec.reviewed_at = None ec.reviewed_at = None
ec.save() ec.save()
return reverse('pt_detail', kwargs={'pk': self.object.pk}) return reverse('ec_detail', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@@ -214,7 +220,7 @@ class HSList(generic.ListView):
template_name = 'hs/hs_list.html' template_name = 'hs/hs_list.html'
def get_queryset(self): def get_queryset(self):
return models.Event.objects.all().exclude(status=models.Event.CANCELLED).exclude(dry_hire=True).order_by('-start_date').select_related('riskassessment').prefetch_related('checklists') return models.Event.objects.all().exclude(status=models.Event.CANCELLED).order_by('-start_date').select_related('riskassessment').prefetch_related('checklists')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)

View File

@@ -3,12 +3,6 @@ import datetime
import re import re
import premailer import premailer
import simplejson import simplejson
import urllib
import hmac
import hashlib
from envparse import env
from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -25,7 +19,6 @@ from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import generic from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from PyRIGS import decorators from PyRIGS import decorators
from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related from PyRIGS.views import OEmbedView, is_ajax, ModalURLMixin, PrintView, get_related
@@ -384,41 +377,3 @@ class EventAuthoriseRequestEmailPreview(generic.DetailView):
context['to_name'] = self.request.GET.get('to_name', None) context['to_name'] = self.request.GET.get('to_name', None)
context['target'] = 'event_authorise_form_preview' context['target'] = 'event_authorise_form_preview'
return context return context
class CreateForumThread(generic.base.RedirectView):
permanent = False
def get_redirect_url(self, *args, **kwargs):
event = get_object_or_404(models.Event, pk=kwargs['pk'])
if event.forum_url:
return event.forum_url
params = {
'title': str(event),
'body': f'https://rigs.nottinghamtec.co.uk/event/{event.pk}',
'category': 'rig-info'
}
return f'https://forum.nottinghamtec.co.uk/new-topic?{urllib.parse.urlencode(params)}'
class RecieveForumWebhook(generic.View):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
computed = f"sha256={hmac.new(env('FORUM_WEBHOOK_SECRET').encode(), request.body, hashlib.sha256).hexdigest()}"
if not hmac.compare_digest(request.headers.get('X-Discourse-Event-Signature'), computed):
return HttpResponseForbidden('Invalid signature header')
# Check if this is the right kind of event. The webhook filters by category on the forum side
if request.headers.get('X-Discourse-Event') == "topic_created":
body = simplejson.loads(request.body.decode('utf-8'))
event_id = int(body['topic']['title'][1:6]) # find the ID, force convert it to an int to eliminate leading zeros
event = models.Event.objects.filter(pk=event_id).first()
if event:
event.forum_url = f"https://forum.nottinghamtec.co.uk/t/{body['topic']['slug']}"
event.save()
return HttpResponse(status=202)
return HttpResponse(status=204)

View File

@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"postdeploy": "python manage.py migrate && python manage.py generateSampleData" "postdeploy": "python manage.py migrate && python manage.py generateSampleData"
}, },
"stack": "heroku-22", "stack": "heroku-20",
"env": { "env": {
"DEBUG": { "DEBUG": {
"required": true "required": true
@@ -51,7 +51,7 @@
"url": "heroku/nodejs" "url": "heroku/nodejs"
}, },
{ {
"url": "heroku/python" "url": "https://github.com/nottinghamtec/heroku-buildpack-python"
} }
] ]
} }

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-24 22:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0027_asset_nickname'),
]
operations = [
migrations.AlterField(
model_name='asset',
name='length',
field=models.DecimalField(blank=True, decimal_places=2, help_text='m', max_digits=10, null=True),
),
]

View File

@@ -135,7 +135,7 @@ class Asset(models.Model, RevisionMixin):
# Cable assets # Cable assets
is_cable = models.BooleanField(default=False) is_cable = models.BooleanField(default=False)
cable_type = models.ForeignKey(to=CableType, blank=True, null=True, on_delete=models.SET_NULL) cable_type = models.ForeignKey(to=CableType, blank=True, null=True, on_delete=models.SET_NULL)
length = models.DecimalField(decimal_places=2, max_digits=10, length = models.DecimalField(decimal_places=1, max_digits=10,
blank=True, null=True, help_text='m') blank=True, null=True, help_text='m')
csa = models.DecimalField(decimal_places=2, max_digits=10, csa = models.DecimalField(decimal_places=2, max_digits=10,
blank=True, null=True, help_text='mm²') blank=True, null=True, help_text='mm²')
@@ -192,5 +192,5 @@ class Asset(models.Model, RevisionMixin):
return str(self.asset_id) return str(self.asset_id)
@property @property
def display_name(self): def name(self):
return f"{self.display_id} | {self.description}" return f"{self.display_id} | {self.description}"

View File

@@ -35,11 +35,6 @@
function onAuditClick(assetID) { function onAuditClick(assetID) {
$('#' + assetID).remove(); $('#' + assetID).remove();
} }
$('#modal').on('hidden.bs.modal', function (e) {
searchbar = document.getElementById('id_q');
searchbar.value = "";
setTimeout(searchbar.focus(), 2000);
})
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -12,6 +12,7 @@
{{ block.super }} {{ block.super }}
<script src="{% static 'js/selects.js' %}"></script> <script src="{% static 'js/selects.js' %}"></script>
<script src="{% static 'js/easymde.min.js' %}"></script> <script src="{% static 'js/easymde.min.js' %}"></script>
<script src="{% static 'js/interaction.js' %}"></script>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
@@ -34,10 +35,9 @@
$(document).find(".selectpicker").selectpicker().each(function(){initPicker($(this))}); $(document).find(".selectpicker").selectpicker().each(function(){initPicker($(this))});
}); });
</script> </script>
<script src="{% static "js/tooltip.js" %}"></script>
<script> <script>
$(function () { $(document).ready(function () {
$('[data-toggle="tooltip"]').tooltip(); setupMDE('#id_comments');
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -12,7 +12,7 @@
</thead> </thead>
<tbody id="asset_table_body"> <tbody id="asset_table_body">
{% for item in object_list %} {% for item in object_list %}
<tr class="table-{{ item.status.display_class|default:'' }} assetRow" id="{{ item.asset_id }}"> <tr class="table-{{ item.status.display_class|default:'' }} assetRow">
<th scope="row" class="align-middle"><a class="assetID" href="{% url 'asset_detail' item.asset_id %}">{{ item.asset_id }}</a></th> <th scope="row" class="align-middle"><a class="assetID" href="{% url 'asset_detail' item.asset_id %}">{{ item.asset_id }}</a></th>
<td class="assetDesc"><span class="text-truncate d-inline-block align-middle">{{ item.description }}</span></td> <td class="assetDesc"><span class="text-truncate d-inline-block align-middle">{{ item.description }}</span></td>
<td class="assetCategory align-middle">{{ item.category }}</td> <td class="assetCategory align-middle">{{ item.category }}</td>

View File

@@ -34,7 +34,7 @@
<label for="{{ form.purchase_price.id_for_label }}">Purchase Price</label> <label for="{{ form.purchase_price.id_for_label }}">Purchase Price</label>
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"><span class="input-group-text">£</span></div> <div class="input-group-prepend"><span class="input-group-text">£</span></div>
{% render_field form.purchase_price|add_class:'form-control'|set_data:"toggle:tooltip" value=object.purchase_price title="Ex. VAT" %} {% render_field form.purchase_price|add_class:'form-control' value=object.purchase_price %}
</div> </div>
</div> </div>
@@ -42,7 +42,7 @@
<label for="{{ form.salvage_value.id_for_label }}">Replacement Cost</label> <label for="{{ form.salvage_value.id_for_label }}">Replacement Cost</label>
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"><span class="input-group-text">£</span></div> <div class="input-group-prepend"><span class="input-group-text">£</span></div>
{% render_field form.replacement_cost|add_class:'form-control'|set_data:"toggle:tooltip" value=object.replacement_cost title="Ex. VAT" %} {% render_field form.replacement_cost|add_class:'form-control' value=object.replacement_cost %}
</div> </div>
</div> </div>

View File

@@ -38,17 +38,3 @@ def test_asset(db, category, status):
asset, created = models.Asset.objects.get_or_create(asset_id="91991", description="Spaceflower", status=status, category=category, date_acquired=datetime.date(1991, 12, 26), replacement_cost=100) asset, created = models.Asset.objects.get_or_create(asset_id="91991", description="Spaceflower", status=status, category=category, date_acquired=datetime.date(1991, 12, 26), replacement_cost=100)
yield asset yield asset
asset.delete() asset.delete()
@pytest.fixture
def test_status_2(db):
status = models.AssetStatus.objects.create(name="Lost", should_show=False)
yield status
status.delete()
@pytest.fixture
def test_asset_2(db, category, test_status_2):
asset, created = models.Asset.objects.get_or_create(asset_id="10", description="Working Mic", status=test_status_2, category=category, date_acquired=datetime.date(2001, 10, 20), replacement_cost=1000)
yield asset
asset.delete()

View File

@@ -77,7 +77,7 @@ class AssetForm(FormPage):
'description': (regions.TextBox, (By.ID, 'id_description')), 'description': (regions.TextBox, (By.ID, 'id_description')),
'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')), 'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')),
'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')), 'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')),
'comments': (regions.TextBox, (By.ID, 'id_comments')), 'comments': (regions.SimpleMDETextArea, (By.ID, 'id_comments')),
'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')), 'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')),
'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')), 'replacement_cost': (regions.TextBox, (By.ID, 'id_replacement_cost')),
'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')), 'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')),

View File

@@ -1,6 +1,5 @@
import time import time
import datetime import datetime
import pytest
from django.utils import timezone from django.utils import timezone
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -54,45 +53,45 @@ class TestAssetList(AutoLoginTest):
self.assertEqual("10", asset_ids[2]) self.assertEqual("10", asset_ids[2])
self.assertEqual("C1", asset_ids[3]) self.assertEqual("C1", asset_ids[3])
def test_search(self):
self.page.set_query("10")
self.page.search()
self.assertTrue(len(self.page.assets) == 1)
self.assertEqual("Working Mic", self.page.assets[0].description)
self.assertEqual("10", self.page.assets[0].id)
@pytest.mark.xfail(reason="Fails on CI for unknown reason", raises=AssertionError) self.page.set_query("light")
def test_search(logged_in_browser, admin_user, live_server, test_asset, test_asset_2, category, status, cable_type): self.page.search()
page = pages.AssetList(logged_in_browser.driver, live_server.url).open() self.assertTrue(len(self.page.assets) == 1)
page.set_query(test_asset.asset_id) self.assertEqual("A light", self.page.assets[0].description)
page.search()
assert len(page.assets) == 1
assert page.assets[0].description == test_asset.description
assert page.assets[0].id == test_asset.asset_id
page.set_query(test_asset.description) self.page.set_query("Random string")
page.search() self.page.search()
assert len(page.assets) == 1 self.assertTrue(len(self.page.assets) == 0)
assert page.assets[0].description == test_asset.description
page.set_query("Random string") self.page.set_query("")
page.search() self.page.search()
assert len(page.assets) == 0 # Only working stuff shown by default
self.assertTrue(len(self.page.assets) == 2)
page.set_query("") self.page.status_selector.toggle()
page.search() self.assertTrue(self.page.status_selector.is_open)
# Only working stuff shown by default self.page.status_selector.select_all()
assert len(page.assets) == 1 self.page.status_selector.toggle()
self.assertFalse(self.page.status_selector.is_open)
self.page.filter()
self.assertTrue(len(self.page.assets) == 4)
page.status_selector.toggle() self.page.category_selector.toggle()
assert page.status_selector.is_open self.assertTrue(self.page.category_selector.is_open)
page.status_selector.select_all() self.page.category_selector.set_option("Sound", True)
page.status_selector.toggle() self.page.category_selector.close()
assert not page.status_selector.is_open self.assertFalse(self.page.category_selector.is_open)
page.filter() self.page.filter()
assert len(page.assets) == 2 self.assertTrue(len(self.page.assets) == 2)
asset_ids = list(map(lambda x: x.id, self.page.assets))
page.category_selector.toggle() self.assertEqual("1", asset_ids[0])
assert page.category_selector.is_open self.assertEqual("10", asset_ids[1])
page.category_selector.set_option(category.name, True)
page.category_selector.close()
assert not page.category_selector.is_open
page.filter()
assert len(page.assets) == 2
def test_cable_create(logged_in_browser, admin_user, live_server, test_asset, category, status, cable_type): def test_cable_create(logged_in_browser, admin_user, live_server, test_asset, category, status, cable_type):

View File

@@ -16,7 +16,7 @@ const con = require('gulp-concat');
const gulpif = require('gulp-if'); const gulpif = require('gulp-if');
function fonts(done) { function fonts(done) {
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.*', { encoding: false }) return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.*')
.pipe(gulp.dest('pipeline/built_assets/fonts')) .pipe(gulp.dest('pipeline/built_assets/fonts'))
.pipe(browsersync.stream()); .pipe(browsersync.stream());
} }
@@ -70,16 +70,16 @@ function scripts() {
.pipe(gulpif(function(file) { return interaction.includes(file.relative);}, con('interaction.js'))) .pipe(gulpif(function(file) { return interaction.includes(file.relative);}, con('interaction.js')))
.pipe(gulpif(function(file) { return jpop.includes(file.relative);}, con('jpop.js'))) .pipe(gulpif(function(file) { return jpop.includes(file.relative);}, con('jpop.js')))
.pipe(flatten()) .pipe(flatten())
// Only minify if filename does not already denote it as minified .pipe(terser())
.pipe(gulpif(function(file) { return file.path.indexOf("min") == -1;},terser()))
.pipe(gulp.dest(dest)) .pipe(gulp.dest(dest))
.pipe(browsersync.stream()); .pipe(browsersync.stream());
} }
function browserSync(done) { function browserSync(done) {
spawn('python', ['manage.py', 'runserver'], {stdio: 'inherit'}); spawn('python', ['manage.py', 'runserver'], {stdio: 'inherit'});
// TODO Wait for Django server to come up before browsersync, it seems inconsistent
browsersync.init({ browsersync.init({
notify: true, notify: false,
open: false, open: false,
port: 8001, port: 8001,
proxy: '127.0.0.1:8000' proxy: '127.0.0.1:8000'

9564
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
"cssnano": "^5.0.13", "cssnano": "^5.0.13",
"easymde": "^2.16.1", "easymde": "^2.16.1",
"fullcalendar": "^5.10.1", "fullcalendar": "^5.10.1",
"gulp": "^4.0.2",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"gulp-flatten": "^0.4.0", "gulp-flatten": "^0.4.0",
"gulp-if": "^3.0.0", "gulp-if": "^3.0.0",
@@ -27,14 +28,13 @@
"jquery": "^3.6.0", "jquery": "^3.6.0",
"konami": "^1.6.3", "konami": "^1.6.3",
"moment": "^2.29.4", "moment": "^2.29.4",
"node-sass": "^9.0.0", "node-sass": "^7.0.3",
"popper.js": "^1.16.1", "popper.js": "^1.16.1",
"postcss": "^8.4.31", "postcss": "^8.4.5",
"uglify-js": "^3.14.5" "uglify-js": "^3.14.5"
}, },
"devDependencies": { "devDependencies": {
"browser-sync": "^3.0.2", "browser-sync": "^2.27.11"
"gulp": "^5.0.0"
}, },
"scripts": { "scripts": {
"gulp": "gulp", "gulp": "gulp",

View File

@@ -6,6 +6,11 @@ function setupItemTable(items_json) {
newitem = -1; newitem = -1;
} }
function nl2br(str, is_xhtml) {
var breakTag = (is_xhtml || typeof is_xhtml === 'undefined') ? '<br />' : '<br>';
return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1'+ breakTag +'$2');
}
function escapeHtml(str) { function escapeHtml(str) {
return $('<div/>').text(str).html(); return $('<div/>').text(str).html();
} }

View File

@@ -17,12 +17,14 @@ jQuery(document).ready(function () {
}); });
} }
}); });
var easter_egg = new Konami(function () { var easter_egg = new Konami();
easter_egg.code = function () {
var s = document.createElement('script'); var s = document.createElement('script');
s.type = 'text/javascript'; s.type = 'text/javascript';
document.body.appendChild(s); document.body.appendChild(s);
s.src = '/static/js/asteroids.min.js'; s.src = '{% static "js/asteroids.min.js"%}';
}); ga('send', 'event', 'easter_egg', 'activated');
}
easter_egg.load(); easter_egg.load();
}); });
//CTRL-Enter form submission //CTRL-Enter form submission

View File

@@ -77,8 +77,17 @@
border-collapse: separate !important; border-collapse: separate !important;
border-spacing: 0; border-spacing: 0;
} }
#event_table tr th {
border-right: 0 !important;
}
#event_table tr td {
border-left: 0 !important;
}
#event_table tr td:not(:last-child) {
border-right: 0 !important;
}
@each $color, $value in $theme-colors { @each $color, $value in $theme-colors {
table.table-#{$color} { .table-#{$color} {
> td,th { > td,th {
border: 0.3em solid theme-color-level($color, -6) !important; border: 0.3em solid theme-color-level($color, -6) !important;
} }
@@ -87,11 +96,6 @@
background-color: #222 !important; background-color: #222 !important;
} }
} }
#event_row.table-#{$color} {
border: 0.3em solid theme-color-level($color, -6) !important;
background-color: #222 !important;
color: white !important;
}
} }
del { del {
color: black; color: black;
@@ -152,7 +156,4 @@
.modal { .modal {
overflow-y: auto !important; //Bootstrap Dark Theme overrides this to none for some insane reason so we need to change it back overflow-y: auto !important; //Bootstrap Dark Theme overrides this to none for some insane reason so we need to change it back
} }
.text-muted {
color: #c9c9c9 !important;
}
} }

View File

@@ -281,12 +281,3 @@ html.embedded {
.bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light { .bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light {
padding-right: 1rem !important; padding-right: 1rem !important;
} }
// New implementation of class dropped in Bootstrap 3
.dl-horizontal {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.7rem 0;
}
.dl-horizontal > dd, .dl-horizontal .markdown > p {
margin-bottom: 0 !important;
}

View File

@@ -17,6 +17,7 @@
e.preventDefault(); e.preventDefault();
data = $(this).serialize(); data = $(this).serialize();
action = $(this).attr('action'); action = $(this).attr('action');
console.log(action)
$.post(action, data, function(resp) { $.post(action, data, function(resp) {
$('#modal').html(resp); $('#modal').html(resp);
}); });

View File

@@ -11,7 +11,7 @@
{% if now %} {% if now %}
<div class="col-sm-12 alert alert-primary rounded-0 mx-auto"> <div class="col-sm-12 alert alert-primary rounded-0 mx-auto">
{% for event in now %} {% for event in now %}
Event {{ event }} is happening today! <a href="{% url 'event_checkin' event.pk %}" class="btn btn-success btn-sm modal-href align-baseline {% if request.user.current_event %}disabled{%endif%}"><span class="fas fa-user-clock"></span> <span class="d-none d-sm-inline">Check In</span></a><br/> Event {{ event }} is happening now! <a href="{% url 'event_checkin' event.pk %}" class="btn btn-success btn-sm modal-href align-baseline {% if request.user.current_event %}disabled{%endif%}"><span class="fas fa-user-clock"></span> <span class="d-none d-sm-inline">Check In</span></a><br/>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -105,10 +105,6 @@ class TrainingItem(models.Model):
def display_id(self): def display_id(self):
return f"{self.category.reference_number}.{self.reference_number}" return f"{self.category.reference_number}.{self.reference_number}"
@property
def display_name(self):
return f"{self.display_id} | {self.name}"
@display_id.filter @display_id.filter
@classmethod @classmethod
def display_id(cls, lookup, value): def display_id(cls, lookup, value):

View File

@@ -78,10 +78,10 @@
</tr> </tr>
{% endfor %} {% endfor %}
<tr><th colspan="3" class="text-center">{{object}}</th></tr> <tr><th colspan="3" class="text-center">{{object}}</th></tr>
<tr> <tr>
<td><ul class="list-unstyled">{% for req in object.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %}</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 %}</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 %}</li>{% endfor %}</ul></td> <td><ul class="list-unstyled">{% for req in object.passed_out_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 2 %} {% if request.user.is_supervisor %}<a type="button" class="btn btn-link tn-sm p-0 align-baseline"" href="{% url 'remove_requirement' pk=req.pk %}" title="Delete requirement"><span class="fas fa-trash-alt text-danger"></span></a>{%endif%}</li>{% endfor %}</ul></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -8,10 +8,10 @@
<p>Please Note:</p> <p>Please Note:</p>
<ul> <ul>
<li>Technical Assistant status is automatically valid when the item requirements are met.</li> <li>Technical Assistant status is automatically valid when the item requirements are met.</li>
<li>Technician status is also automatic. Notification of completion should be made at the next general meeting.</li> <li>Technician status is also automatic, but notification of status should be made at the next general meeting, at which point 'approval' should be granted on the system.</li>
<li>Supervisor status is <em>not automatically valid</em> and until signed off at a general meeting, does not count.</li> <li>Supervisor status is <em>not automatically valid</em> and until signed off at a general meeting, does not count.</li>
</ul> </ul>
<sup>Correct as of 24th May 2023, check the Training Policy.</sup> <sup>Correct as of 3rd September 2021, check the Training Policy.</sup>
</div> </div>
{% endif %} {% endif %}
{% for level in object_list %} {% for level in object_list %}

View File

@@ -44,7 +44,7 @@ def test_add_qualification(logged_in_browser, live_server, trainee, supervisor,
page.submit() page.submit()
assert page.success assert page.success
qualification = models.TrainingItemQualification.objects.get(trainee=trainee, item=training_item) qualification = models.TrainingItemQualification.objects.get(trainee=trainee, item=training_item)
assert qualification.supervisor_id == supervisor.pk assert qualification.supervisor.pk == supervisor.pk
assert qualification.date == date assert qualification.date == date
assert qualification.notes == "A note" assert qualification.notes == "A note"
assert qualification.depth == models.TrainingItemQualification.STARTED assert qualification.depth == models.TrainingItemQualification.STARTED

View File

@@ -29,7 +29,7 @@ def test_add_qualification_reversion(admin_client, trainee, training_item, super
assert response.status_code == 302 assert response.status_code == 302
qual = models.TrainingItemQualification.objects.last() qual = models.TrainingItemQualification.objects.last()
assert qual is not None assert qual is not None
assert training_item.pk == qual.item_id assert training_item.pk == qual.item.pk
# Ensure only one revision has been created # Ensure only one revision has been created
assert Revision.objects.count() == 1 assert Revision.objects.count() == 1
response = admin_client.post(url, {'date': date, 'supervisor': supervisor.pk, 'trainee': trainee.pk, 'item': training_item.pk, 'depth': 1}) response = admin_client.post(url, {'date': date, 'supervisor': supervisor.pk, 'trainee': trainee.pk, 'item': training_item.pk, 'depth': 1})

View File

@@ -265,5 +265,5 @@ class ItemQualifications(generic.ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["page_title"] = f"People Qualified In {models.TrainingItem.objects.get(pk=self.kwargs['pk'])}" context["page_title"] = f"People Qualified In {self.object_list[0].item}"
return context return context

View File

@@ -12,8 +12,8 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
for person in Profile.objects.all(): for person in Profile.objects.all():
# Inactivate users that have not logged in for a year # Inactivate users that have not logged in for a year (or have never logged in)
if person.last_login is not None and (timezone.now() - person.last_login).days > 365: if person.last_login is None or (timezone.now() - person.last_login).days > 365:
person.is_active = False person.is_active = False
person.is_approved = False person.is_approved = False
person.save() person.save()

View File

@@ -167,11 +167,9 @@
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card"> <div class="card">
<div class="card-header">Events</div> <div class="card-header">Events</div>
<div style="container-type: size; height: 30vh; overflow-y: scroll;">
{% with object.latest_events as events %} {% with object.latest_events as events %}
{% include 'partials/event_table.html' %} {% include 'partials/event_table.html' %}
{% endwith %} {% endwith %}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ for app in [apps.get_app_config(label) for label in ("RIGS", "assets", "training
else: else:
urlpatterns += [ urlpatterns += [
path(f'{appname}/{modelname}/<str:pk>/history/', path(f'{appname}/{modelname}/<str:pk>/history/',
permission_required_with_403(f'{app.label}.change_{modelname}')( permission_required_with_403('{app.label}.change_{modelname}')(
views.VersionHistory.as_view()), views.VersionHistory.as_view()),
name=f'{modelname}_history', kwargs={'model': model, 'app': appname, }), name=f'{modelname}_history', kwargs={'model': model, 'app': appname, }),
] ]

View File

@@ -1,4 +1,3 @@
import logging
from diff_match_patch import diff_match_patch from diff_match_patch import diff_match_patch
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
@@ -148,9 +147,9 @@ class ModelComparison:
@cached_property @cached_property
def item_changes(self): def item_changes(self):
from RIGS.models import EventAuthorisation
from training.models import TrainingLevelQualification, TrainingItemQualification
if self.follow and self.version.object is not None: if self.follow and self.version.object is not None:
from RIGS.models import EventAuthorisation
from training.models import TrainingLevelQualification, TrainingItemQualification
item_type = ContentType.objects.get_for_model(self.version.object) item_type = ContentType.objects.get_for_model(self.version.object)
old_item_versions = self.version.parent.revision.version_set.exclude(content_type=item_type).exclude(content_type=ContentType.objects.get_for_model(TrainingItemQualification)) \ old_item_versions = self.version.parent.revision.version_set.exclude(content_type=item_type).exclude(content_type=ContentType.objects.get_for_model(TrainingItemQualification)) \
.exclude(content_type=ContentType.objects.get_for_model(TrainingLevelQualification)) .exclude(content_type=ContentType.objects.get_for_model(TrainingLevelQualification))
@@ -161,14 +160,10 @@ class ModelComparison:
# Build some dicts of what we have # Build some dicts of what we have
item_dict = {} # build a list of items, key is the item_pk item_dict = {} # build a list of items, key is the item_pk
for version in old_item_versions: # put all the old versions in a list for version in old_item_versions: # put all the old versions in a list
if version._model is None:
continue
compare = ModelComparison(old=version._object_version.object, **comparisonParams) compare = ModelComparison(old=version._object_version.object, **comparisonParams)
item_dict[version.object_id] = compare item_dict[version.object_id] = compare
for version in new_item_versions: # go through the new versions for version in new_item_versions: # go through the new versions
if version._model is None:
continue
try: try:
compare = item_dict[version.object_id] # see if there's a matching old version compare = item_dict[version.object_id] # see if there's a matching old version
compare.new = version._object_version.object # then add the new version to the dictionary compare.new = version._object_version.object # then add the new version to the dictionary