From a0491891e9f8a7c776a5da548286497133a92616 Mon Sep 17 00:00:00 2001 From: Arona Jones Date: Mon, 13 Apr 2020 15:54:43 +0100 Subject: [PATCH 1/4] Add 'CableTypes' (#406) * Move relevant fields and create migration to autogen cable types * CRUD and ordering * FIX: Prevent creating duplicate cable types * FIX: pep8/remove debug print * FIX: Meta migrations... :> * FIX: Update tests to match new UX * Move cabletype menu links into 'Assets' dropdown * Fix migration * Specify version of reportlab Should fix CI - looks like I went a bit too ham-handed in my requirements.txt purge last time... --- assets/admin.py | 7 ++- assets/forms.py | 15 +++++ .../commands/generateSampleAssetsData.py | 8 +-- assets/migrations/0011_auto_20200218_1617.py | 29 +++++++++ assets/migrations/0012_auto_20200218_1627.py | 26 ++++++++ assets/migrations/0013_auto_20200218_1639.py | 29 +++++++++ assets/migrations/0014_auto_20200218_1840.py | 17 ++++++ assets/models.py | 42 ++++++++----- assets/templates/cable_type_form.html | 61 +++++++++++++++++++ assets/templates/cable_type_list.html | 41 +++++++++++++ assets/templates/partials/asset_form.html | 5 +- assets/templates/partials/cable_form.html | 33 +++------- assets/tests/pages.py | 5 +- assets/tests/test_assets.py | 26 +++----- assets/urls.py | 5 ++ assets/views.py | 42 +++++++++++++ requirements.txt | 1 + templates/base_assets.html | 48 ++++++++------- 18 files changed, 349 insertions(+), 91 deletions(-) create mode 100644 assets/migrations/0011_auto_20200218_1617.py create mode 100644 assets/migrations/0012_auto_20200218_1627.py create mode 100644 assets/migrations/0013_auto_20200218_1639.py create mode 100644 assets/migrations/0014_auto_20200218_1840.py create mode 100644 assets/templates/cable_type_form.html create mode 100644 assets/templates/cable_type_list.html diff --git a/assets/admin.py b/assets/admin.py index 3e6c9d58..ca39ca3b 100644 --- a/assets/admin.py +++ b/assets/admin.py @@ -23,10 +23,15 @@ class SupplierAdmin(admin.ModelAdmin): @admin.register(assets.Asset) class AssetAdmin(admin.ModelAdmin): list_display = ['id', 'asset_id', 'description', 'category', 'status'] - list_filter = ['is_cable', 'category'] + list_filter = ['is_cable', 'category', 'status'] search_fields = ['id', 'asset_id', 'description'] +@admin.register(assets.CableType) +class CableTypeAdmin(admin.ModelAdmin): + list_display = ['id', '__str__', 'plug', 'socket', 'cores', 'circuits'] + + @admin.register(assets.Connector) class ConnectorAdmin(admin.ModelAdmin): list_display = ['id', '__str__', 'current_rating', 'voltage_rating', 'num_pins'] diff --git a/assets/forms.py b/assets/forms.py index ea05efd3..8fd07179 100644 --- a/assets/forms.py +++ b/assets/forms.py @@ -1,6 +1,7 @@ from django import forms from assets import models +from django.db.models import Q class AssetForm(forms.ModelForm): @@ -34,3 +35,17 @@ class SupplierForm(forms.ModelForm): class SupplierSearchForm(forms.Form): query = forms.CharField(required=False) + + +class CableTypeForm(forms.ModelForm): + class Meta: + model = models.CableType + fields = '__all__' + + def clean(self): + form_data = self.cleaned_data + queryset = models.CableType.objects.filter(Q(plug=form_data['plug']) & Q(socket=form_data['socket']) & Q(circuits=form_data['circuits']) & Q(cores=form_data['cores'])) + # Being identical to itself shouldn't count... + if queryset.exists() and self.instance.pk != queryset[0].pk: + raise forms.ValidationError("A cable type that exactly matches this one already exists, please use that instead.", code="notunique") + return form_data diff --git a/assets/management/commands/generateSampleAssetsData.py b/assets/management/commands/generateSampleAssetsData.py index 258551c2..67b8cc9d 100644 --- a/assets/management/commands/generateSampleAssetsData.py +++ b/assets/management/commands/generateSampleAssetsData.py @@ -78,6 +78,9 @@ class Command(BaseCommand): suppliers = models.Supplier.objects.all() connectors = models.Connector.objects.all() + for i in range(len(connectors)): + models.CableType.objects.create(plug=random.choice(connectors), socket=random.choice(connectors), circuits=random.choice(circuits), cores=random.choice(cores)) + for i in range(100): asset = models.Asset( asset_id='{}'.format(models.Asset.get_available_asset_id()), @@ -87,12 +90,9 @@ class Command(BaseCommand): date_acquired=timezone.now().date(), is_cable=True, - plug=random.choice(connectors), - socket=random.choice(connectors), + cable_type=random.choice(models.CableType.objects.all()), csa=random.choice(csas), length=random.choice(lengths), - circuits=random.choice(circuits), - cores=random.choice(circuits) ) if i % 5 == 0: diff --git a/assets/migrations/0011_auto_20200218_1617.py b/assets/migrations/0011_auto_20200218_1617.py new file mode 100644 index 00000000..2debea8c --- /dev/null +++ b/assets/migrations/0011_auto_20200218_1617.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.13 on 2020-02-18 16:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0010_auto_20200219_1444'), + ] + + operations = [ + migrations.CreateModel( + name='CableType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('circuits', models.IntegerField(blank=True, null=True)), + ('cores', models.IntegerField(blank=True, null=True)), + ('plug', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plug', to='assets.Connector')), + ('socket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socket', to='assets.Connector')), + ], + ), + migrations.AddField( + model_name='asset', + name='cable_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.CableType'), + ), + ] diff --git a/assets/migrations/0012_auto_20200218_1627.py b/assets/migrations/0012_auto_20200218_1627.py new file mode 100644 index 00000000..bf61b845 --- /dev/null +++ b/assets/migrations/0012_auto_20200218_1627.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.13 on 2020-02-18 16:27 + +from django.db import migrations +from django.db.models import Q + + +def move_cable_type_data(apps, schema_editor): + Asset = apps.get_model('assets', 'Asset') + CableType = apps.get_model('assets', 'CableType') + for asset in Asset.objects.filter(is_cable=True): + # Only create one type per...well...type + if(not CableType.objects.filter(Q(plug=asset.plug) & Q(socket=asset.socket))): + cabletype = CableType.objects.create(plug=asset.plug, socket=asset.socket, circuits=asset.circuits, cores=asset.cores) + asset.save() + cabletype.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0011_auto_20200218_1617'), + ] + + operations = [ + migrations.RunPython(move_cable_type_data) + ] diff --git a/assets/migrations/0013_auto_20200218_1639.py b/assets/migrations/0013_auto_20200218_1639.py new file mode 100644 index 00000000..c7cebec1 --- /dev/null +++ b/assets/migrations/0013_auto_20200218_1639.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.13 on 2020-02-18 16:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0012_auto_20200218_1627'), + ] + + operations = [ + migrations.RemoveField( + model_name='asset', + name='circuits', + ), + migrations.RemoveField( + model_name='asset', + name='cores', + ), + migrations.RemoveField( + model_name='asset', + name='plug', + ), + migrations.RemoveField( + model_name='asset', + name='socket', + ), + ] diff --git a/assets/migrations/0014_auto_20200218_1840.py b/assets/migrations/0014_auto_20200218_1840.py new file mode 100644 index 00000000..a5c36c40 --- /dev/null +++ b/assets/migrations/0014_auto_20200218_1840.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.13 on 2020-02-18 18:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0013_auto_20200218_1639'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cabletype', + options={'ordering': ['plug', 'socket', '-circuits']}, + ), + ] diff --git a/assets/models.py b/assets/models.py index a6c767f1..4f6f96ec 100644 --- a/assets/models.py +++ b/assets/models.py @@ -63,6 +63,21 @@ class Connector(models.Model): return self.description +class CableType(models.Model): + class Meta: + ordering = ['plug', 'socket', '-circuits'] + + circuits = models.IntegerField(blank=True, null=True) + cores = models.IntegerField(blank=True, null=True) + plug = models.ForeignKey(Connector, on_delete=models.SET_NULL, + related_name='plug', blank=True, null=True) + socket = models.ForeignKey(Connector, on_delete=models.SET_NULL, + related_name='socket', blank=True, null=True) + + def __str__(self): + return "%s → %s" % (self.plug.description, self.socket.description) + + @reversion.register class Asset(models.Model, RevisionMixin): class Meta: @@ -88,16 +103,11 @@ class Asset(models.Model, RevisionMixin): # Cable assets is_cable = models.BooleanField(default=False) - plug = models.ForeignKey(Connector, on_delete=models.SET_NULL, - related_name='plug', blank=True, null=True) - socket = models.ForeignKey(Connector, on_delete=models.SET_NULL, - related_name='socket', blank=True, null=True) + cable_type = models.ForeignKey(to=CableType, blank=True, null=True, on_delete=models.SET_NULL) length = models.DecimalField(decimal_places=1, max_digits=10, blank=True, null=True, help_text='m') csa = models.DecimalField(decimal_places=2, max_digits=10, blank=True, null=True, help_text='mm^2') - circuits = models.IntegerField(blank=True, null=True) - cores = models.IntegerField(blank=True, null=True) # Hidden asset_id components # For example, if asset_id was "C1001" then asset_id_prefix would be "C" and number "1001" @@ -127,7 +137,7 @@ class Asset(models.Model, RevisionMixin): def __str__(self): out = str(self.asset_id) + ' - ' + self.description if self.is_cable: - out += '{} - {}m - {}'.format(self.plug, self.length, self.socket) + out += '{} - {}m - {}'.format(self.cable_type.plug, self.length, self.cable_type.socket) return out def clean(self): @@ -152,14 +162,16 @@ class Asset(models.Model, RevisionMixin): errdict["length"] = ["The length of a cable must be more than 0"] if not self.csa or self.csa <= 0: errdict["csa"] = ["The CSA of a cable must be more than 0"] - if not self.circuits or self.circuits <= 0: - errdict["circuits"] = ["There must be at least one circuit in a cable"] - if not self.cores or self.cores <= 0: - errdict["cores"] = ["There must be at least one core in a cable"] - if self.socket is None: - errdict["socket"] = ["A cable must have a socket"] - if self.plug is None: - errdict["plug"] = ["A cable must have a plug"] + if not self.cable_type: + errdict["cable_type"] = ["A cable must have a type"] + # if not self.circuits or self.circuits <= 0: + # errdict["circuits"] = ["There must be at least one circuit in a cable"] + # if not self.cores or self.cores <= 0: + # errdict["cores"] = ["There must be at least one core in a cable"] + # if self.socket is None: + # errdict["socket"] = ["A cable must have a socket"] + # if self.plug is None: + # errdict["plug"] = ["A cable must have a plug"] if errdict != {}: # If there was an error when validation raise ValidationError(errdict) diff --git a/assets/templates/cable_type_form.html b/assets/templates/cable_type_form.html new file mode 100644 index 00000000..3f44ed71 --- /dev/null +++ b/assets/templates/cable_type_form.html @@ -0,0 +1,61 @@ +{% extends 'base_assets.html' %} +{% load widget_tweaks %} +{% block title %}Cable Type{% endblock %} + +{% block content %} + +{% if create %} +
+{% elif edit %} + +{% endif %} +{% include 'form_errors.html' %} +{% csrf_token %} + +
+
+ {% if create or edit %} +
+ + {% render_field form.plug|add_class:'form-control'%} +
+
+ + {% render_field form.socket|add_class:'form-control'%} +
+
+ + {% render_field form.circuits|add_class:'form-control' value=object.circuits %} +
+
+ + {% render_field form.cores|add_class:'form-control' value=object.cores %} +
+
+ +
+ +
+ {% else %} +
+
Socket
+
{{ object.socket|default_if_none:'-' }}
+ +
Plug
+
{{ object.plug|default_if_none:'-' }}
+ +
Circuits
+
{{ object.circuits|default_if_none:'-' }}
+ +
Cores
+
{{ object.cores|default_if_none:'-' }}
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/assets/templates/cable_type_list.html b/assets/templates/cable_type_list.html new file mode 100644 index 00000000..74e97407 --- /dev/null +++ b/assets/templates/cable_type_list.html @@ -0,0 +1,41 @@ +{% extends 'base_assets.html' %} +{% block title %}Supplier List{% endblock %} +{% load paginator from filters %} +{% load widget_tweaks %} + +{% block content %} + + + + + + + + + + + + + {% for item in object_list %} + + + + + + + {% endfor %} + +
Cable TypeCircuitsCoresQuick Links
{{ item }}{{ item.circuits }}{{ item.cores }} + View + Edit +
+ +{% if is_paginated %} +
+ {% paginator %} +
+{% endif %} + +{% endblock %} diff --git a/assets/templates/partials/asset_form.html b/assets/templates/partials/asset_form.html index 45424992..a1fd4c88 100644 --- a/assets/templates/partials/asset_form.html +++ b/assets/templates/partials/asset_form.html @@ -23,7 +23,6 @@ {% render_field form.category|add_class:'form-control'%} - {% render_field form.is_cable|attr:'onchange=checkIfCableHidden()' %}
{% render_field form.status|add_class:'form-control'%} @@ -32,6 +31,10 @@ {% render_field form.serial_number|add_class:'form-control' value=object.serial_number %}
+
+ + {% render_field form.is_cable|attr:'onchange=checkIfCableHidden()' %} +
diff --git a/assets/templates/partials/cable_form.html b/assets/templates/partials/cable_form.html index e5deb006..e8aa7607 100644 --- a/assets/templates/partials/cable_form.html +++ b/assets/templates/partials/cable_form.html @@ -6,12 +6,10 @@
{% if create or edit or duplicate %}
- - {% render_field form.plug|add_class:'form-control'%} -
-
- - {% render_field form.socket|add_class:'form-control'%} + +
+ {% render_field form.cable_type|add_class:'form-control' %} +
@@ -27,33 +25,16 @@ {{ form.csa.help_text }}
-
- - {% render_field form.circuits|add_class:'form-control' value=object.circuits %} -
-
- - {% render_field form.cores|add_class:'form-control' value=object.cores %} -
{% else %}
-
Socket
-
{{ object.socket|default_if_none:'-' }}
- -
Plug
-
{{ object.plug|default_if_none:'-' }}
+
Cable Type
+
{{ object.cable_type|default_if_none:'-' }}
Length
{{ object.length|default_if_none:'-' }}m
Cross Sectional Area
-
{{ object.csa|default_if_none:'-' }}m^2
- -
Circuits
-
{{ object.circuits|default_if_none:'-' }}
- -
Cores
-
{{ object.cores|default_if_none:'-' }}
+
{{ object.csa|default_if_none:'-' }}mm²
{% endif %}
diff --git a/assets/tests/pages.py b/assets/tests/pages.py index e31859e1..c68d2568 100644 --- a/assets/tests/pages.py +++ b/assets/tests/pages.py @@ -82,12 +82,9 @@ class AssetForm(FormPage): 'category': (regions.SingleSelectPicker, (By.ID, 'id_category')), 'status': (regions.SingleSelectPicker, (By.ID, 'id_status')), - 'plug': (regions.SingleSelectPicker, (By.ID, 'id_plug')), - 'socket': (regions.SingleSelectPicker, (By.ID, 'id_socket')), + 'cable_type': (regions.SingleSelectPicker, (By.ID, 'id_cable_type')), 'length': (regions.TextBox, (By.ID, 'id_length')), 'csa': (regions.TextBox, (By.ID, 'id_csa')), - 'circuits': (regions.TextBox, (By.ID, 'id_circuits')), - 'cores': (regions.TextBox, (By.ID, 'id_cores')) } @property diff --git a/assets/tests/test_assets.py b/assets/tests/test_assets.py index 37cf816a..06d35701 100644 --- a/assets/tests/test_assets.py +++ b/assets/tests/test_assets.py @@ -98,6 +98,7 @@ class TestAssetForm(AutoLoginTest): self.supplier = models.Supplier.objects.create(name="Fullmetal Heavy Industry") self.parent = models.Asset.objects.create(asset_id="9000", description="Shelf", status=self.status, category=self.category, date_acquired=datetime.date(2000, 1, 1)) self.connector = models.Connector.objects.create(description="IEC", current_rating=10, voltage_rating=240, num_pins=3) + self.cable_type = models.CableType.objects.create(plug=self.connector, socket=self.connector, circuits=1, cores=3) self.page = pages.AssetCreate(self.driver, self.live_server_url).open() def test_asset_create(self): @@ -154,12 +155,10 @@ class TestAssetForm(AutoLoginTest): self.page.is_cable = True self.assertTrue(self.driver.find_element_by_id('cable-table').is_displayed()) - self.page.plug = "IEC" + self.page.cable_type = "IEC → IEC" self.page.socket = "IEC" self.page.length = 10 self.page.csa = "1.5" - self.page.circuits = 1 - self.page.cores = 3 self.page.submit() self.assertTrue(self.page.success) @@ -375,7 +374,8 @@ class TestFormValidation(TestCase): cls.status = models.AssetStatus.objects.create(name="Broken", should_show=True) cls.asset = models.Asset.objects.create(asset_id="9999", description="The Office", status=cls.status, category=cls.category, date_acquired=datetime.date(2018, 6, 15)) cls.connector = models.Connector.objects.create(description="16A IEC", current_rating=16, voltage_rating=240, num_pins=3) - cls.cable_asset = models.Asset.objects.create(asset_id="666", description="125A -> Jack", comments="The cable from Hell...", status=cls.status, category=cls.category, date_acquired=datetime.date(2006, 6, 6), is_cable=True, plug=cls.connector, socket=cls.connector, length=10, csa="1.5", circuits=1, cores=3) + cls.cable_type = models.CableType.objects.create(circuits=11, cores=3, plug=cls.connector, socket=cls.connector) + cls.cable_asset = models.Asset.objects.create(asset_id="666", description="125A -> Jack", comments="The cable from Hell...", status=cls.status, category=cls.category, date_acquired=datetime.date(2006, 6, 6), is_cable=True, cable_type=cls.cable_type, length=10, csa="1.5") def setUp(self): self.profile.set_password('testuser') @@ -399,12 +399,9 @@ class TestFormValidation(TestCase): response = self.client.post(url, {'asset_id': 'X$%A', 'is_cable': True}) self.assertFormError(response, 'form', 'asset_id', 'An Asset ID can only consist of letters and numbers, with a final number') - self.assertFormError(response, 'form', 'plug', 'A cable must have a plug') - self.assertFormError(response, 'form', 'socket', 'A cable must have a socket') + self.assertFormError(response, 'form', 'cable_type', 'A cable must have a type') self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') - self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') - self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') # Given that validation is done at model level it *shouldn't* need retesting...gonna do it anyway! def test_asset_edit(self): @@ -422,24 +419,19 @@ class TestFormValidation(TestCase): def test_cable_edit(self): url = reverse('asset_update', kwargs={'pk': self.cable_asset.asset_id}) # TODO Why do I have to send is_cable=True here? - response = self.client.post(url, {'is_cable': True, 'length': -3, 'csa': -3, 'circuits': -4, 'cores': -8}) + response = self.client.post(url, {'is_cable': True, 'length': -3, 'csa': -3}) - # Can't figure out how to select the 'none' option... - # self.assertFormError(response, 'form', 'plug', 'A cable must have a plug') - # self.assertFormError(response, 'form', 'socket', 'A cable must have a socket') + # TODO Can't figure out how to select the 'none' option... + # self.assertFormError(response, 'form', 'cable_type', 'A cable must have a type') self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') - self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') - self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') def test_asset_duplicate(self): url = reverse('asset_duplicate', kwargs={'pk': self.cable_asset.asset_id}) - response = self.client.post(url, {'is_cable': True, 'length': 0, 'csa': 0, 'circuits': 0, 'cores': 0}) + response = self.client.post(url, {'is_cable': True, 'length': 0, 'csa': 0}) self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') - self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') - self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') class TestSampleDataGenerator(TestCase): diff --git a/assets/urls.py b/assets/urls.py index dc8e021c..8bc3c2a0 100644 --- a/assets/urls.py +++ b/assets/urls.py @@ -22,6 +22,11 @@ urlpatterns = [ path('activity', permission_required_with_403('assets.view_asset') (views.ActivityTable.as_view()), name='asset_activity_table'), + path('cabletype/list/', permission_required_with_403('assets.view_cable_type')(views.CableTypeList.as_view()), name='cable_type_list'), + path('cabletype/create/', permission_required_with_403('assets.add_cable_type')(views.CableTypeCreate.as_view()), name='cable_type_create'), + path('cabletype//update/', permission_required_with_403('assets.change_cable_type')(views.CableTypeUpdate.as_view()), name='cable_type_update'), + path('cabletype//detail/', permission_required_with_403('assets.view_cable_type')(views.CableTypeDetail.as_view()), name='cable_type_detail'), + path('asset/search/', views.AssetSearch.as_view(), name='asset_search_json'), path('asset/id//embed/', xframe_options_exempt( diff --git a/assets/views.py b/assets/views.py index 29eaa7aa..29e86872 100644 --- a/assets/views.py +++ b/assets/views.py @@ -252,3 +252,45 @@ class ActivityTable(versioning.ActivityTable): versions = versioning.RIGSVersion.objects.get_for_multiple_models( [models.Asset, models.Supplier]) return versions + + +class CableTypeList(generic.ListView): + model = models.CableType + template_name = 'cable_type_list.html' + paginate_by = 40 + # ordering = ['__str__'] + + +class CableTypeDetail(generic.DetailView): + model = models.CableType + template_name = 'cable_type_form.html' + + +class CableTypeCreate(generic.CreateView): + model = models.CableType + template_name = "cable_type_form.html" + form_class = forms.CableTypeForm + + def get_context_data(self, **kwargs): + context = super(CableTypeCreate, self).get_context_data(**kwargs) + context["create"] = True + + return context + + def get_success_url(self): + return reverse("cable_type_detail", kwargs={"pk": self.object.pk}) + + +class CableTypeUpdate(generic.UpdateView): + model = models.CableType + template_name = "cable_type_form.html" + form_class = forms.CableTypeForm + + def get_context_data(self, **kwargs): + context = super(CableTypeUpdate, self).get_context_data(**kwargs) + context["edit"] = True + + return context + + def get_success_url(self): + return reverse("cable_type_detail", kwargs={"pk": self.object.pk}) diff --git a/requirements.txt b/requirements.txt index e737650a..569e0366 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,5 @@ requests==2.23.0 selenium==3.141.0 simplejson==3.17.0 whitenoise==5.0.1 +reportlab==3.4.0 z3c.rml==3.9.1 diff --git a/templates/base_assets.html b/templates/base_assets.html index 636c70df..7f226963 100644 --- a/templates/base_assets.html +++ b/templates/base_assets.html @@ -10,29 +10,31 @@ {% endblock %} {% block titleelements %} - {# % if perms.assets.view_asset % #} - - {# % endif % #} - {# % if perms.assets.view_supplier % #} - - {# % endif % #} + + {% if perms.assets.view_asset %}
  • Recent Changes
  • {% endif %} From be4a7baf8e6665981cf4c871f6ae23afdd655fd9 Mon Sep 17 00:00:00 2001 From: FreneticScribbler Date: Mon, 13 Apr 2020 16:14:06 +0100 Subject: [PATCH 2/4] Remove obsolete 'next_scheduled_maint' from asset model Should fix production data... --- .../0015_remove_asset_next_sched_maint.py | 17 +++++++++++++++++ assets/models.py | 1 - 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 assets/migrations/0015_remove_asset_next_sched_maint.py diff --git a/assets/migrations/0015_remove_asset_next_sched_maint.py b/assets/migrations/0015_remove_asset_next_sched_maint.py new file mode 100644 index 00000000..a6a63f44 --- /dev/null +++ b/assets/migrations/0015_remove_asset_next_sched_maint.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-04-13 15:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0014_auto_20200218_1840'), + ] + + operations = [ + migrations.RemoveField( + model_name='asset', + name='next_sched_maint', + ), + ] diff --git a/assets/models.py b/assets/models.py index 4f6f96ec..2b709a94 100644 --- a/assets/models.py +++ b/assets/models.py @@ -99,7 +99,6 @@ class Asset(models.Model, RevisionMixin): purchase_price = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10) salvage_value = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10) comments = models.TextField(blank=True) - next_sched_maint = models.DateField(blank=True, null=True) # Cable assets is_cable = models.BooleanField(default=False) From 0fe7d55eab6e2341c503055b9f8c727ea8597aed Mon Sep 17 00:00:00 2001 From: FreneticScribbler Date: Mon, 13 Apr 2020 16:33:57 +0100 Subject: [PATCH 3/4] Fix for existing invalid cable types Also hotfix against more in the future. Proper rework needed...This is why I should have waited for review...! Lesson learnt? --- assets/migrations/0016_auto_20200413_1632.py | 34 ++++++++++++++++++++ assets/models.py | 18 +++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 assets/migrations/0016_auto_20200413_1632.py diff --git a/assets/migrations/0016_auto_20200413_1632.py b/assets/migrations/0016_auto_20200413_1632.py new file mode 100644 index 00000000..c8b3e781 --- /dev/null +++ b/assets/migrations/0016_auto_20200413_1632.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.3 on 2020-04-13 15:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0015_remove_asset_next_sched_maint'), + ] + + operations = [ + migrations.AlterField( + model_name='cabletype', + name='circuits', + field=models.IntegerField(default=1), + ), + migrations.AlterField( + model_name='cabletype', + name='cores', + field=models.IntegerField(default=3), + ), + migrations.AlterField( + model_name='cabletype', + name='plug', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plug', to='assets.Connector'), + ), + migrations.AlterField( + model_name='cabletype', + name='socket', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='socket', to='assets.Connector'), + ), + ] diff --git a/assets/models.py b/assets/models.py index 2b709a94..9a314a63 100644 --- a/assets/models.py +++ b/assets/models.py @@ -63,19 +63,23 @@ class Connector(models.Model): return self.description +# Things are nullable that shouldn't be because I didn't properly fix the data structure when moving this to its own model... class CableType(models.Model): class Meta: ordering = ['plug', 'socket', '-circuits'] - circuits = models.IntegerField(blank=True, null=True) - cores = models.IntegerField(blank=True, null=True) - plug = models.ForeignKey(Connector, on_delete=models.SET_NULL, - related_name='plug', blank=True, null=True) - socket = models.ForeignKey(Connector, on_delete=models.SET_NULL, - related_name='socket', blank=True, null=True) + circuits = models.IntegerField(default=1) + cores = models.IntegerField(default=3) + plug = models.ForeignKey(Connector, on_delete=models.CASCADE, + related_name='plug', null=True) + socket = models.ForeignKey(Connector, on_delete=models.CASCADE, + related_name='socket', null=True) def __str__(self): - return "%s → %s" % (self.plug.description, self.socket.description) + if self.plug and self.socket: + return "%s → %s" % (self.plug.description, self.socket.description) + else: + return "Unknown" @reversion.register From 3be06a7b250788f7317d3b4955220081e87f5572 Mon Sep 17 00:00:00 2001 From: Arona Jones Date: Tue, 14 Apr 2020 21:11:09 +0100 Subject: [PATCH 4/4] Create initial asset audit framework (#403) * WIP: Basic work on audit * WIP: Audit modal works Need to get the ID search working. * WIP: Javascript shenanigans for asset audit search It's not clean but it works.. * Improve audit search bar Optimise for APM! * Filter asset audit list by never-audited * Added cable functionality to audit form Also improved styling * FIX: Revert partialising of asset search * Various UX Improvements Also rearranged asset detail/edit to be more space efficient * FIX: Remove assets from to-be-audited table when audited Previously required a page reload * Improve sample data generator Does reversion properly and sets colours for asset statuses * FIX: Gracefully handle 404s in audit search * FEAT: Add buttons for some common defaults on audit form TODO: Partialise those fragments and add them to the edit/create forms too. * FIX: Fix asset sample data command when run alone * FEAT: More handy buttons * FIX: Stop quickbuttons being tab-selected If someone's tabbing through, they won't be needing the buttons... * FIX: Hide asset detail buttons for basic users * FIX: Migrations * Start tests for audit * Some deduplication for testing code * Improve asset audit testing * Remember to test the tests Arona * Potentially make modal tests more consistent * FIX?: Up WebDriverWait timeout for modal tests * FIX?: What about this way... * Remake migrations * Fix README badges to point to right branch While I'm here eh :P * Use aware time in audit * Fix migrations again * Fix for my fix... * Modify audit exclusions to properly prevent data loss * pep eiiiiiight --- PyRIGS/tests/pages.py | 31 +--- PyRIGS/tests/regions.py | 24 +++ README.md | 4 +- assets/forms.py | 9 +- .../commands/generateSampleAssetsData.py | 50 +++--- assets/migrations/0017_add_audit.py | 31 ++++ assets/models.py | 8 +- assets/templates/asset_audit.html | 142 ++++++++++++++++++ assets/templates/asset_audit_list.html | 87 +++++++++++ assets/templates/asset_update.html | 19 ++- assets/templates/partials/asset_buttons.html | 51 ++++--- .../partials/asset_list_table_body.html | 8 +- assets/templates/partials/audit_details.html | 8 + assets/tests/pages.py | 99 ++++++++++-- assets/tests/test_assets.py | 75 +++++++++ assets/urls.py | 3 + assets/views.py | 39 ++++- templates/base_assets.html | 1 + 18 files changed, 595 insertions(+), 94 deletions(-) create mode 100644 assets/migrations/0017_add_audit.py create mode 100644 assets/templates/asset_audit.html create mode 100644 assets/templates/asset_audit_list.html create mode 100644 assets/templates/partials/audit_details.html diff --git a/PyRIGS/tests/pages.py b/PyRIGS/tests/pages.py index 4bf34a6d..27d00a52 100644 --- a/PyRIGS/tests/pages.py +++ b/PyRIGS/tests/pages.py @@ -2,6 +2,7 @@ from pypom import Page, Region from selenium.webdriver.common.by import By from selenium.webdriver import Chrome from selenium.common.exceptions import NoSuchElementException +from PyRIGS.tests import regions class BasePage(Page): @@ -34,37 +35,19 @@ class FormPage(BasePage): self.driver.execute_script("Array.from(document.getElementsByTagName(\"input\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") self.driver.execute_script("Array.from(document.getElementsByTagName(\"select\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") + def submit(self): + previous_errors = self.errors + self.find_element(*self._submit_locator).click() + self.wait.until(lambda x: self.errors != previous_errors or self.success) + @property def errors(self): try: - error_page = self.ErrorPage(self, self.find_element(*self._errors_selector)) + error_page = regions.ErrorPage(self, self.find_element(*self._errors_selector)) return error_page.errors except NoSuchElementException: return None - class ErrorPage(Region): - _error_item_selector = (By.CSS_SELECTOR, "dl>span") - - class ErrorItem(Region): - _field_selector = (By.CSS_SELECTOR, "dt") - _error_selector = (By.CSS_SELECTOR, "dd>ul>li") - - @property - def field_name(self): - return self.find_element(*self._field_selector).text - - @property - def errors(self): - return [x.text for x in self.find_elements(*self._error_selector)] - - @property - def errors(self): - error_items = [self.ErrorItem(self, x) for x in self.find_elements(*self._error_item_selector)] - errors = {} - for error in error_items: - errors[error.field_name] = error.errors - return errors - class LoginPage(BasePage): URL_TEMPLATE = '/user/login' diff --git a/PyRIGS/tests/regions.py b/PyRIGS/tests/regions.py index 5dd364ff..562976b5 100644 --- a/PyRIGS/tests/regions.py +++ b/PyRIGS/tests/regions.py @@ -131,3 +131,27 @@ class SingleSelectPicker(Region): def set_value(self, value): picker = Select(self.root) picker.select_by_visible_text(value) + + +class ErrorPage(Region): + _error_item_selector = (By.CSS_SELECTOR, "dl>span") + + class ErrorItem(Region): + _field_selector = (By.CSS_SELECTOR, "dt") + _error_selector = (By.CSS_SELECTOR, "dd>ul>li") + + @property + def field_name(self): + return self.find_element(*self._field_selector).text + + @property + def errors(self): + return [x.text for x in self.find_elements(*self._error_selector)] + + @property + def errors(self): + error_items = [self.ErrorItem(self, x) for x in self.find_elements(*self._error_item_selector)] + errors = {} + for error in error_items: + errors[error.field_name] = error.errors + return errors diff --git a/README.md b/README.md index 50bf51d4..8c0adcbe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TEC PA & Lighting - PyRIGS # -[![Build Status](https://travis-ci.org/nottinghamtec/PyRIGS.svg?branch=develop)](https://travis-ci.org/nottinghamtec/PyRIGS) -[![Coverage Status](https://coveralls.io/repos/github/nottinghamtec/PyRIGS/badge.svg?branch=develop)](https://coveralls.io/github/nottinghamtec/PyRIGS) +[![Build Status](https://travis-ci.org/nottinghamtec/PyRIGS.svg)](https://travis-ci.org/nottinghamtec/PyRIGS) +[![Coverage Status](https://coveralls.io/repos/github/nottinghamtec/PyRIGS/badge.svg)](https://coveralls.io/github/nottinghamtec/PyRIGS) Welcome to TEC PA & Lightings PyRIGS program. This is a reimplementation of the existing Rig Information Gathering System (RIGS) that was developed using Ruby on Rails. diff --git a/assets/forms.py b/assets/forms.py index 8fd07179..580a5f2e 100644 --- a/assets/forms.py +++ b/assets/forms.py @@ -13,7 +13,7 @@ class AssetForm(forms.ModelForm): class Meta: model = models.Asset fields = '__all__' - exclude = ['asset_id_prefix', 'asset_id_number'] + exclude = ['asset_id_prefix', 'asset_id_number', 'last_audited_at', 'last_audited_by'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -21,6 +21,13 @@ class AssetForm(forms.ModelForm): self.fields['date_acquired'].widget.format = '%Y-%m-%d' +class AssetAuditForm(AssetForm): + class Meta(AssetForm.Meta): + # Prevents assets losing existing data that isn't included in the audit form + exclude = ['asset_id_prefix', 'asset_id_number', 'last_audited_at', 'last_audited_by', + 'parent', 'purchased_from', 'purchase_price', 'comments'] + + class AssetSearchForm(forms.Form): query = forms.CharField(required=False) category = forms.ModelMultipleChoiceField(models.AssetCategory.objects.all(), required=False) diff --git a/assets/management/commands/generateSampleAssetsData.py b/assets/management/commands/generateSampleAssetsData.py index 67b8cc9d..a7ddd404 100644 --- a/assets/management/commands/generateSampleAssetsData.py +++ b/assets/management/commands/generateSampleAssetsData.py @@ -1,7 +1,9 @@ import random from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from reversion import revisions as reversion from assets import models +from RIGS import models as rigsmodels class Command(BaseCommand): @@ -15,6 +17,7 @@ class Command(BaseCommand): random.seed('Some object to see the random number generator') + self.create_profile() self.create_categories() self.create_statuses() self.create_suppliers() @@ -22,6 +25,13 @@ class Command(BaseCommand): self.create_connectors() self.create_cables() + # Make sure that there's at least one profile if this command is run standalone + def create_profile(self): + name = "Fred Johnson" + models.Profile.objects.create(username=name.replace(" ", ""), first_name=name.split(" ")[0], last_name=name.split(" ")[-1], + email=name.replace(" ", "") + "@example.com", + initials="".join([j[0].upper() for j in name.split()])) + def create_categories(self): categories = ['Case', 'Video', 'General', 'Sound', 'Lighting', 'Rigging'] @@ -29,17 +39,19 @@ class Command(BaseCommand): models.AssetCategory.objects.create(name=cat) def create_statuses(self): - statuses = [('In Service', True), ('Lost', False), ('Binned', False), ('Sold', False), ('Broken', False)] + statuses = [('In Service', True, 'success'), ('Lost', False, 'warning'), ('Binned', False, 'danger'), ('Sold', False, 'danger'), ('Broken', False, 'warning')] for stat in statuses: - models.AssetStatus.objects.create(name=stat[0], should_show=stat[1]) + models.AssetStatus.objects.create(name=stat[0], should_show=stat[1], display_class=stat[2]) def create_suppliers(self): suppliers = ["Acme, inc.", "Widget Corp", "123 Warehousing", "Demo Company", "Smith and Co.", "Foo Bars", "ABC Telecom", "Fake Brothers", "QWERTY Logistics", "Demo, inc.", "Sample Company", "Sample, inc", "Acme Corp", "Allied Biscuit", "Ankh-Sto Associates", "Extensive Enterprise", "Galaxy Corp", "Globo-Chem", "Mr. Sparkle", "Globex Corporation", "LexCorp", "LuthorCorp", "North Central Positronics", "Omni Consimer Products", "Praxis Corporation", "Sombra Corporation", "Sto Plains Holdings", "Tessier-Ashpool", "Wayne Enterprises", "Wentworth Industries", "ZiffCorp", "Bluth Company", "Strickland Propane", "Thatherton Fuels", "Three Waters", "Water and Power", "Western Gas & Electric", "Mammoth Pictures", "Mooby Corp", "Gringotts", "Thrift Bank", "Flowers By Irene", "The Legitimate Businessmens Club", "Osato Chemicals", "Transworld Consortium", "Universal Export", "United Fried Chicken", "Virtucon", "Kumatsu Motors", "Keedsler Motors", "Powell Motors", "Industrial Automation", "Sirius Cybernetics Corporation", "U.S. Robotics and Mechanical Men", "Colonial Movers", "Corellian Engineering Corporation", "Incom Corporation", "General Products", "Leeding Engines Ltd.", "Blammo", # noqa "Input, Inc.", "Mainway Toys", "Videlectrix", "Zevo Toys", "Ajax", "Axis Chemical Co.", "Barrytron", "Carrys Candles", "Cogswell Cogs", "Spacely Sprockets", "General Forge and Foundry", "Duff Brewing Company", "Dunder Mifflin", "General Services Corporation", "Monarch Playing Card Co.", "Krustyco", "Initech", "Roboto Industries", "Primatech", "Sonky Rubber Goods", "St. Anky Beer", "Stay Puft Corporation", "Vandelay Industries", "Wernham Hogg", "Gadgetron", "Burleigh and Stronginthearm", "BLAND Corporation", "Nordyne Defense Dynamics", "Petrox Oil Company", "Roxxon", "McMahon and Tate", "Sixty Second Avenue", "Charles Townsend Agency", "Spade and Archer", "Megadodo Publications", "Rouster and Sideways", "C.H. Lavatory and Sons", "Globo Gym American Corp", "The New Firm", "SpringShield", "Compuglobalhypermeganet", "Data Systems", "Gizmonic Institute", "Initrode", "Taggart Transcontinental", "Atlantic Northern", "Niagular", "Plow King", "Big Kahuna Burger", "Big T Burgers and Fries", "Chez Quis", "Chotchkies", "The Frying Dutchman", "Klimpys", "The Krusty Krab", "Monks Diner", "Milliways", "Minuteman Cafe", "Taco Grande", "Tip Top Cafe", "Moes Tavern", "Central Perk", "Chasers"] # noqa - for supplier in suppliers: - models.Supplier.objects.create(name=supplier) + with reversion.create_revision(): + for supplier in suppliers: + reversion.set_user(random.choice(rigsmodels.Profile.objects.all())) + models.Supplier.objects.create(name=supplier) def create_assets(self): asset_description = ['Large cable', 'Shiny thing', 'New lights', 'Really expensive microphone', 'Box of fuse flaps', 'Expensive tool we didn\'t agree to buy', 'Cable drums', 'Boring amount of tape', 'Video stuff no one knows how to use', 'More amplifiers', 'Heatshrink'] @@ -48,22 +60,24 @@ class Command(BaseCommand): statuses = models.AssetStatus.objects.all() suppliers = models.Supplier.objects.all() - for i in range(100): - asset = models.Asset( - asset_id='{}'.format(models.Asset.get_available_asset_id()), - description=random.choice(asset_description), - category=random.choice(categories), - status=random.choice(statuses), - date_acquired=timezone.now().date() - ) + with reversion.create_revision(): + for i in range(100): + reversion.set_user(random.choice(rigsmodels.Profile.objects.all())) + asset = models.Asset( + asset_id='{}'.format(models.Asset.get_available_asset_id()), + description=random.choice(asset_description), + category=random.choice(categories), + status=random.choice(statuses), + date_acquired=timezone.now().date() + ) - if i % 4 == 0: - asset.parent = models.Asset.objects.order_by('?').first() + if i % 4 == 0: + asset.parent = models.Asset.objects.order_by('?').first() - if i % 3 == 0: - asset.purchased_from = random.choice(suppliers) - asset.clean() - asset.save() + if i % 3 == 0: + asset.purchased_from = random.choice(suppliers) + asset.clean() + asset.save() def create_cables(self): asset_description = ['The worm', 'Harting without a cap', 'Heavy cable', 'Extension lead', 'IEC cable that we should remember to prep'] diff --git a/assets/migrations/0017_add_audit.py b/assets/migrations/0017_add_audit.py new file mode 100644 index 00000000..7fb0564a --- /dev/null +++ b/assets/migrations/0017_add_audit.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.3 on 2020-04-13 00:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('assets', '0016_auto_20200413_1632'), + ] + + operations = [ + migrations.AddField( + model_name='asset', + name='last_audited_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='asset', + name='last_audited_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audited_by', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='asset', + name='csa', + field=models.DecimalField(blank=True, decimal_places=2, help_text='mm²', max_digits=10, null=True), + ), + ] diff --git a/assets/models.py b/assets/models.py index 9a314a63..6cf41eee 100644 --- a/assets/models.py +++ b/assets/models.py @@ -9,7 +9,7 @@ from django.dispatch.dispatcher import receiver from reversion import revisions as reversion from reversion.models import Version -from RIGS.models import RevisionMixin +from RIGS.models import RevisionMixin, Profile class AssetCategory(models.Model): @@ -104,13 +104,17 @@ class Asset(models.Model, RevisionMixin): salvage_value = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10) comments = models.TextField(blank=True) + # Audit + last_audited_at = models.DateTimeField(blank=True, null=True) + last_audited_by = models.ForeignKey(Profile, on_delete=models.SET_NULL, related_name='audited_by', blank=True, null=True) + # Cable assets is_cable = models.BooleanField(default=False) cable_type = models.ForeignKey(to=CableType, blank=True, null=True, on_delete=models.SET_NULL) length = models.DecimalField(decimal_places=1, max_digits=10, blank=True, null=True, help_text='m') csa = models.DecimalField(decimal_places=2, max_digits=10, - blank=True, null=True, help_text='mm^2') + blank=True, null=True, help_text='mm²') # Hidden asset_id components # For example, if asset_id was "C1001" then asset_id_prefix would be "C" and number "1001" diff --git a/assets/templates/asset_audit.html b/assets/templates/asset_audit.html new file mode 100644 index 00000000..2ed26c88 --- /dev/null +++ b/assets/templates/asset_audit.html @@ -0,0 +1,142 @@ +{% extends request.is_ajax|yesno:'base_ajax.html,base_assets.html' %} +{% load widget_tweaks %} +{% block title %}Audit Asset {{ object.asset_id }}{% endblock %} + +{% block content %} + +
    + {% include 'form_errors.html' %} + {% csrf_token %} + +
    + +
    + {% render_field form.asset_id|add_class:'form-control' value=object.asset_idz %} +
    +
    +
    + +
    + {% render_field form.description|add_class:'form-control' value=object.description %} +
    +
    +
    + +
    + {% render_field form.category|add_class:'form-control'%} +
    +
    +
    + +
    + {% render_field form.status|add_class:'form-control'%} +
    +
    +
    + +
    + {% render_field form.serial_number|add_class:'form-control' value=object.serial_number %} +
    +
    +
    + +
    + {% render_field form.date_acquired|add_class:'form-control' value=object.date_acquired %} +
    +
    + Today + Unknown +
    +
    +
    + +
    + {% render_field form.date_sold|add_class:'form-control' value=object.date_sold %} +
    +
    +
    + +
    +
    + £ + {% render_field form.salvage_value|add_class:'form-control' value=object.salvage_value %} +
    +
    +
    +
    +
    + +
    + {% render_field form.is_cable|attr:'onchange=checkIfCableHidden()' %} +
    +
    +
    +
    + +
    + {% render_field form.cable_type|add_class:'form-control' %} +
    +
    +
    + +
    +
    + {% render_field form.length|add_class:'form-control' %} + {{ form.length.help_text }} +
    +
    +
    + 5{{ form.length.help_text }} + 10{{ form.length.help_text }} + 20{{ form.length.help_text }} +
    +
    +
    + +
    +
    + {% render_field form.csa|add_class:'form-control' value=object.csa %} + {{ form.csa.help_text }} +
    +
    +
    + 1.5{{ form.csa.help_text }} + 2.5{{ form.csa.help_text }} +
    +
    +
    + {% if not request.is_ajax %} +
    + +
    + {% endif %} +
    +{% endblock %} + +{% block footer %} +
    + +
    +{% endblock %} diff --git a/assets/templates/asset_audit_list.html b/assets/templates/asset_audit_list.html new file mode 100644 index 00000000..5117bbed --- /dev/null +++ b/assets/templates/asset_audit_list.html @@ -0,0 +1,87 @@ +{% extends 'base_assets.html' %} +{% block title %}Asset Audit List{% endblock %} +{% load static %} +{% load paginator from filters %} +{% load widget_tweaks %} + +{% block js %} + + + + +{% endblock %} + +{% block content %} + + + + +

    Audit Asset:

    +
    +
    + {% render_field form.query|add_class:'form-control' placeholder='Enter Asset ID' autofocus="true" %} + + Search +
    +
    + +

    Assets Requiring Audit:

    + + + + + + + + + + + + {% include 'partials/asset_list_table_body.html' with audit="true" %} + +
    Asset IDDescriptionCategoryStatus
    + +{% if is_paginated %} +
    + {% paginator %} +
    +{% endif %} +{% endblock %} diff --git a/assets/templates/asset_update.html b/assets/templates/asset_update.html index 4f576130..cd781de5 100644 --- a/assets/templates/asset_update.html +++ b/assets/templates/asset_update.html @@ -1,4 +1,4 @@ -{% extends 'base_assets.html' %} +{% extends request.is_ajax|yesno:'base_ajax.html,base_assets.html' %} {% load widget_tweaks %} {% block title %}Asset {{ object.asset_id }}{% endblock %} @@ -23,18 +23,23 @@
    - {% if perms.assets.asset_finance %} -
    - {% include 'partials/purchasedetails_form.html' %} -
    - {%endif%} - + {% if perms.assets.asset_finance %} +
    + {% include 'partials/purchasedetails_form.html' %} +
    + {%endif%}
    {% include 'partials/parent_form.html' %}
    + {% if not edit %} +
    + {% include 'partials/audit_details.html' %} +
    + {% endif %}
    diff --git a/assets/templates/partials/asset_buttons.html b/assets/templates/partials/asset_buttons.html index 3c99225f..d947e57b 100644 --- a/assets/templates/partials/asset_buttons.html +++ b/assets/templates/partials/asset_buttons.html @@ -1,25 +1,28 @@ -{% if edit and object %} - - - Duplicate -{% elif duplicate %} - - -{% elif create %} - - -{% else %} - - -{% endif %} -{% if create or edit or duplicate %} -
    - +{% if perms.assets.change_asset %} + {% if edit and object %} + + + Duplicate + {% elif duplicate %} + + + {% elif create %} + + + {% else %} + + + {% endif %} + {% if create or edit or duplicate %} +
    + + {% endif %} {% endif %} diff --git a/assets/templates/partials/asset_list_table_body.html b/assets/templates/partials/asset_list_table_body.html index 352d15db..64526d08 100644 --- a/assets/templates/partials/asset_list_table_body.html +++ b/assets/templates/partials/asset_list_table_body.html @@ -1,19 +1,21 @@ {% for item in object_list %} - {#
  • {{ item.asset_id }} - {{ item.description }}
  • #} - - + {{ item.asset_id }} {{ item.description }} {{ item.category }} {{ item.status }}
    + {% if audit %} + Audit + {% else %} View {% if perms.assets.change_asset %} Edit Duplicate {% endif %}
    + {% endif %} {% endfor %} diff --git a/assets/templates/partials/audit_details.html b/assets/templates/partials/audit_details.html new file mode 100644 index 00000000..8edc2d80 --- /dev/null +++ b/assets/templates/partials/audit_details.html @@ -0,0 +1,8 @@ +
    +
    + Audit Details +
    +
    +

    Audited at {{ object.last_audited_at|default_if_none:'-' }} by {{ object.last_audited_by|default_if_none:'-' }}

    +
    +
    diff --git a/assets/tests/pages.py b/assets/tests/pages.py index c68d2568..f505c116 100644 --- a/assets/tests/pages.py +++ b/assets/tests/pages.py @@ -6,7 +6,7 @@ from selenium.webdriver import Chrome from django.urls import reverse from PyRIGS.tests import regions from PyRIGS.tests.pages import BasePage, FormPage -import pdb +from selenium.common.exceptions import NoSuchElementException class AssetList(BasePage): @@ -95,11 +95,6 @@ class AssetForm(FormPage): def parent_selector(self): return regions.BootstrapSelectElement(self, self.find_element(*self._parent_select_locator)) - def submit(self): - previous_errors = self.errors - self.find_element(*self._submit_locator).click() - self.wait.until(lambda x: self.errors != previous_errors or self.success) - class AssetEdit(AssetForm): URL_TEMPLATE = '/assets/asset/id/{asset_id}/edit/' @@ -162,11 +157,6 @@ class SupplierForm(FormPage): 'name': (regions.TextBox, (By.ID, 'id_name')), } - def submit(self): - previous_errors = self.errors - self.find_element(*self._submit_locator).click() - self.wait.until(lambda x: self.errors != previous_errors or self.success) - class SupplierCreate(SupplierForm): URL_TEMPLATE = reverse('supplier_create') @@ -183,3 +173,90 @@ class SupplierEdit(SupplierForm): @property def success(self): return '/edit' not in self.driver.current_url + + +class AssetAuditList(AssetList): + URL_TEMPLATE = reverse('asset_audit_list') + + _search_text_locator = (By.ID, 'id_query') + _go_button_locator = (By.ID, 'searchButton') + _modal_locator = (By.ID, 'modal') + _errors_selector = (By.CLASS_NAME, "alert-danger") + + @property + def modal(self): + return self.AssetAuditModal(self, self.find_element(*self._modal_locator)) + + @property + def query(self): + return self.find_element(*self._search_text_locator).text + + def set_query(self, queryString): + element = self.find_element(*self._search_text_locator) + element.clear() + element.send_keys(queryString) + + def search(self): + self.find_element(*self._go_button_locator).click() + + @property + def error(self): + try: + return self.find_element(*self._errors_selector) + except NoSuchElementException: + return None + + class AssetAuditModal(Region): + _errors_selector = (By.CLASS_NAME, "alert-danger") + # Don't use the usual success selector - that tries and fails to hit the '10m long cable' helper button... + _submit_locator = (By.ID, "id_mark_audited") + form_items = { + 'asset_id': (regions.TextBox, (By.ID, 'id_asset_id')), + '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')), + 'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')), + 'category': (regions.SingleSelectPicker, (By.ID, 'id_category')), + 'status': (regions.SingleSelectPicker, (By.ID, 'id_status')), + + 'plug': (regions.SingleSelectPicker, (By.ID, 'id_plug')), + 'socket': (regions.SingleSelectPicker, (By.ID, 'id_socket')), + 'length': (regions.TextBox, (By.ID, 'id_length')), + 'csa': (regions.TextBox, (By.ID, 'id_csa')), + 'circuits': (regions.TextBox, (By.ID, 'id_circuits')), + 'cores': (regions.TextBox, (By.ID, 'id_cores')) + } + + @property + def errors(self): + try: + error_page = regions.ErrorPage(self, self.find_element(*self._errors_selector)) + return error_page.errors + except NoSuchElementException: + return None + + def submit(self): + previous_errors = self.errors + self.root.find_element(*self._submit_locator).click() + # self.wait.until(lambda x: not self.is_displayed) TODO + + def remove_all_required(self): + self.driver.execute_script("Array.from(document.getElementsByTagName(\"input\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") + self.driver.execute_script("Array.from(document.getElementsByTagName(\"select\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") + + def __getattr__(self, name): + if name in self.form_items: + element = self.form_items[name] + form_element = element[0](self, self.find_element(*element[1])) + return form_element.value + else: + return super().__getattribute__(name) + + def __setattr__(self, name, value): + if name in self.form_items: + element = self.form_items[name] + form_element = element[0](self, self.find_element(*element[1])) + form_element.set_value(value) + else: + self.__dict__[name] = value diff --git a/assets/tests/test_assets.py b/assets/tests/test_assets.py index 06d35701..2647d0f3 100644 --- a/assets/tests/test_assets.py +++ b/assets/tests/test_assets.py @@ -9,8 +9,13 @@ from RIGS import models as rigsmodels from PyRIGS.tests.base import BaseTest, AutoLoginTest from assets import models, urls from reversion import revisions as reversion +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait +from RIGS.test_functional import animation_is_finished import datetime +from django.utils import timezone class TestAssetList(AutoLoginTest): @@ -255,6 +260,76 @@ class TestSupplierCreateAndEdit(AutoLoginTest): self.assertTrue(self.page.success) +class TestAssetAudit(AutoLoginTest): + def setUp(self): + super().setUp() + self.category = models.AssetCategory.objects.create(name="Haulage") + self.status = models.AssetStatus.objects.create(name="Probably Fine", should_show=True) + self.supplier = models.Supplier.objects.create(name="The Bazaar") + 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)) + models.Asset.objects.create(asset_id="11", description="Trailerboard", status=self.status, category=self.category, date_acquired=datetime.date(2020, 2, 1)) + models.Asset.objects.create(asset_id="111", description="Erms", status=self.status, category=self.category, date_acquired=datetime.date(2020, 2, 1)) + models.Asset.objects.create(asset_id="1111", description="A hammer", status=self.status, category=self.category, date_acquired=datetime.date(2020, 2, 1)) + self.page = pages.AssetAuditList(self.driver, self.live_server_url).open() + self.wait = WebDriverWait(self.driver, 5) + + def test_audit_process(self): + asset_id = "1111" + self.page.set_query(asset_id) + self.page.search() + mdl = self.page.modal + self.wait.until(EC.visibility_of_element_located((By.ID, 'modal'))) + # Do it wrong on purpose to check error display + mdl.remove_all_required() + mdl.description = "" + mdl.submit() + # self.wait.until(EC.visibility_of_element_located((By.ID, 'modal'))) + self.wait.until(animation_is_finished()) + # self.assertTrue(self.driver.find_element_by_id('modal').is_displayed()) + self.assertIn("This field is required.", mdl.errors["Description"]) + # Now do it properly + new_desc = "A BIG hammer" + mdl.description = new_desc + mdl.submit() + self.wait.until(animation_is_finished()) + self.assertFalse(self.driver.find_element_by_id('modal').is_displayed()) + + # Check data is correct + audited = models.Asset.objects.get(asset_id="1111") + self.assertEqual(audited.description, new_desc) + # Make sure audit 'log' was filled out + self.assertEqual(self.profile.initials, audited.last_audited_by.initials) + self.assertEqual(timezone.now().date(), audited.last_audited_at.date()) + self.assertEqual(timezone.now().hour, audited.last_audited_at.hour) + self.assertEqual(timezone.now().minute, audited.last_audited_at.minute) + # Check we've removed it from the 'needing audit' list + self.assertNotIn(asset_id, self.page.assets) + + def test_audit_list(self): + self.assertEqual(len(models.Asset.objects.filter(last_audited_at=None)), len(self.page.assets)) + + assetRow = self.page.assets[0] + assetRow.find_element(By.CSS_SELECTOR, "td:nth-child(5) > div:nth-child(1) > a:nth-child(1)").click() + self.wait.until(EC.visibility_of_element_located((By.ID, 'modal'))) + self.assertEqual(self.page.modal.asset_id, assetRow.id) + + # First close button is for the not found error + self.page.find_element(By.XPATH, '(//button[@class="close"])[2]').click() + self.wait.until(animation_is_finished()) + self.assertFalse(self.driver.find_element_by_id('modal').is_displayed()) + # Make sure audit log was NOT filled out + audited = models.Asset.objects.get(asset_id=assetRow.id) + self.assertEqual(None, audited.last_audited_by) + + # Check that a failed search works + self.page.set_query("NOTFOUND") + self.page.search() + self.wait.until(animation_is_finished()) + self.assertFalse(self.driver.find_element_by_id('modal').is_displayed()) + self.assertIn("Asset with that ID does not exist!", self.page.error.text) + + class TestSupplierValidation(TestCase): @classmethod def setUpTestData(cls): diff --git a/assets/urls.py b/assets/urls.py index 8bc3c2a0..dc76ecbe 100644 --- a/assets/urls.py +++ b/assets/urls.py @@ -36,6 +36,9 @@ urlpatterns = [ views.AssetOembed.as_view(), name='asset_oembed'), + path('asset/audit/', permission_required_with_403('assets.change_asset')(views.AssetAuditList.as_view()), name='asset_audit_list'), + path('asset/id//audit/', permission_required_with_403('assets.change_asset')(views.AssetAudit.as_view()), name='asset_audit'), + path('supplier/list', views.SupplierList.as_view(), name='supplier_list'), path('supplier/', views.SupplierDetail.as_view(), name='supplier_detail'), path('supplier/create', permission_required_with_403('assets.add_supplier') diff --git a/assets/views.py b/assets/views.py index 29e86872..c748fa05 100644 --- a/assets/views.py +++ b/assets/views.py @@ -4,13 +4,17 @@ from django.http import HttpResponse, Http404 from django.views import generic from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator -from django.urls import reverse +from django.urls import reverse_lazy, reverse from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.core import serializers +from django.contrib import messages from assets import models, forms from RIGS import versioning import simplejson +import datetime +from django.utils import timezone @method_decorator(csrf_exempt, name='dispatch') @@ -109,7 +113,14 @@ class AssetEdit(LoginRequiredMixin, AssetIDUrlMixin, generic.UpdateView): return context def get_success_url(self): - return reverse("asset_detail", kwargs={"pk": self.object.asset_id}) + if self.request.is_ajax(): + url = reverse_lazy('closemodal') + update_url = str(reverse_lazy('asset_update', kwargs={'pk': self.object.pk})) + messages.info(self.request, "modalobject=" + serializers.serialize("json", [self.object])) + messages.info(self.request, "modalobject[0]['update_url']='" + update_url + "'") + else: + url = reverse_lazy('asset_detail', kwargs={'pk': self.object.asset_id, }) + return url class AssetCreate(LoginRequiredMixin, generic.CreateView): @@ -173,6 +184,30 @@ class AssetEmbed(AssetDetail): template_name = 'asset_embed.html' +@method_decorator(csrf_exempt, name='dispatch') +class AssetAuditList(AssetList): + template_name = 'asset_audit_list.html' + hide_hidden_status = False + + # TODO Refresh this when the modal is submitted + def get_queryset(self): + self.form = forms.AssetSearchForm(data={}) + return self.model.objects.filter(Q(last_audited_at__isnull=True)) + + +class AssetAudit(AssetEdit): + template_name = 'asset_audit.html' + form_class = forms.AssetAuditForm + + def get_success_url(self): + # TODO For some reason this doesn't stick when done in form_valid?? + asset = self.get_object() + asset.last_audited_by = self.request.user + asset.last_audited_at = timezone.now() + asset.save() + return super().get_success_url() + + class SupplierList(generic.ListView): model = models.Supplier template_name = 'supplier_list.html' diff --git a/templates/base_assets.html b/templates/base_assets.html index 7f226963..1dc36e08 100644 --- a/templates/base_assets.html +++ b/templates/base_assets.html @@ -37,5 +37,6 @@ {% if perms.assets.view_asset %}
  • Recent Changes
  • +
  • Audit
  • {% endif %} {% endblock %}