diff --git a/PyRIGS/tests/pages.py b/PyRIGS/tests/pages.py index e4e6db16..ad9304ff 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): @@ -36,37 +37,19 @@ class FormPage(BasePage): 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 # -[](https://travis-ci.org/nottinghamtec/PyRIGS) -[](https://coveralls.io/github/nottinghamtec/PyRIGS) +[](https://travis-ci.org/nottinghamtec/PyRIGS) +[](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/admin.py b/assets/admin.py index a62c90e3..09864d36 100644 --- a/assets/admin.py +++ b/assets/admin.py @@ -24,10 +24,15 @@ class SupplierAdmin(VersionAdmin): @admin.register(assets.Asset) class AssetAdmin(VersionAdmin): 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..580a5f2e 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): @@ -12,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) @@ -20,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) @@ -34,3 +42,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..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'] @@ -78,6 +92,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 +104,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/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/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/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 c3bc49b6..0d552791 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): @@ -68,6 +68,25 @@ 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(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): + if self.plug and self.socket: + return "%s → %s" % (self.plug.description, self.socket.description) + else: + return "Unknown" + + @reversion.register class Asset(models.Model, RevisionMixin): class Meta: @@ -90,18 +109,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) - 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) + 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" @@ -131,7 +149,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): @@ -156,14 +174,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/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 %} + +
+{% endblock %} + +{% block footer %} +| Cable Type | +Circuits | +Cores | +Quick Links | +
|---|---|---|---|
| {{ item }} | +{{ item.circuits }} | +{{ item.cores }} | ++ View + Edit + | +
Audited at {{ object.last_audited_at|default_if_none:'-' }} by {{ object.last_audited_by|default_if_none:'-' }}
+