Просмотр исходного кода

Merge pull request #2198 from digitalocean/1898-activity-logging

Closes #1898: Change logging
Jeremy Stretch 7 лет назад
Родитель
Сommit
ffcbc54522
80 измененных файлов с 3133 добавлено и 1716 удалено
  1. 8 0
      docs/configuration/optional-settings.md
  2. 45 0
      netbox/circuits/migrations/0012_change_logging.py
  3. 7 8
      netbox/circuits/models.py
  4. 3 0
      netbox/circuits/tables.py
  5. 5 0
      netbox/circuits/urls.py
  6. 0 27
      netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py
  7. 1 1
      netbox/dcim/migrations/0059_site_latitude_longitude.py
  8. 135 0
      netbox/dcim/migrations/0060_change_logging.py
  9. 150 38
      netbox/dcim/models.py
  10. 30 6
      netbox/dcim/tables.py
  11. 17 2
      netbox/dcim/urls.py
  12. 5 13
      netbox/dcim/views.py
  13. 45 2
      netbox/extras/admin.py
  14. 34 4
      netbox/extras/api/serializers.py
  15. 3 0
      netbox/extras/api/urls.py
  16. 16 1
      netbox/extras/api/views.py
  17. 10 0
      netbox/extras/constants.py
  18. 21 1
      netbox/extras/filters.py
  19. 42 3
      netbox/extras/forms.py
  20. 65 0
      netbox/extras/middleware.py
  21. 40 0
      netbox/extras/migrations/0013_objectchange.py
  22. 115 0
      netbox/extras/models.py
  23. 40 0
      netbox/extras/tables.py
  24. 4 0
      netbox/extras/urls.py
  25. 78 5
      netbox/extras/views.py
  26. 105 0
      netbox/ipam/migrations/0023_change_logging.py
  27. 18 22
      netbox/ipam/models.py
  28. 9 0
      netbox/ipam/tables.py
  29. 11 0
      netbox/ipam/urls.py
  30. 3 11
      netbox/ipam/views.py
  31. 3 0
      netbox/netbox/configuration.example.py
  32. 2 0
      netbox/netbox/settings.py
  33. 35 0
      netbox/secrets/migrations/0005_change_logging.py
  34. 12 4
      netbox/secrets/models.py
  35. 3 0
      netbox/secrets/tables.py
  36. 4 0
      netbox/secrets/urls.py
  37. 1 0
      netbox/templates/_base.html
  38. 49 36
      netbox/templates/circuits/circuit.html
  39. 54 41
      netbox/templates/circuits/provider.html
  40. 625 555
      netbox/templates/dcim/device.html
  41. 1 2
      netbox/templates/dcim/device_config.html
  42. 66 67
      netbox/templates/dcim/device_inventory.html
  43. 1 2
      netbox/templates/dcim/device_lldp_neighbors.html
  44. 1 2
      netbox/templates/dcim/device_status.html
  45. 40 27
      netbox/templates/dcim/devicetype.html
  46. 0 65
      netbox/templates/dcim/inc/device_header.html
  47. 51 40
      netbox/templates/dcim/rack.html
  48. 58 46
      netbox/templates/dcim/site.html
  49. 8 0
      netbox/templates/extras/object_changelog.html
  50. 101 0
      netbox/templates/extras/objectchange.html
  51. 17 0
      netbox/templates/extras/objectchange_list.html
  52. 3 0
      netbox/templates/inc/nav_menu.html
  53. 47 36
      netbox/templates/ipam/aggregate.html
  54. 0 55
      netbox/templates/ipam/inc/prefix_header.html
  55. 3 0
      netbox/templates/ipam/inc/service.html
  56. 0 46
      netbox/templates/ipam/inc/vlan_header.html
  57. 49 38
      netbox/templates/ipam/ipaddress.html
  58. 205 141
      netbox/templates/ipam/prefix.html
  59. 2 3
      netbox/templates/ipam/prefix_ipaddresses.html
  60. 2 3
      netbox/templates/ipam/prefix_prefixes.html
  61. 163 108
      netbox/templates/ipam/vlan.html
  62. 2 3
      netbox/templates/ipam/vlan_members.html
  63. 46 35
      netbox/templates/ipam/vrf.html
  64. 36 25
      netbox/templates/secrets/secret.html
  65. 49 38
      netbox/templates/tenancy/tenant.html
  66. 49 38
      netbox/templates/virtualization/cluster.html
  67. 91 80
      netbox/templates/virtualization/virtualmachine.html
  68. 35 0
      netbox/tenancy/migrations/0005_change_logging.py
  69. 5 7
      netbox/tenancy/models.py
  70. 3 0
      netbox/tenancy/tables.py
  71. 4 0
      netbox/tenancy/urls.py
  72. 16 0
      netbox/utilities/api.py
  73. 31 3
      netbox/utilities/models.py
  74. 14 0
      netbox/utilities/utils.py
  75. 1 11
      netbox/utilities/views.py
  76. 55 0
      netbox/virtualization/migrations/0007_change_logging.py
  77. 9 11
      netbox/virtualization/models.py
  78. 6 0
      netbox/virtualization/tables.py
  79. 6 0
      netbox/virtualization/urls.py
  80. 4 4
      netbox/virtualization/views.py

+ 8 - 0
docs/configuration/optional-settings.md

@@ -44,6 +44,14 @@ BASE_PATH = 'netbox/'
 
 
 ---
 ---
 
 
+## CHANGELOG_RETENTION
+
+Default: 90
+
+The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. (Warning: This will greatly increase database size over time.)
+
+---
+
 ## CORS_ORIGIN_ALLOW_ALL
 ## CORS_ORIGIN_ALLOW_ALL
 
 
 Default: False
 Default: False

+ 45 - 0
netbox/circuits/migrations/0012_change_logging.py

@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0011_tags'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittype',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='circuittype',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='circuit',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='circuit',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 7 - 8
netbox/circuits/models.py

@@ -9,12 +9,12 @@ from taggit.managers import TaggableManager
 from dcim.constants import STATUS_CLASSES
 from dcim.constants import STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Provider(CreatedUpdatedModel, CustomFieldModel):
+class Provider(ChangeLoggedModel, CustomFieldModel):
     """
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     stores information pertinent to the user's relationship with the Provider.
     stores information pertinent to the user's relationship with the Provider.
@@ -59,9 +59,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
-
     serializer = 'circuits.api.serializers.ProviderSerializer'
     serializer = 'circuits.api.serializers.ProviderSerializer'
+    csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -86,7 +85,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class CircuitType(models.Model):
+class CircuitType(ChangeLoggedModel):
     """
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     "Long Haul," "Metro," or "Out-of-Band".
     "Long Haul," "Metro," or "Out-of-Band".
@@ -99,6 +98,7 @@ class CircuitType(models.Model):
         unique=True
         unique=True
     )
     )
 
 
+    serializer = 'circuits.api.serializers.CircuitTypeSerializer'
     csv_headers = ['name', 'slug']
     csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
@@ -118,7 +118,7 @@ class CircuitType(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Circuit(CreatedUpdatedModel, CustomFieldModel):
+class Circuit(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
     circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
@@ -173,12 +173,11 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'circuits.api.serializers.CircuitSerializer'
     csv_headers = [
     csv_headers = [
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
     ]
     ]
 
 
-    serializer = 'circuits.api.serializers.CircuitSerializer'
-
     class Meta:
     class Meta:
         ordering = ['provider', 'cid']
         ordering = ['provider', 'cid']
         unique_together = ['provider', 'cid']
         unique_together = ['provider', 'cid']

+ 3 - 0
netbox/circuits/tables.py

@@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 from .models import Circuit, CircuitType, Provider
 
 
 CIRCUITTYPE_ACTIONS = """
 CIRCUITTYPE_ACTIONS = """
+<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.circuit.change_circuittype %}
 {% if perms.circuit.change_circuittype %}
     <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}

+ 5 - 0
netbox/circuits/urls.py

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
+from .models import Circuit, CircuitType, Provider
 
 
 app_name = 'circuits'
 app_name = 'circuits'
 urlpatterns = [
 urlpatterns = [
@@ -16,6 +18,7 @@ urlpatterns = [
     url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
     url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
     url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
     url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
     url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
     url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
+    url(r'^providers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
 
 
     # Circuit types
     # Circuit types
     url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
@@ -23,6 +26,7 @@ urlpatterns = [
     url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
     url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
     url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
+    url(r'^circuit-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
 
 
     # Circuits
     # Circuits
     url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
     url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
@@ -33,6 +37,7 @@ urlpatterns = [
     url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    url(r'^circuits/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
     url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
     url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # Circuit terminations

+ 0 - 27
netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py

@@ -1,27 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.12 on 2018-05-30 17:30
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-import django.utils.timezone
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('dcim', '0058_relax_rack_naming_constraints'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='devicetype',
-            name='created',
-            field=models.DateField(auto_now_add=True, default=django.utils.timezone.now),
-            preserve_default=False,
-        ),
-        migrations.AddField(
-            model_name='devicetype',
-            name='last_updated',
-            field=models.DateTimeField(auto_now=True),
-        ),
-    ]

+ 1 - 1
netbox/dcim/migrations/0060_site_latitude_longitude.py → netbox/dcim/migrations/0059_site_latitude_longitude.py

@@ -8,7 +8,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0059_devicetype_add_created_updated_times'),
+        ('dcim', '0058_relax_rack_naming_constraints'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 135 - 0
netbox/dcim/migrations/0060_change_logging.py

@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0059_site_latitude_longitude'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicerole',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='devicerole',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='manufacturer',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='manufacturer',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rackgroup',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rackgroup',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rackreservation',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rackrole',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rackrole',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='region',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='region',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='virtualchassis',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='virtualchassis',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rackreservation',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 150 - 38
netbox/dcim/models.py

@@ -18,22 +18,48 @@ from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from extras.models import CustomFieldModel
+from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
+from extras.models import CustomFieldModel, ObjectChange
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from utilities.fields import ColorField, NullableCharField
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
+from utilities.utils import serialize_object
 from .constants import *
 from .constants import *
 from .fields import ASNField, MACAddressField
 from .fields import ASNField, MACAddressField
 from .querysets import InterfaceQuerySet
 from .querysets import InterfaceQuerySet
 
 
 
 
+class ComponentModel(models.Model):
+
+    class Meta:
+        abstract = True
+
+    def get_component_parent(self):
+        raise NotImplementedError(
+            "ComponentModel must implement get_component_parent()"
+        )
+
+    def log_change(self, user, request_id, action):
+        """
+        Log an ObjectChange including the parent Device/VM.
+        """
+        ObjectChange(
+            user=user,
+            request_id=request_id,
+            changed_object=self,
+            related_object=self.get_component_parent(),
+            action=action,
+            object_data=serialize_object(self)
+        ).save()
+
+
 #
 #
 # Regions
 # Regions
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Region(MPTTModel):
+class Region(MPTTModel, ChangeLoggedModel):
     """
     """
     Sites can be grouped within geographic Regions.
     Sites can be grouped within geographic Regions.
     """
     """
@@ -53,6 +79,7 @@ class Region(MPTTModel):
         unique=True
         unique=True
     )
     )
 
 
+    serializer = 'dcim.api.serializers.RegionSerializer'
     csv_headers = ['name', 'slug', 'parent']
     csv_headers = ['name', 'slug', 'parent']
 
 
     class MPTTMeta:
     class MPTTMeta:
@@ -81,7 +108,7 @@ class SiteManager(NaturalOrderByManager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Site(CreatedUpdatedModel, CustomFieldModel):
+class Site(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
     field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@@ -174,13 +201,12 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
     objects = SiteManager()
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'dcim.api.serializers.SiteSerializer'
     csv_headers = [
     csv_headers = [
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
     ]
     ]
 
 
-    serializer = 'dcim.api.serializers.SiteSerializer'
-
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -245,7 +271,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class RackGroup(models.Model):
+class RackGroup(ChangeLoggedModel):
     """
     """
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
     example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
     example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
@@ -261,9 +287,8 @@ class RackGroup(models.Model):
         related_name='rack_groups'
         related_name='rack_groups'
     )
     )
 
 
-    csv_headers = ['site', 'name', 'slug']
-
     serializer = 'dcim.api.serializers.RackGroupSerializer'
     serializer = 'dcim.api.serializers.RackGroupSerializer'
+    csv_headers = ['site', 'name', 'slug']
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
@@ -287,7 +312,7 @@ class RackGroup(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class RackRole(models.Model):
+class RackRole(ChangeLoggedModel):
     """
     """
     Racks can be organized by functional role, similar to Devices.
     Racks can be organized by functional role, similar to Devices.
     """
     """
@@ -300,6 +325,7 @@ class RackRole(models.Model):
     )
     )
     color = ColorField()
     color = ColorField()
 
 
+    serializer = 'dcim.api.serializers.RackRoleSerializer'
     csv_headers = ['name', 'slug', 'color']
     csv_headers = ['name', 'slug', 'color']
 
 
     class Meta:
     class Meta:
@@ -324,7 +350,7 @@ class RackManager(NaturalOrderByManager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Rack(CreatedUpdatedModel, CustomFieldModel):
+class Rack(ChangeLoggedModel, CustomFieldModel):
     """
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -406,13 +432,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
     objects = RackManager()
     objects = RackManager()
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'dcim.api.serializers.RackSerializer'
     csv_headers = [
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
         'desc_units', 'comments',
         'desc_units', 'comments',
     ]
     ]
 
 
-    serializer = 'dcim.api.serializers.RackSerializer'
-
     class Meta:
     class Meta:
         ordering = ['site', 'group', 'name']
         ordering = ['site', 'group', 'name']
         unique_together = [
         unique_together = [
@@ -584,7 +609,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class RackReservation(models.Model):
+class RackReservation(ChangeLoggedModel):
     """
     """
     One or more reserved units within a Rack.
     One or more reserved units within a Rack.
     """
     """
@@ -596,9 +621,6 @@ class RackReservation(models.Model):
     units = ArrayField(
     units = ArrayField(
         base_field=models.PositiveSmallIntegerField()
         base_field=models.PositiveSmallIntegerField()
     )
     )
-    created = models.DateTimeField(
-        auto_now_add=True
-    )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -614,6 +636,8 @@ class RackReservation(models.Model):
         max_length=100
         max_length=100
     )
     )
 
 
+    serializer = 'dcim.api.serializers.RackReservationSerializer'
+
     class Meta:
     class Meta:
         ordering = ['created']
         ordering = ['created']
 
 
@@ -661,7 +685,7 @@ class RackReservation(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Manufacturer(models.Model):
+class Manufacturer(ChangeLoggedModel):
     """
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
     """
     """
@@ -673,6 +697,7 @@ class Manufacturer(models.Model):
         unique=True
         unique=True
     )
     )
 
 
+    serializer = 'dcim.api.serializers.ManufacturerSerializer'
     csv_headers = ['name', 'slug']
     csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
@@ -692,7 +717,7 @@ class Manufacturer(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceType(CreatedUpdatedModel, CustomFieldModel):
+class DeviceType(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     well as high-level functional role(s).
     well as high-level functional role(s).
@@ -767,6 +792,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'dcim.api.serializers.DeviceTypeSerializer'
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
         'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@@ -866,7 +892,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsolePortTemplate(models.Model):
+class ConsolePortTemplate(ComponentModel):
     """
     """
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
     """
     """
@@ -886,9 +912,12 @@ class ConsolePortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsoleServerPortTemplate(models.Model):
+class ConsoleServerPortTemplate(ComponentModel):
     """
     """
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
     """
     """
@@ -908,9 +937,12 @@ class ConsoleServerPortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerPortTemplate(models.Model):
+class PowerPortTemplate(ComponentModel):
     """
     """
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
     """
     """
@@ -930,9 +962,12 @@ class PowerPortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerOutletTemplate(models.Model):
+class PowerOutletTemplate(ComponentModel):
     """
     """
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
     """
     """
@@ -952,9 +987,12 @@ class PowerOutletTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class InterfaceTemplate(models.Model):
+class InterfaceTemplate(ComponentModel):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
     """
     """
@@ -984,9 +1022,12 @@ class InterfaceTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceBayTemplate(models.Model):
+class DeviceBayTemplate(ComponentModel):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     A template for a DeviceBay to be created for a new parent Device.
     """
     """
@@ -1006,13 +1047,16 @@ class DeviceBayTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 #
 #
 # Devices
 # Devices
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceRole(models.Model):
+class DeviceRole(ChangeLoggedModel):
     """
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
     color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
@@ -1032,6 +1076,7 @@ class DeviceRole(models.Model):
         help_text='Virtual machines may be assigned to this role'
         help_text='Virtual machines may be assigned to this role'
     )
     )
 
 
+    serializer = 'dcim.api.serializers.DeviceRoleSerializer'
     csv_headers = ['name', 'slug', 'color', 'vm_role']
     csv_headers = ['name', 'slug', 'color', 'vm_role']
 
 
     class Meta:
     class Meta:
@@ -1053,7 +1098,7 @@ class DeviceRole(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Platform(models.Model):
+class Platform(ChangeLoggedModel):
     """
     """
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
     NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
     NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
@@ -1087,6 +1132,7 @@ class Platform(models.Model):
         verbose_name='Legacy RPC client'
         verbose_name='Legacy RPC client'
     )
     )
 
 
+    serializer = 'dcim.api.serializers.PlatformSerializer'
     csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
     csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
 
 
     class Meta:
     class Meta:
@@ -1112,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Device(CreatedUpdatedModel, CustomFieldModel):
+class Device(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@@ -1252,13 +1298,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     objects = DeviceManager()
     objects = DeviceManager()
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'dcim.api.serializers.DeviceSerializer'
     csv_headers = [
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
         'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
         'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
     ]
     ]
 
 
-    serializer = 'dcim.api.serializers.DeviceSerializer'
-
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
         unique_together = [
         unique_together = [
@@ -1501,7 +1546,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsolePort(models.Model):
+class ConsolePort(ComponentModel):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
@@ -1538,6 +1583,9 @@ class ConsolePort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.cs_port.device.identifier if self.cs_port else None,
             self.cs_port.device.identifier if self.cs_port else None,
@@ -1563,7 +1611,7 @@ class ConsoleServerPortManager(models.Manager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsoleServerPort(models.Model):
+class ConsoleServerPort(ComponentModel):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
     """
@@ -1587,6 +1635,9 @@ class ConsoleServerPort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a console server
         # Check that the parent device's DeviceType is a console server
@@ -1604,7 +1655,7 @@ class ConsoleServerPort(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerPort(models.Model):
+class PowerPort(ComponentModel):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
@@ -1640,6 +1691,9 @@ class PowerPort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.power_outlet.device.identifier if self.power_outlet else None,
             self.power_outlet.device.identifier if self.power_outlet else None,
@@ -1665,7 +1719,7 @@ class PowerOutletManager(models.Manager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerOutlet(models.Model):
+class PowerOutlet(ComponentModel):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
     """
@@ -1689,6 +1743,9 @@ class PowerOutlet(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a PDU
         # Check that the parent device's DeviceType is a PDU
@@ -1706,7 +1763,7 @@ class PowerOutlet(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Interface(models.Model):
+class Interface(ComponentModel):
     """
     """
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
     Interface via the creation of an InterfaceConnection.
     Interface via the creation of an InterfaceConnection.
@@ -1796,6 +1853,9 @@ class Interface(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.parent.get_absolute_url()
         return self.parent.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device or self.virtual_machine
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a network device
         # Check that the parent device's DeviceType is a network device
@@ -1866,6 +1926,23 @@ class Interface(models.Model):
 
 
         return super(Interface, self).save(*args, **kwargs)
         return super(Interface, self).save(*args, **kwargs)
 
 
+    def log_change(self, user, request_id, action):
+        """
+        Include the connected Interface (if any).
+        """
+        ObjectChange(
+            user=user,
+            request_id=request_id,
+            changed_object=self,
+            related_object=self.get_component_parent(),
+            action=action,
+            object_data=serialize_object(self, extra={
+                'connected_interface': self.connected_interface.pk if self.connection else None,
+                'connection_status': self.connection.connection_status if self.connection else None,
+            })
+        ).save()
+
+    # TODO: Replace `parent` with get_component_parent() (from ComponentModel)
     @property
     @property
     def parent(self):
     def parent(self):
         return self.device or self.virtual_machine
         return self.device or self.virtual_machine
@@ -1970,13 +2047,40 @@ class InterfaceConnection(models.Model):
             self.get_connection_status_display(),
             self.get_connection_status_display(),
         )
         )
 
 
+    def log_change(self, user, request_id, action):
+        """
+        Create a new ObjectChange for each of the two affected Interfaces.
+        """
+        interfaces = (
+            (self.interface_a, self.interface_b),
+            (self.interface_b, self.interface_a),
+        )
+        for interface, peer_interface in interfaces:
+            if action == OBJECTCHANGE_ACTION_DELETE:
+                connection_data = {
+                    'connected_interface': None,
+                }
+            else:
+                connection_data = {
+                    'connected_interface': peer_interface.pk,
+                    'connection_status': self.connection_status
+                }
+            ObjectChange(
+                user=user,
+                request_id=request_id,
+                changed_object=interface,
+                related_object=interface.parent,
+                action=OBJECTCHANGE_ACTION_UPDATE,
+                object_data=serialize_object(interface, extra=connection_data)
+            ).save()
+
 
 
 #
 #
 # Device bays
 # Device bays
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceBay(models.Model):
+class DeviceBay(ComponentModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
     """
     """
@@ -2007,6 +2111,9 @@ class DeviceBay(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Validate that the parent Device can have DeviceBays
         # Validate that the parent Device can have DeviceBays
@@ -2025,7 +2132,7 @@ class DeviceBay(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class InventoryItem(models.Model):
+class InventoryItem(ComponentModel):
     """
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     InventoryItems are used only for inventory purposes.
     InventoryItems are used only for inventory purposes.
@@ -2094,6 +2201,9 @@ class InventoryItem(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.device.name or '{' + self.device.pk + '}',
             self.device.name or '{' + self.device.pk + '}',
@@ -2112,7 +2222,7 @@ class InventoryItem(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VirtualChassis(models.Model):
+class VirtualChassis(ChangeLoggedModel):
     """
     """
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).
     """
     """
@@ -2126,6 +2236,8 @@ class VirtualChassis(models.Model):
         blank=True
         blank=True
     )
     )
 
 
+    serializer = 'dcim.api.serializers.VirtualChassisSerializer'
+
     class Meta:
     class Meta:
         ordering = ['master']
         ordering = ['master']
         verbose_name_plural = 'virtual chassis'
         verbose_name_plural = 'virtual chassis'

+ 30 - 6
netbox/dcim/tables.py

@@ -41,12 +41,18 @@ DEVICE_LINK = """
 """
 """
 
 
 REGION_ACTIONS = """
 REGION_ACTIONS = """
+<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_region %}
 {% if perms.dcim.change_region %}
     <a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
 """
 """
 
 
 RACKGROUP_ACTIONS = """
 RACKGROUP_ACTIONS = """
+<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
 <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
     <i class="fa fa-eye"></i>
     <i class="fa fa-eye"></i>
 </a>
 </a>
@@ -58,6 +64,9 @@ RACKGROUP_ACTIONS = """
 """
 """
 
 
 RACKROLE_ACTIONS = """
 RACKROLE_ACTIONS = """
+<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_rackrole %}
 {% if perms.dcim.change_rackrole %}
     <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
@@ -76,23 +85,32 @@ RACK_DEVICE_COUNT = """
 """
 """
 
 
 RACKRESERVATION_ACTIONS = """
 RACKRESERVATION_ACTIONS = """
+<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_rackreservation %}
 {% if perms.dcim.change_rackreservation %}
     <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
 """
 """
 
 
-DEVICEROLE_ACTIONS = """
-{% if perms.dcim.change_devicerole %}
-    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
-{% endif %}
-"""
-
 MANUFACTURER_ACTIONS = """
 MANUFACTURER_ACTIONS = """
+<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_manufacturer %}
 {% if perms.dcim.change_manufacturer %}
     <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
 """
 """
 
 
+DEVICEROLE_ACTIONS = """
+<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
+{% if perms.dcim.change_devicerole %}
+    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
 DEVICEROLE_DEVICE_COUNT = """
 DEVICEROLE_DEVICE_COUNT = """
 <a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
 <a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
 """
 """
@@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """
 """
 """
 
 
 PLATFORM_ACTIONS = """
 PLATFORM_ACTIONS = """
+<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_platform %}
 {% if perms.dcim.change_platform %}
     <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
@@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """
 """
 """
 
 
 VIRTUALCHASSIS_ACTIONS = """
 VIRTUALCHASSIS_ACTIONS = """
+<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.dcim.change_virtualchassis %}
 {% if perms.dcim.change_virtualchassis %}
     <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}

+ 17 - 2
netbox/dcim/urls.py

@@ -2,11 +2,14 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ImageAttachmentEditView
+from extras.views import ObjectChangeLogView, ImageAttachmentEditView
 from ipam.views import ServiceCreateView
 from ipam.views import ServiceCreateView
 from secrets.views import secret_add
 from secrets.views import secret_add
 from . import views
 from . import views
-from .models import Device, Rack, Site
+from .models import (
+    Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Region, Site,
+    VirtualChassis,
+)
 
 
 app_name = 'dcim'
 app_name = 'dcim'
 urlpatterns = [
 urlpatterns = [
@@ -17,6 +20,7 @@ urlpatterns = [
     url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
     url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
     url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
+    url(r'^regions/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
 
 
     # Sites
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
@@ -26,6 +30,7 @@ urlpatterns = [
     url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
+    url(r'^sites/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
     url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
     url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
     # Rack groups
     # Rack groups
@@ -34,6 +39,7 @@ urlpatterns = [
     url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
     url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
     url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
+    url(r'^rack-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
 
 
     # Rack roles
     # Rack roles
     url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
     url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -41,6 +47,7 @@ urlpatterns = [
     url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
     url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
     url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
+    url(r'^rack-roles/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
 
 
     # Rack reservations
     # Rack reservations
     url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
     url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
@@ -48,6 +55,7 @@ urlpatterns = [
     url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
     url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
     url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
     url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
+    url(r'^rack-reservations/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
 
 
     # Racks
     # Racks
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
@@ -59,6 +67,7 @@ urlpatterns = [
     url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
     url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
+    url(r'^racks/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
     url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
     url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
@@ -68,6 +77,7 @@ urlpatterns = [
     url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
     url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
     url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
+    url(r'^manufacturers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
 
 
     # Device types
     # Device types
     url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
     url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
@@ -78,6 +88,7 @@ urlpatterns = [
     url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
     url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
     url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
+    url(r'^device-types/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
 
 
     # Console port templates
     # Console port templates
     url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
     url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
@@ -110,6 +121,7 @@ urlpatterns = [
     url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
     url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
     url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
+    url(r'^device-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
 
 
     # Platforms
     # Platforms
     url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
     url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
@@ -117,6 +129,7 @@ urlpatterns = [
     url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
     url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
     url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
     url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
+    url(r'^platforms/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
 
 
     # Devices
     # Devices
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
@@ -128,6 +141,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
     url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
+    url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
     url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
@@ -221,6 +235,7 @@ urlpatterns = [
     url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
     url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
     url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
     url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
     url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
     url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
     url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
     url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
 

+ 5 - 13
netbox/dcim/views.py

@@ -18,7 +18,7 @@ from django.views.generic import View
 from natsort import natsorted
 from natsort import natsorted
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction
+from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from ipam.models import Prefix, Service, VLAN
 from ipam.models import Prefix, Service, VLAN
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
@@ -945,6 +945,7 @@ class DeviceInventoryView(View):
         return render(request, 'dcim/device_inventory.html', {
         return render(request, 'dcim/device_inventory.html', {
             'device': device,
             'device': device,
             'inventory_items': inventory_items,
             'inventory_items': inventory_items,
+            'active_tab': 'inventory',
         })
         })
 
 
 
 
@@ -957,6 +958,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
 
 
         return render(request, 'dcim/device_status.html', {
         return render(request, 'dcim/device_status.html', {
             'device': device,
             'device': device,
+            'active_tab': 'status',
         })
         })
 
 
 
 
@@ -975,6 +977,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
         return render(request, 'dcim/device_lldp_neighbors.html', {
         return render(request, 'dcim/device_lldp_neighbors.html', {
             'device': device,
             'device': device,
             'interfaces': interfaces,
             'interfaces': interfaces,
+            'active_tab': 'lldp-neighbors',
         })
         })
 
 
 
 
@@ -987,6 +990,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
 
 
         return render(request, 'dcim/device_config.html', {
         return render(request, 'dcim/device_config.html', {
             'device': device,
             'device': device,
+            'active_tab': 'config',
         })
         })
 
 
 
 
@@ -1104,7 +1108,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View):
                 escape(consoleport.cs_port.name),
                 escape(consoleport.cs_port.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, consoleport, msg)
 
 
             return redirect('dcim:device', pk=consoleport.device.pk)
             return redirect('dcim:device', pk=consoleport.device.pk)
 
 
@@ -1155,7 +1158,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View):
                 escape(cs_port.name),
                 escape(cs_port.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, consoleport, msg)
 
 
             return redirect('dcim:device', pk=consoleport.device.pk)
             return redirect('dcim:device', pk=consoleport.device.pk)
 
 
@@ -1244,7 +1246,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View):
                 escape(consoleserverport.name),
                 escape(consoleserverport.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, consoleport, msg)
 
 
             return redirect('dcim:device', pk=consoleserverport.device.pk)
             return redirect('dcim:device', pk=consoleserverport.device.pk)
 
 
@@ -1296,7 +1297,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View):
                 escape(consoleserverport.name),
                 escape(consoleserverport.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, consoleport, msg)
 
 
             return redirect('dcim:device', pk=consoleserverport.device.pk)
             return redirect('dcim:device', pk=consoleserverport.device.pk)
 
 
@@ -1390,7 +1390,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View):
                 escape(powerport.power_outlet.name),
                 escape(powerport.power_outlet.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, powerport, msg)
 
 
             return redirect('dcim:device', pk=powerport.device.pk)
             return redirect('dcim:device', pk=powerport.device.pk)
 
 
@@ -1441,7 +1440,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View):
                 escape(power_outlet.name),
                 escape(power_outlet.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, powerport, msg)
 
 
             return redirect('dcim:device', pk=powerport.device.pk)
             return redirect('dcim:device', pk=powerport.device.pk)
 
 
@@ -1529,7 +1527,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View):
                 escape(poweroutlet.name),
                 escape(poweroutlet.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, powerport, msg)
 
 
             return redirect('dcim:device', pk=poweroutlet.device.pk)
             return redirect('dcim:device', pk=poweroutlet.device.pk)
 
 
@@ -1580,7 +1577,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View):
                 escape(poweroutlet.name),
                 escape(poweroutlet.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, powerport, msg)
 
 
             return redirect('dcim:device', pk=poweroutlet.device.pk)
             return redirect('dcim:device', pk=poweroutlet.device.pk)
 
 
@@ -1910,7 +1906,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie
                 escape(interfaceconnection.interface_b.name),
                 escape(interfaceconnection.interface_b.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, interfaceconnection, msg)
 
 
             if '_addanother' in request.POST:
             if '_addanother' in request.POST:
                 base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
                 base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
@@ -1961,7 +1956,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin,
                 escape(interfaceconnection.interface_b.name),
                 escape(interfaceconnection.interface_b.name),
             )
             )
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_edit(request.user, interfaceconnection, msg)
 
 
             return redirect(self.get_return_url(request, interfaceconnection))
             return redirect(self.get_return_url(request, interfaceconnection))
 
 
@@ -2241,7 +2235,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
                 membership_form.save()
                 membership_form.save()
                 msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
                 msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
                 messages.success(request, mark_safe(msg))
                 messages.success(request, mark_safe(msg))
-                UserAction.objects.log_edit(request.user, device, msg)
 
 
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
                     return redirect(request.get_full_path())
                     return redirect(request.get_full_path())
@@ -2296,7 +2289,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
 
 
             msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
             msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
             messages.success(request, msg)
             messages.success(request, msg)
-            UserAction.objects.log_edit(request.user, device, msg)
 
 
             return redirect(self.get_return_url(request, device))
             return redirect(self.get_return_url(request, device))
 
 

+ 45 - 2
netbox/extras/admin.py

@@ -5,9 +5,9 @@ from django.contrib import admin
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from utilities.forms import LaxURLField
 from utilities.forms import LaxURLField
+from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 from .models import (
 from .models import (
-    CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction,
-    Webhook
+    CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook,
 )
 )
 
 
 
 
@@ -125,6 +125,49 @@ class TopologyMapAdmin(admin.ModelAdmin):
     }
     }
 
 
 
 
+#
+# Change logging
+#
+
+@admin.register(ObjectChange)
+class ObjectChangeAdmin(admin.ModelAdmin):
+    actions = None
+    fields = ['time', 'changed_object_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data']
+    list_display = ['time', 'changed_object_type', 'display_object', 'display_action', 'display_user', 'request_id']
+    list_filter = ['time', 'action', 'user__username']
+    list_select_related = ['changed_object_type', 'user']
+    readonly_fields = fields
+    search_fields = ['user_name', 'object_repr', 'request_id']
+
+    def has_add_permission(self, request):
+        return False
+
+    def display_user(self, obj):
+        if obj.user is not None:
+            return obj.user
+        else:
+            return '{} (deleted)'.format(obj.user_name)
+    display_user.short_description = 'user'
+
+    def display_action(self, obj):
+        icon = {
+            OBJECTCHANGE_ACTION_CREATE: 'addlink',
+            OBJECTCHANGE_ACTION_UPDATE: 'changelink',
+            OBJECTCHANGE_ACTION_DELETE: 'deletelink',
+        }
+        return mark_safe('<span class="{}">{}</span>'.format(icon[obj.action], obj.get_action_display()))
+    display_action.short_description = 'action'
+
+    def display_object(self, obj):
+        if hasattr(obj.changed_object, 'get_absolute_url'):
+            return mark_safe('<a href="{}">{}</a>'.format(obj.changed_object.get_absolute_url(), obj.changed_object))
+        elif obj.changed_object is not None:
+            return obj.changed_object
+        else:
+            return '{} (deleted)'.format(obj.object_repr)
+    display_object.short_description = 'object'
+
+
 #
 #
 # User actions
 # User actions
 #
 #

+ 34 - 4
netbox/extras/api/serializers.py

@@ -6,11 +6,12 @@ from taggit.models import Tag
 
 
 from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
 from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
 from dcim.models import Device, Rack, Site
 from dcim.models import Device, Rack, Site
-from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES
-from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
-from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
+from extras.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction
 from extras.constants import *
 from extras.constants import *
+from users.api.serializers import NestedUserSerializer
+from utilities.api import (
+    ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer,
+)
 
 
 
 
 #
 #
@@ -155,6 +156,35 @@ class ReportDetailSerializer(ReportSerializer):
     result = ReportResultSerializer()
     result = ReportResultSerializer()
 
 
 
 
+#
+# Change logging
+#
+
+class ObjectChangeSerializer(serializers.ModelSerializer):
+    user = NestedUserSerializer(read_only=True)
+    content_type = ContentTypeFieldSerializer(read_only=True)
+    changed_object = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = ObjectChange
+        fields = [
+            'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data',
+        ]
+
+    def get_changed_object(self, obj):
+        """
+        Serialize a nested representation of the changed object.
+        """
+        if obj.changed_object is None:
+            return None
+        serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
+        if serializer is None:
+            return obj.object_repr
+        context = {'request': self.context['request']}
+        data = serializer(obj.changed_object, context=context).data
+        return data
+
+
 #
 #
 # User actions
 # User actions
 #
 #

+ 3 - 0
netbox/extras/api/urls.py

@@ -37,6 +37,9 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
 # Reports
 # Reports
 router.register(r'reports', views.ReportViewSet, base_name='report')
 router.register(r'reports', views.ReportViewSet, base_name='report')
 
 
+# Change logging
+router.register(r'object-changes', views.ObjectChangeViewSet)
+
 # Recent activity
 # Recent activity
 router.register(r'recent-activity', views.RecentActivityViewSet)
 router.register(r'recent-activity', views.RecentActivityViewSet)
 
 

+ 16 - 1
netbox/extras/api/views.py

@@ -11,7 +11,9 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 from extras import filters
 from extras import filters
-from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
+from extras.models import (
+    CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction,
+)
 from extras.reports import get_report, get_reports
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from . import serializers
 from . import serializers
@@ -206,6 +208,19 @@ class ReportViewSet(ViewSet):
         return Response(serializer.data)
         return Response(serializer.data)
 
 
 
 
+#
+# Change logging
+#
+
+class ObjectChangeViewSet(ReadOnlyModelViewSet):
+    """
+    Retrieve a list of recent changes.
+    """
+    queryset = ObjectChange.objects.select_related('user')
+    serializer_class = serializers.ObjectChangeSerializer
+    filter_class = filters.ObjectChangeFilter
+
+
 #
 #
 # User activity
 # User activity
 #
 #

+ 10 - 0
netbox/extras/constants.py

@@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = (
     (TOPOLOGYMAP_TYPE_POWER, 'Power'),
     (TOPOLOGYMAP_TYPE_POWER, 'Power'),
 )
 )
 
 
+# Change log actions
+OBJECTCHANGE_ACTION_CREATE = 1
+OBJECTCHANGE_ACTION_UPDATE = 2
+OBJECTCHANGE_ACTION_DELETE = 3
+OBJECTCHANGE_ACTION_CHOICES = (
+    (OBJECTCHANGE_ACTION_CREATE, 'Created'),
+    (OBJECTCHANGE_ACTION_UPDATE, 'Updated'),
+    (OBJECTCHANGE_ACTION_DELETE, 'Deleted'),
+)
+
 # User action types
 # User action types
 ACTION_CREATE = 1
 ACTION_CREATE = 1
 ACTION_IMPORT = 2
 ACTION_IMPORT = 2

+ 21 - 1
netbox/extras/filters.py

@@ -8,7 +8,7 @@ from taggit.models import Tag
 
 
 from dcim.models import Site
 from dcim.models import Site
 from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
 from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
-from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
+from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
 
 
 
 
 class CustomFieldFilter(django_filters.Filter):
 class CustomFieldFilter(django_filters.Filter):
@@ -124,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet):
         fields = ['name', 'slug']
         fields = ['name', 'slug']
 
 
 
 
+class ObjectChangeFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    time = django_filters.DateTimeFromToRangeFilter()
+
+    class Meta:
+        model = ObjectChange
+        fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user_name__icontains=value) |
+            Q(object_repr__icontains=value)
+        )
+
+
 class UserActionFilter(django_filters.FilterSet):
 class UserActionFilter(django_filters.FilterSet):
     username = django_filters.ModelMultipleChoiceFilter(
     username = django_filters.ModelMultipleChoiceFilter(
         name='user__username',
         name='user__username',

+ 42 - 3
netbox/extras/forms.py

@@ -3,12 +3,16 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django import forms
 from django import forms
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from taggit.models import Tag
 from taggit.models import Tag
 
 
-from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField
-from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
-from .models import CustomField, CustomFieldValue, ImageAttachment
+from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField
+from .constants import (
+    CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
+    OBJECTCHANGE_ACTION_CHOICES,
+)
+from .models import CustomField, CustomFieldValue, ImageAttachment, ObjectChange
 
 
 
 
 #
 #
@@ -189,3 +193,38 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
         fields = ['name', 'image']
         fields = ['name', 'image']
+
+
+#
+# Change logging
+#
+
+class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = ObjectChange
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+    # TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
+    time_0 = forms.DateTimeField(
+        label='After',
+        required=False,
+        widget=forms.TextInput(
+            attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
+        )
+    )
+    time_1 = forms.DateTimeField(
+        label='Before',
+        required=False,
+        widget=forms.TextInput(
+            attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
+        )
+    )
+    action = forms.ChoiceField(
+        choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
+        required=False
+    )
+    user = forms.ModelChoiceField(
+        queryset=User.objects.order_by('username'),
+        required=False
+    )

+ 65 - 0
netbox/extras/middleware.py

@@ -0,0 +1,65 @@
+from __future__ import unicode_literals
+
+from datetime import timedelta
+import random
+import uuid
+
+from django.conf import settings
+from django.db.models.signals import post_delete, post_save
+from django.utils import timezone
+from django.utils.functional import curry, SimpleLazyObject
+
+from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
+from .models import ObjectChange
+
+
+def record_object_change(user, request_id, instance, **kwargs):
+    """
+    Create an ObjectChange in response to an object being created or deleted.
+    """
+    if not hasattr(instance, 'log_change'):
+        return
+
+    # Determine what action is being performed. The post_save signal sends a `created` boolean, whereas post_delete
+    # does not.
+    if 'created' in kwargs:
+        action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
+    else:
+        action = OBJECTCHANGE_ACTION_DELETE
+
+    instance.log_change(user, request_id, action)
+
+    # 1% chance of clearing out expired ObjectChanges
+    if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
+        cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
+        purged_count, _ = ObjectChange.objects.filter(
+            time__lt=cutoff
+        ).delete()
+
+
+class ChangeLoggingMiddleware(object):
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+
+        def get_user(request):
+            return request.user
+
+        # DRF employs a separate authentication mechanism outside Django's normal request/response cycle, so calling
+        # request.user in middleware will always return AnonymousUser for API requests. To work around this, we point
+        # to a lazy object that doesn't resolve the user until after DRF's authentication has been called. For more
+        # detail, see https://stackoverflow.com/questions/26240832/
+        user = SimpleLazyObject(lambda: get_user(request))
+
+        request_id = uuid.uuid4()
+
+        # Django doesn't provide any request context with the post_save/post_delete signals, so we curry
+        # record_object_change() to include the user associated with the current request.
+        _record_object_change = curry(record_object_change, user, request_id)
+
+        post_save.connect(_record_object_change, dispatch_uid='record_object_saved')
+        post_delete.connect(_record_object_change, dispatch_uid='record_object_deleted')
+
+        return self.get_response(request)

+ 40 - 0
netbox/extras/migrations/0013_objectchange.py

@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-22 18:13
+from __future__ import unicode_literals
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0012_webhooks'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ObjectChange',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('time', models.DateTimeField(auto_now_add=True)),
+                ('user_name', models.CharField(editable=False, max_length=150)),
+                ('request_id', models.UUIDField(editable=False)),
+                ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])),
+                ('changed_object_id', models.PositiveIntegerField()),
+                ('related_object_id', models.PositiveIntegerField(blank=True, null=True)),
+                ('object_repr', models.CharField(editable=False, max_length=200)),
+                ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)),
+                ('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
+                ('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['-time'],
+            },
+        ),
+    ]

+ 115 - 0
netbox/extras/models.py

@@ -2,12 +2,14 @@ from __future__ import unicode_literals
 
 
 from collections import OrderedDict
 from collections import OrderedDict
 from datetime import date
 from datetime import date
+import json
 
 
 import graphviz
 import graphviz
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import JSONField
 from django.contrib.postgres.fields import JSONField
+from django.urls import reverse
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import Q
 from django.db.models import Q
@@ -656,6 +658,119 @@ class ReportResult(models.Model):
         ordering = ['report']
         ordering = ['report']
 
 
 
 
+#
+# Change logging
+#
+
+@python_2_unicode_compatible
+class ObjectChange(models.Model):
+    """
+    Record a change to an object and the user account associated with that change. A change record may optionally
+    indicate an object related to the one being changed. For example, a change to an interface may also indicate the
+    parent device. This will ensure changes made to component models appear in the parent model's changelog.
+    """
+    time = models.DateTimeField(
+        auto_now_add=True,
+        editable=False
+    )
+    user = models.ForeignKey(
+        to=User,
+        on_delete=models.SET_NULL,
+        related_name='changes',
+        blank=True,
+        null=True
+    )
+    user_name = models.CharField(
+        max_length=150,
+        editable=False
+    )
+    request_id = models.UUIDField(
+        editable=False
+    )
+    action = models.PositiveSmallIntegerField(
+        choices=OBJECTCHANGE_ACTION_CHOICES
+    )
+    changed_object_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    changed_object_id = models.PositiveIntegerField()
+    changed_object = GenericForeignKey(
+        ct_field='changed_object_type',
+        fk_field='changed_object_id'
+    )
+    related_object_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.PROTECT,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    related_object_id = models.PositiveIntegerField(
+        blank=True,
+        null=True
+    )
+    related_object = GenericForeignKey(
+        ct_field='related_object_type',
+        fk_field='related_object_id'
+    )
+    object_repr = models.CharField(
+        max_length=200,
+        editable=False
+    )
+    object_data = JSONField(
+        editable=False
+    )
+
+    serializer = 'extras.api.serializers.ObjectChangeSerializer'
+    csv_headers = [
+        'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
+        'related_object_type', 'related_object_id', 'object_repr', 'object_data',
+    ]
+
+    class Meta:
+        ordering = ['-time']
+
+    def __str__(self):
+        return '{} {} {} by {}'.format(
+            self.changed_object_type,
+            self.object_repr,
+            self.get_action_display().lower(),
+            self.user_name
+        )
+
+    def save(self, *args, **kwargs):
+
+        # Record the user's name and the object's representation as static strings
+        self.user_name = self.user.username
+        self.object_repr = str(self.changed_object)
+
+        return super(ObjectChange, self).save(*args, **kwargs)
+
+    def get_absolute_url(self):
+        return reverse('extras:objectchange', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.time,
+            self.user,
+            self.user_name,
+            self.request_id,
+            self.get_action_display(),
+            self.changed_object_type,
+            self.changed_object_id,
+            self.related_object_type,
+            self.related_object_id,
+            self.object_repr,
+            self.object_data,
+        )
+
+    @property
+    def object_data_pretty(self):
+        return json.dumps(self.object_data, indent=4, sort_keys=True)
+
+
 #
 #
 # User actions
 # User actions
 #
 #

+ 40 - 0
netbox/extras/tables.py

@@ -4,6 +4,7 @@ import django_tables2 as tables
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 from utilities.tables import BaseTable, ToggleColumn
 from utilities.tables import BaseTable, ToggleColumn
+from .models import ObjectChange
 
 
 TAG_ACTIONS = """
 TAG_ACTIONS = """
 {% if perms.taggit.change_tag %}
 {% if perms.taggit.change_tag %}
@@ -14,6 +15,24 @@ TAG_ACTIONS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+OBJECTCHANGE_ACTION = """
+{% if record.action == 1 %}
+    <span class="label label-success">Created</span>
+{% elif record.action == 2 %}
+    <span class="label label-primary">Updated</span>
+{% elif record.action == 3 %}
+    <span class="label label-danger">Deleted</span>
+{% endif %}
+"""
+
+OBJECTCHANGE_OBJECT = """
+{% if record.action != 3 and record.changed_object.get_absolute_url %}
+    <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
+{% else %}
+    {{ record.object_repr }}
+{% endif %}
+"""
+
 
 
 class TagTable(BaseTable):
 class TagTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
@@ -26,3 +45,24 @@ class TagTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tag
         model = Tag
         fields = ('pk', 'name', 'items')
         fields = ('pk', 'name', 'items')
+
+
+class ObjectChangeTable(BaseTable):
+    time = tables.LinkColumn()
+    action = tables.TemplateColumn(
+        template_code=OBJECTCHANGE_ACTION
+    )
+    changed_object_type = tables.Column(
+        verbose_name='Type'
+    )
+    object_repr = tables.TemplateColumn(
+        template_code=OBJECTCHANGE_OBJECT,
+        verbose_name='Object'
+    )
+    request_id = tables.Column(
+        verbose_name='Request ID'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ObjectChange
+        fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')

+ 4 - 0
netbox/extras/urls.py

@@ -22,4 +22,8 @@ urlpatterns = [
     url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
     url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
     url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
     url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
 
 
+    # Change logging
+    url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    url(r'^changelog/(?P<pk>\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'),
+
 ]
 ]

+ 78 - 5
netbox/extras/views.py

@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from django import template
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.db.models import Count
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Count, Q
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render, reverse
 from django.shortcuts import get_object_or_404, redirect, render, reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -11,10 +13,11 @@ from taggit.models import Tag
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
 from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
-from .forms import ImageAttachmentForm, TagForm
-from .models import ImageAttachment, ReportResult, UserAction
+from . import filters
+from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm
+from .models import ImageAttachment, ObjectChange, ReportResult
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
-from .tables import TagTable
+from .tables import ObjectChangeTable, TagTable
 
 
 
 
 #
 #
@@ -50,6 +53,77 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
+#
+# Change logging
+#
+
+class ObjectChangeListView(ObjectListView):
+    queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
+    filter = filters.ObjectChangeFilter
+    filter_form = ObjectChangeFilterForm
+    table = ObjectChangeTable
+    template_name = 'extras/objectchange_list.html'
+
+
+class ObjectChangeView(View):
+
+    def get(self, request, pk):
+
+        objectchange = get_object_or_404(ObjectChange, pk=pk)
+
+        related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
+        related_changes_table = ObjectChangeTable(
+            data=related_changes[:50],
+            orderable=False
+        )
+
+        return render(request, 'extras/objectchange.html', {
+            'objectchange': objectchange,
+            'related_changes_table': related_changes_table,
+            'related_changes_count': related_changes.count()
+        })
+
+
+class ObjectChangeLogView(View):
+    """
+    Present a history of changes made to a particular object.
+    """
+
+    def get(self, request, model, **kwargs):
+
+        # Get object my model and kwargs (e.g. slug='foo')
+        obj = get_object_or_404(model, **kwargs)
+
+        # Gather all changes for this object (and its related objects)
+        content_type = ContentType.objects.get_for_model(model)
+        objectchanges = ObjectChange.objects.select_related(
+            'user', 'changed_object_type'
+        ).filter(
+            Q(changed_object_type=content_type, changed_object_id=obj.pk) |
+            Q(related_object_type=content_type, related_object_id=obj.pk)
+        )
+        objectchanges_table = ObjectChangeTable(
+            data=objectchanges,
+            orderable=False
+        )
+
+        # Check whether a header template exists for this model
+        base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
+        try:
+            template.loader.get_template(base_template)
+            object_var = model._meta.model_name
+        except template.TemplateDoesNotExist:
+            base_template = '_base.html'
+            object_var = 'obj'
+
+        return render(request, 'extras/object_changelog.html', {
+            object_var: obj,
+            'objectchanges_table': objectchanges_table,
+            'base_template': base_template,
+            'active_tab': 'changelog',
+        })
+
+
 #
 #
 # Image attachments
 # Image attachments
 #
 #
@@ -149,6 +223,5 @@ class ReportRunView(PermissionRequiredMixin, View):
             result = 'failed' if report.failed else 'passed'
             result = 'failed' if report.failed else 'passed'
             msg = "Ran report {} ({})".format(report.full_name, result)
             msg = "Ran report {} ({})".format(report.full_name, result)
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
-            UserAction.objects.log_create(request.user, report.result, msg)
 
 
         return redirect('extras:report', name=report.full_name)
         return redirect('extras:report', name=report.full_name)

+ 105 - 0
netbox/ipam/migrations/0023_change_logging.py

@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0022_tags'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rir',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='rir',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='role',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='role',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='aggregate',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='aggregate',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 18 - 22
netbox/ipam/models.py

@@ -14,14 +14,14 @@ from taggit.managers import TaggableManager
 
 
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
 from .constants import *
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
 from .querysets import PrefixQuerySet
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VRF(CreatedUpdatedModel, CustomFieldModel):
+class VRF(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
     table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
@@ -59,9 +59,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
-
     serializer = 'ipam.api.serializers.VRFSerializer'
     serializer = 'ipam.api.serializers.VRFSerializer'
+    csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['name', 'rd']
         ordering = ['name', 'rd']
@@ -91,7 +90,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class RIR(models.Model):
+class RIR(ChangeLoggedModel):
     """
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918.
     space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918.
@@ -109,6 +108,7 @@ class RIR(models.Model):
         help_text='IP space managed by this RIR is considered private'
         help_text='IP space managed by this RIR is considered private'
     )
     )
 
 
+    serializer = 'ipam.api.serializers.RIRSerializer'
     csv_headers = ['name', 'slug', 'is_private']
     csv_headers = ['name', 'slug', 'is_private']
 
 
     class Meta:
     class Meta:
@@ -131,7 +131,7 @@ class RIR(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Aggregate(CreatedUpdatedModel, CustomFieldModel):
+class Aggregate(ChangeLoggedModel, CustomFieldModel):
     """
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
     the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -162,9 +162,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['prefix', 'rir', 'date_added', 'description']
-
     serializer = 'ipam.api.serializers.AggregateSerializer'
     serializer = 'ipam.api.serializers.AggregateSerializer'
+    csv_headers = ['prefix', 'rir', 'date_added', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['family', 'prefix']
         ordering = ['family', 'prefix']
@@ -228,7 +227,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Role(models.Model):
+class Role(ChangeLoggedModel):
     """
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
     "Management."
     "Management."
@@ -244,6 +243,7 @@ class Role(models.Model):
         default=1000
         default=1000
     )
     )
 
 
+    serializer = 'ipam.api.serializers.RoleSerializer'
     csv_headers = ['name', 'slug', 'weight']
     csv_headers = ['name', 'slug', 'weight']
 
 
     class Meta:
     class Meta:
@@ -261,7 +261,7 @@ class Role(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Prefix(CreatedUpdatedModel, CustomFieldModel):
+class Prefix(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
     VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -336,12 +336,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'ipam.api.serializers.PrefixSerializer'
     csv_headers = [
     csv_headers = [
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
     ]
     ]
 
 
-    serializer = 'ipam.api.serializers.PrefixSerializer'
-
     class Meta:
     class Meta:
         ordering = ['vrf', 'family', 'prefix']
         ordering = ['vrf', 'family', 'prefix']
         verbose_name_plural = 'prefixes'
         verbose_name_plural = 'prefixes'
@@ -503,7 +502,7 @@ class IPAddressManager(models.Manager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class IPAddress(CreatedUpdatedModel, CustomFieldModel):
+class IPAddress(ChangeLoggedModel, CustomFieldModel):
     """
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
@@ -578,13 +577,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     objects = IPAddressManager()
     objects = IPAddressManager()
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'ipam.api.serializers.IPAddressSerializer'
     csv_headers = [
     csv_headers = [
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
         'description',
         'description',
     ]
     ]
 
 
-    serializer = 'ipam.api.serializers.IPAddressSerializer'
-
     class Meta:
     class Meta:
         ordering = ['family', 'address']
         ordering = ['family', 'address']
         verbose_name = 'IP address'
         verbose_name = 'IP address'
@@ -663,7 +661,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VLANGroup(models.Model):
+class VLANGroup(ChangeLoggedModel):
     """
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     """
     """
@@ -679,9 +677,8 @@ class VLANGroup(models.Model):
         null=True
         null=True
     )
     )
 
 
-    csv_headers = ['name', 'slug', 'site']
-
     serializer = 'ipam.api.serializers.VLANGroupSerializer'
     serializer = 'ipam.api.serializers.VLANGroupSerializer'
+    csv_headers = ['name', 'slug', 'site']
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
@@ -717,7 +714,7 @@ class VLANGroup(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VLAN(CreatedUpdatedModel, CustomFieldModel):
+class VLAN(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
     to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
     to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
@@ -778,9 +775,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
-
     serializer = 'ipam.api.serializers.VLANSerializer'
     serializer = 'ipam.api.serializers.VLANSerializer'
+    csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'group', 'vid']
         ordering = ['site', 'group', 'vid']
@@ -835,7 +831,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Service(CreatedUpdatedModel, CustomFieldModel):
+class Service(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
     optionally be tied to one or more specific IPAddresses belonging to its parent.
     optionally be tied to one or more specific IPAddresses belonging to its parent.

+ 9 - 0
netbox/ipam/tables.py

@@ -28,6 +28,9 @@ RIR_UTILIZATION = """
 """
 """
 
 
 RIR_ACTIONS = """
 RIR_ACTIONS = """
+<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.ipam.change_rir %}
 {% if perms.ipam.change_rir %}
     <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
@@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """
 """
 """
 
 
 ROLE_ACTIONS = """
 ROLE_ACTIONS = """
+<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.ipam.change_role %}
 {% if perms.ipam.change_role %}
     <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
@@ -127,6 +133,9 @@ VLAN_ROLE_LINK = """
 """
 """
 
 
 VLANGROUP_ACTIONS = """
 VLANGROUP_ACTIONS = """
+<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% with next_vid=record.get_next_available_vid %}
 {% with next_vid=record.get_next_available_vid %}
     {% if next_vid and perms.ipam.add_vlan %}
     {% if next_vid and perms.ipam.add_vlan %}
         <a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
         <a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">

+ 11 - 0
netbox/ipam/urls.py

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
+from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
 
 
 app_name = 'ipam'
 app_name = 'ipam'
@@ -17,6 +19,7 @@ urlpatterns = [
     url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
     url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
     url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
     url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
     url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
     url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
+    url(r'^vrfs/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
 
 
     # RIRs
     # RIRs
     url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
     url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
@@ -24,6 +27,7 @@ urlpatterns = [
     url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
     url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
     url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
     url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
     url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
     url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
+    url(r'^vrfs/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
 
 
     # Aggregates
     # Aggregates
     url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
     url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
@@ -34,6 +38,7 @@ urlpatterns = [
     url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
     url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
     url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
     url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
     url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
     url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
+    url(r'^aggregates/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
 
 
     # Roles
     # Roles
     url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
     url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
@@ -41,6 +46,7 @@ urlpatterns = [
     url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
     url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
     url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
     url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
     url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
     url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
+    url(r'^roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
 
 
     # Prefixes
     # Prefixes
     url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
     url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
@@ -51,6 +57,7 @@ urlpatterns = [
     url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
     url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
     url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
     url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
     url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
     url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
+    url(r'^prefixes/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
     url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
     url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
     url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
     url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
 
@@ -61,6 +68,7 @@ urlpatterns = [
     url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
+    url(r'^ip-addresses/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
     url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
     url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
@@ -72,6 +80,7 @@ urlpatterns = [
     url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
     url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
     url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
     url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
     url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
+    url(r'^vlan-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
 
 
     # VLANs
     # VLANs
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
@@ -83,6 +92,7 @@ urlpatterns = [
     url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
     url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
+    url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
 
 
     # Services
     # Services
     url(r'^services/$', views.ServiceListView.as_view(), name='service_list'),
     url(r'^services/$', views.ServiceListView.as_view(), name='service_list'),
@@ -91,5 +101,6 @@ urlpatterns = [
     url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'),
     url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'),
     url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
     url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
     url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
     url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
+    url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
 
 
 ]
 ]

+ 3 - 11
netbox/ipam/views.py

@@ -522,6 +522,7 @@ class PrefixPrefixesView(View):
             'prefix_table': prefix_table,
             'prefix_table': prefix_table,
             'permissions': permissions,
             'permissions': permissions,
             'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
             'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
+            'active_tab': 'prefixes',
         })
         })
 
 
 
 
@@ -560,6 +561,7 @@ class PrefixIPAddressesView(View):
             'ip_table': ip_table,
             'ip_table': ip_table,
             'permissions': permissions,
             'permissions': permissions,
             'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
             'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
+            'active_tab': 'ip-addresses',
         })
         })
 
 
 
 
@@ -859,8 +861,6 @@ class VLANMembersView(View):
         members = vlan.get_members().select_related('device', 'virtual_machine')
         members = vlan.get_members().select_related('device', 'virtual_machine')
 
 
         members_table = tables.VLANMemberTable(members)
         members_table = tables.VLANMemberTable(members)
-        # if request.user.has_perm('dcim.change_interface'):
-        #     members_table.columns.show('pk')
 
 
         paginate = {
         paginate = {
             'klass': EnhancedPaginator,
             'klass': EnhancedPaginator,
@@ -868,18 +868,10 @@ class VLANMembersView(View):
         }
         }
         RequestConfig(request, paginate).configure(members_table)
         RequestConfig(request, paginate).configure(members_table)
 
 
-        # Compile permissions list for rendering the object table
-        # permissions = {
-        #     'add': request.user.has_perm('ipam.add_ipaddress'),
-        #     'change': request.user.has_perm('ipam.change_ipaddress'),
-        #     'delete': request.user.has_perm('ipam.delete_ipaddress'),
-        # }
-
         return render(request, 'ipam/vlan_members.html', {
         return render(request, 'ipam/vlan_members.html', {
             'vlan': vlan,
             'vlan': vlan,
             'members_table': members_table,
             'members_table': members_table,
-            # 'permissions': permissions,
-            # 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
+            'active_tab': 'members',
         })
         })
 
 
 
 

+ 3 - 0
netbox/netbox/configuration.example.py

@@ -50,6 +50,9 @@ BANNER_LOGIN = ''
 # BASE_PATH = 'netbox/'
 # BASE_PATH = 'netbox/'
 BASE_PATH = ''
 BASE_PATH = ''
 
 
+# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
+CHANGELOG_RETENTION = 90
+
 # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
 # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
 # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
 # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
 # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
 # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers

+ 2 - 0
netbox/netbox/settings.py

@@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
+CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@@ -174,6 +175,7 @@ MIDDLEWARE = (
     'utilities.middleware.ExceptionHandlingMiddleware',
     'utilities.middleware.ExceptionHandlingMiddleware',
     'utilities.middleware.LoginRequiredMiddleware',
     'utilities.middleware.LoginRequiredMiddleware',
     'utilities.middleware.APIVersionMiddleware',
     'utilities.middleware.APIVersionMiddleware',
+    'extras.middleware.ChangeLoggingMiddleware',
 )
 )
 
 
 ROOT_URLCONF = 'netbox.urls'
 ROOT_URLCONF = 'netbox.urls'

+ 35 - 0
netbox/secrets/migrations/0005_change_logging.py

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:29
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('secrets', '0004_tags'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='secretrole',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='secretrole',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='secret',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='secret',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 12 - 4
netbox/secrets/models.py

@@ -14,7 +14,7 @@ from django.urls import reverse
 from django.utils.encoding import force_bytes, python_2_unicode_compatible
 from django.utils.encoding import force_bytes, python_2_unicode_compatible
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
 from .exceptions import InvalidKey
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
 from .hashers import SecretValidationHasher
 from .querysets import UserKeyQuerySet
 from .querysets import UserKeyQuerySet
@@ -48,12 +48,18 @@ def decrypt_master_key(master_key_cipher, private_key):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class UserKey(CreatedUpdatedModel):
+class UserKey(models.Model):
     """
     """
     A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
     A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
     copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
     copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
     matching (private) decryption key.
     matching (private) decryption key.
     """
     """
+    created = models.DateField(
+        auto_now_add=True
+    )
+    last_updated = models.DateTimeField(
+        auto_now=True
+    )
     user = models.OneToOneField(
     user = models.OneToOneField(
         to=User,
         to=User,
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
@@ -251,7 +257,7 @@ class SessionKey(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class SecretRole(models.Model):
+class SecretRole(ChangeLoggedModel):
     """
     """
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
     such as "Login Credentials" or "SNMP Communities."
     such as "Login Credentials" or "SNMP Communities."
@@ -277,6 +283,7 @@ class SecretRole(models.Model):
         blank=True
         blank=True
     )
     )
 
 
+    serializer = 'ipam.api.secrets.SecretSerializer'
     csv_headers = ['name', 'slug']
     csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
@@ -304,7 +311,7 @@ class SecretRole(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Secret(CreatedUpdatedModel):
+class Secret(ChangeLoggedModel):
     """
     """
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
     SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a
     SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a
@@ -340,6 +347,7 @@ class Secret(CreatedUpdatedModel):
     tags = TaggableManager()
     tags = TaggableManager()
 
 
     plaintext = None
     plaintext = None
+    serializer = 'ipam.api.secrets.SecretSerializer'
     csv_headers = ['device', 'role', 'name', 'plaintext']
     csv_headers = ['device', 'role', 'name', 'plaintext']
 
 
     class Meta:
     class Meta:

+ 3 - 0
netbox/secrets/tables.py

@@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import SecretRole, Secret
 from .models import SecretRole, Secret
 
 
 SECRETROLE_ACTIONS = """
 SECRETROLE_ACTIONS = """
+<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.secrets.change_secretrole %}
 {% if perms.secrets.change_secretrole %}
     <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}

+ 4 - 0
netbox/secrets/urls.py

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
+from .models import Secret, SecretRole
 
 
 app_name = 'secrets'
 app_name = 'secrets'
 urlpatterns = [
 urlpatterns = [
@@ -13,6 +15,7 @@ urlpatterns = [
     url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
     url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
     url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
     url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
+    url(r'^secret-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
 
 
     # Secrets
     # Secrets
     url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
     url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
@@ -22,5 +25,6 @@ urlpatterns = [
     url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
     url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
     url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
     url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
     url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
     url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
+    url(r'^secrets/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
 
 
 ]
 ]

+ 1 - 0
netbox/templates/_base.html

@@ -34,6 +34,7 @@
                 {{ message }}
                 {{ message }}
             </div>
             </div>
         {% endfor %}
         {% endfor %}
+        {% block header %}{% endblock %}
         {% block content %}{% endblock %}
         {% block content %}{% endblock %}
         <div class="push"></div>
         <div class="push"></div>
          {% if settings.BANNER_BOTTOM %}
          {% if settings.BANNER_BOTTOM %}

+ 49 - 36
netbox/templates/circuits/circuit.html

@@ -1,44 +1,57 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
-            <li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
-            <li>{{ circuit.cid }}</li>
-        </ol>
+{% block title %}{{ circuit }}{% endblock %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
+                <li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
+                <li>{{ circuit.cid }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'circuits:circuit_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'circuits:circuit_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.circuits.change_circuit %}
+            <a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this circuit
+            </a>
+        {% endif %}
+        {% if perms.circuits.delete_circuit %}
+            <a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this circuit
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.circuits.change_circuit %}
-		<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this circuit
-		</a>
-    {% endif %}
-    {% if perms.circuits.delete_circuit %}
-		<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this circuit
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=circuit %}
+    <h1>{{ circuit }}</h1>
+    {% include 'inc/created_updated.html' with obj=circuit %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ circuit.get_absolute_url }}">Circuit</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 54 - 41
netbox/templates/circuits/provider.html

@@ -2,49 +2,62 @@
 {% load static from staticfiles %}
 {% load static from staticfiles %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
-            <li>{{ provider }}</li>
-        </ol>
+{% block title %}{{ provider }}{% endblock %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
+                <li>{{ provider }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'circuits:provider_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'circuits:provider_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if show_graphs %}
+            <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
+                <i class="fa fa-signal" aria-hidden="true"></i>
+                Graphs
+            </button>
+        {% endif %}
+        {% if perms.circuits.change_provider %}
+            <a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this provider
+            </a>
+        {% endif %}
+        {% if perms.circuits.delete_provider %}
+            <a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this provider
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if show_graphs %}
-        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
-            <i class="fa fa-signal" aria-hidden="true"></i>
-            Graphs
-        </button>
-    {% endif %}
-    {% if perms.circuits.change_provider %}
-		<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this provider
-		</a>
-    {% endif %}
-    {% if perms.circuits.delete_provider %}
-		<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this provider
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ provider }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=provider %}
+    <h1>{{ provider }}</h1>
+    {% include 'inc/created_updated.html' with obj=provider %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ provider.get_absolute_url }}">Provider</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-4">
 	<div class="col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 625 - 555
netbox/templates/dcim/device.html

@@ -4,629 +4,699 @@
 
 
 {% block title %}{{ device }}{% endblock %}
 {% block title %}{{ device }}{% endblock %}
 
 
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+        <ol class="breadcrumb">
+            <li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
+            {% if device.rack %}
+                <li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
+                <li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
+            {% endif %}
+            {% if device.parent_bay %}
+                <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
+                <li>{{ device.parent_bay }}</li>
+            {% endif %}
+            <li>{{ device }}</li>
+        </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:device_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search devices" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.dcim.change_device %}
+            <a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
+                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+                Edit this device
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_device %}
+            <a href="{% url 'dcim:device_delete' pk=device.pk %}" class="btn btn-danger">
+                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+                Delete this device
+            </a>
+        {% endif %}
+    </div>
+    <h1>{{ device }}</h1>
+    {% include 'inc/created_updated.html' with obj=device %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{% url 'dcim:device' pk=device.pk %}">Device</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
+        </li>
+        {% if perms.dcim.napalm_read %}
+            {% if device.status != 1 %}
+                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
+            {% elif not device.platform %}
+                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
+            {% elif not device.platform.napalm_driver %}
+                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
+            {% elif not device.primary_ip %}
+                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
+            {% else %}
+                {% include 'dcim/inc/device_napalm_tabs.html' %}
+            {% endif %}
+        {% endif %}
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
 {% block content %}
 {% block content %}
-{% include 'dcim/inc/device_header.html' with active_tab='info' %}
-<div class="row">
-	<div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Device</strong>
-            </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Site</td>
-                    <td>
-                        {% if device.site.region %}
-                            <a href="{{ device.site.region.get_absolute_url }}">{{ device.site.region }}</a>
-                            <i class="fa fa-angle-right"></i>
-                        {% endif %}
-                        <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Rack</td>
-                    <td>
-                        {% if device.rack %}
-                            {% if device.rack.group %}
-                                <a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a>
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Device</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Site</td>
+                        <td>
+                            {% if device.site.region %}
+                                <a href="{{ device.site.region.get_absolute_url }}">{{ device.site.region }}</a>
                                 <i class="fa fa-angle-right"></i>
                                 <i class="fa fa-angle-right"></i>
                             {% endif %}
                             {% endif %}
-                            <a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Position</td>
-                    <td>
-                        {% if device.parent_bay %}
-                            {% with device.parent_bay.device as parent %}
-                                <a href="{{ parent.get_absolute_url }}">{{ parent }}</a> <i class="fa fa-angle-right"></i> {{ device.parent_bay }}
-                                {% if parent.position %}
-                                    (U{{ parent.position }} / {{ parent.get_face_display }})
+                            <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Rack</td>
+                        <td>
+                            {% if device.rack %}
+                                {% if device.rack.group %}
+                                    <a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
                                 {% endif %}
                                 {% endif %}
-                            {% endwith %}
-                        {% elif device.rack and device.position %}
-                            <span>U{{ device.position }} / {{ device.get_face_display }}</span>
-                        {% elif device.rack and device.device_type.u_height %}
-                            <span class="label label-warning">Not racked</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Tenant</td>
-                    <td>
-                        {% if device.tenant %}
-                            {% if device.tenant.group %}
-                                <a href="{{ device.tenant.group.get_absolute_url }}">{{ device.tenant.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+                                <a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Device Type</td>
-                    <td>
-                        <span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type.full_name }}</a> ({{ device.device_type.u_height }}U)</span>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Serial Number</td>
-                    <td>
-                        {% if device.serial %}
-                            <span>{{ device.serial }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Position</td>
+                        <td>
+                            {% if device.parent_bay %}
+                                {% with device.parent_bay.device as parent %}
+                                    <a href="{{ parent.get_absolute_url }}">{{ parent }}</a> <i class="fa fa-angle-right"></i> {{ device.parent_bay }}
+                                    {% if parent.position %}
+                                        (U{{ parent.position }} / {{ parent.get_face_display }})
+                                    {% endif %}
+                                {% endwith %}
+                            {% elif device.rack and device.position %}
+                                <span>U{{ device.position }} / {{ device.get_face_display }}</span>
+                            {% elif device.rack and device.device_type.u_height %}
+                                <span class="label label-warning">Not racked</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tenant</td>
+                        <td>
+                            {% if device.tenant %}
+                                {% if device.tenant.group %}
+                                    <a href="{{ device.tenant.group.get_absolute_url }}">{{ device.tenant.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Device Type</td>
+                        <td>
+                            <span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type.full_name }}</a> ({{ device.device_type.u_height }}U)</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Serial Number</td>
+                        <td>
+                            {% if device.serial %}
+                                <span>{{ device.serial }}</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Asset Tag</td>
+                        <td>
+                            {% if device.asset_tag %}
+                                <span>{{ device.asset_tag }}</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tags</td>
+                        <td>
+                            {% for tag in device.tags.all %}
+                                {% tag 'dcim:device_list' tag %}
+                            {% empty %}
+                                <span class="text-muted">N/A</span>
+                            {% endfor %}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+            {% if vc_members %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Virtual Chassis</strong>
+                    </div>
+                    <table class="table table-hover panel-body attr-table">
+                        <tr>
+                            <th>Device</th>
+                            <th>Position</th>
+                            <th>Master</th>
+                            <th>Priority</th>
+                        </tr>
+                        {% for vc_member in vc_members %}
+                            <tr{% if vc_member == device %} class="info"{% endif %}>
+                                <td>
+                                    <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
+                                </td>
+                                <td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
+                                <td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
+                                <td>{{ vc_member.vc_priority|default:"" }}</td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                    <div class="panel-footer text-right">
+                        {% if perms.dcim.change_virtualchassis %}
+                            <a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
+                            </a>
+                            <a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
+                            </a>
                         {% endif %}
                         {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Asset Tag</td>
-                    <td>
-                        {% if device.asset_tag %}
-                            <span>{{ device.asset_tag }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
+                        {% if perms.dcim.delete_virtualchassis %}
+                            <a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
+                            </a>
                         {% endif %}
                         {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Tags</td>
-                    <td>
-                        {% for tag in device.tags.all %}
-                            {% tag 'dcim:device_list' tag %}
-                        {% empty %}
-                            <span class="text-muted">N/A</span>
-                        {% endfor %}
-                    </td>
-                </tr>
-            </table>
-        </div>
-        {% if vc_members %}
+                    </div>
+                </div>
+            {% endif %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>Virtual Chassis</strong>
+                    <strong>Management</strong>
                 </div>
                 </div>
                 <table class="table table-hover panel-body attr-table">
                 <table class="table table-hover panel-body attr-table">
                     <tr>
                     <tr>
-                        <th>Device</th>
-                        <th>Position</th>
-                        <th>Master</th>
-                        <th>Priority</th>
+                        <td>Role</td>
+                        <td>
+                            <a href="{{ device.device_role.get_absolute_url }}">{{ device.device_role }}</a>
+                        </td>
                     </tr>
                     </tr>
-                    {% for vc_member in vc_members %}
-                        <tr{% if vc_member == device %} class="info"{% endif %}>
-                            <td>
-                                <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
-                            </td>
-                            <td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
-                            <td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
-                            <td>{{ vc_member.vc_priority|default:"" }}</td>
-                        </tr>
-                    {% endfor %}
-                </table>
-                <div class="panel-footer text-right">
-                    {% if perms.dcim.change_virtualchassis %}
-                        <a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
-                        </a>
-                        <a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
-                        </a>
-                    {% endif %}
-                    {% if perms.dcim.delete_virtualchassis %}
-                        <a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
-                        </a>
-                    {% endif %}
-                </div>
-            </div>
-        {% endif %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Management</strong>
-            </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Role</td>
-                    <td>
-                        <a href="{{ device.device_role.get_absolute_url }}">{{ device.device_role }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Platform</td>
-                    <td>
-                        {% if device.platform %}
-                            <span>{{ device.platform }}</span>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Status</td>
-                    <td>
-                        <span class="label label-{{ device.get_status_class }}">{{ device.get_status_display }}</span>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Primary IPv4</td>
-                    <td>
-                        {% if device.primary_ip4 %}
-                            <a href="{% url 'ipam:ipaddress' pk=device.primary_ip4.pk %}">{{ device.primary_ip4.address.ip }}</a>
-                            {% if device.primary_ip4.nat_inside %}
-                                <span>(NAT for {{ device.primary_ip4.nat_inside.address.ip }})</span>
-                            {% elif device.primary_ip4.nat_outside %}
-                                <span>(NAT: {{ device.primary_ip4.nat_outside.address.ip }})</span>
+                    <tr>
+                        <td>Platform</td>
+                        <td>
+                            {% if device.platform %}
+                                <span>{{ device.platform }}</span>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Primary IPv6</td>
-                    <td>
-                        {% if device.primary_ip6 %}
-                            <a href="{% url 'ipam:ipaddress' pk=device.primary_ip6.pk %}">{{ device.primary_ip6.address.ip }}</a>
-                            {% if device.primary_ip6.nat_inside %}
-                                <span>(NAT for {{ device.primary_ip6.nat_inside.address.ip }})</span>
-                            {% elif device.primary_ip6.nat_outside %}
-                                <span>(NAT: {{ device.primary_ip6.nat_outside.address.ip }})</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Status</td>
+                        <td>
+                            <span class="label label-{{ device.get_status_class }}">{{ device.get_status_display }}</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Primary IPv4</td>
+                        <td>
+                            {% if device.primary_ip4 %}
+                                <a href="{% url 'ipam:ipaddress' pk=device.primary_ip4.pk %}">{{ device.primary_ip4.address.ip }}</a>
+                                {% if device.primary_ip4.nat_inside %}
+                                    <span>(NAT for {{ device.primary_ip4.nat_inside.address.ip }})</span>
+                                {% elif device.primary_ip4.nat_outside %}
+                                    <span>(NAT: {{ device.primary_ip4.nat_outside.address.ip }})</span>
+                                {% endif %}
+                            {% else %}
+                                <span class="text-muted">N/A</span>
                             {% endif %}
                             {% endif %}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                {% if device.cluster %}
+                        </td>
+                    </tr>
                     <tr>
                     <tr>
-                        <td>Cluster</td>
+                        <td>Primary IPv6</td>
                         <td>
                         <td>
-                            {% if device.cluster.group %}
-                                <a href="{{ device.cluster.group.get_absolute_url }}">{{ device.cluster.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+                            {% if device.primary_ip6 %}
+                                <a href="{% url 'ipam:ipaddress' pk=device.primary_ip6.pk %}">{{ device.primary_ip6.address.ip }}</a>
+                                {% if device.primary_ip6.nat_inside %}
+                                    <span>(NAT for {{ device.primary_ip6.nat_inside.address.ip }})</span>
+                                {% elif device.primary_ip6.nat_outside %}
+                                    <span>(NAT: {{ device.primary_ip6.nat_outside.address.ip }})</span>
+                                {% endif %}
+                            {% else %}
+                                <span class="text-muted">N/A</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ device.cluster.get_absolute_url }}">{{ device.cluster }}</a>
                         </td>
                         </td>
                     </tr>
                     </tr>
-                {% endif %}
-            </table>
-        </div>
-        {% with device.get_custom_fields as custom_fields %}
-            {% include 'inc/custom_fields_panel.html' %}
-        {% endwith %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Comments</strong>
-            </div>
-            <div class="panel-body">
-                {% if device.comments %}
-                    {{ device.comments|gfm }}
-                {% else %}
-                    <span class="text-muted">None</span>
-                {% endif %}
-            </div>
-        </div>
-    </div>
-    <div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Console / Power</strong>
-            </div>
-            <table class="table table-hover panel-body component-list">
-                {% for cp in console_ports %}
-                    {% include 'dcim/inc/consoleport.html' %}
-                {% empty %}
-                    {% if device.device_type.console_port_templates.exists %}
+                    {% if device.cluster %}
                         <tr>
                         <tr>
-                            <td colspan="6" class="alert-warning">
-                                <i class="fa fa-fw fa-warning"></i> No console ports defined
-                                {% if perms.dcim.add_consoleport %}
-                                    <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
+                            <td>Cluster</td>
+                            <td>
+                                {% if device.cluster.group %}
+                                    <a href="{{ device.cluster.group.get_absolute_url }}">{{ device.cluster.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
                                 {% endif %}
                                 {% endif %}
+                                <a href="{{ device.cluster.get_absolute_url }}">{{ device.cluster }}</a>
                             </td>
                             </td>
                         </tr>
                         </tr>
                     {% endif %}
                     {% endif %}
-                {% endfor %}
-                {% for pp in power_ports %}
-                    {% include 'dcim/inc/powerport.html' %}
-                {% empty %}
-                    {% if device.device_type.power_port_templates.exists %}
-                        <tr>
-                            <td colspan="6" class="alert-warning">
-                                <i class="fa fa-fw fa-warning"></i> No power ports defined
-                                {% if perms.dcim.add_powerport %}
-                                    <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
-                                {% endif %}
-                            </td>
-                        </tr>
+                </table>
+            </div>
+            {% with device.get_custom_fields as custom_fields %}
+                {% include 'inc/custom_fields_panel.html' %}
+            {% endwith %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Comments</strong>
+                </div>
+                <div class="panel-body">
+                    {% if device.comments %}
+                        {{ device.comments|gfm }}
+                    {% else %}
+                        <span class="text-muted">None</span>
                     {% endif %}
                     {% endif %}
-                {% endfor %}
-            </table>
-            {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
-                <div class="panel-footer text-right">
-                    {% if perms.dcim.add_consoleport %}
-                        <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
-                        </a>
+                </div>
+            </div>
+        </div>
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Console / Power</strong>
+                </div>
+                <table class="table table-hover panel-body component-list">
+                    {% for cp in console_ports %}
+                        {% include 'dcim/inc/consoleport.html' %}
+                    {% empty %}
+                        {% if device.device_type.console_port_templates.exists %}
+                            <tr>
+                                <td colspan="6" class="alert-warning">
+                                    <i class="fa fa-fw fa-warning"></i> No console ports defined
+                                    {% if perms.dcim.add_consoleport %}
+                                        <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
+                                    {% endif %}
+                                </td>
+                            </tr>
+                        {% endif %}
+                    {% endfor %}
+                    {% for pp in power_ports %}
+                        {% include 'dcim/inc/powerport.html' %}
+                    {% empty %}
+                        {% if device.device_type.power_port_templates.exists %}
+                            <tr>
+                                <td colspan="6" class="alert-warning">
+                                    <i class="fa fa-fw fa-warning"></i> No power ports defined
+                                    {% if perms.dcim.add_powerport %}
+                                        <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
+                                    {% endif %}
+                                </td>
+                            </tr>
+                        {% endif %}
+                    {% endfor %}
+                </table>
+                {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
+                    <div class="panel-footer text-right">
+                        {% if perms.dcim.add_consoleport %}
+                            <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
+                            </a>
+                        {% endif %}
+                        {% if perms.dcim.add_powerport %}
+                            <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
+                            </a>
+                        {% endif %}
+                    </div>
+                {% endif %}
+            </div>
+            {% if request.user.is_authenticated %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Secrets</strong>
+                    </div>
+                    {% if secrets %}
+                        <table class="table table-hover panel-body">
+                            {% for secret in secrets %}
+                                {% include 'secrets/inc/secret_tr.html' %}
+                            {% endfor %}
+                        </table>
+                    {% else %}
+                        <div class="panel-body text-muted">
+                            None found
+                        </div>
                     {% endif %}
                     {% endif %}
-                    {% if perms.dcim.add_powerport %}
-                        <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
-                        </a>
+                    {% if perms.secrets.add_secret %}
+                        <form id="secret_form">
+                            {% csrf_token %}
+                        </form>
+                        <div class="panel-footer text-right">
+                            <a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                                Add secret
+                            </a>
+                        </div>
                     {% endif %}
                     {% endif %}
                 </div>
                 </div>
             {% endif %}
             {% endif %}
-        </div>
-        {% if request.user.is_authenticated %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>Secrets</strong>
+                    <strong>Services</strong>
                 </div>
                 </div>
-                {% if secrets %}
+                {% if services %}
                     <table class="table table-hover panel-body">
                     <table class="table table-hover panel-body">
-                        {% for secret in secrets %}
-                            {% include 'secrets/inc/secret_tr.html' %}
+                        {% for service in services %}
+                            {% include 'ipam/inc/service.html' %}
                         {% endfor %}
                         {% endfor %}
                     </table>
                     </table>
                 {% else %}
                 {% else %}
                     <div class="panel-body text-muted">
                     <div class="panel-body text-muted">
-                        None found
+                        None
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
-                {% if perms.secrets.add_secret %}
-                    <form id="secret_form">
-                        {% csrf_token %}
-                    </form>
+                {% if perms.ipam.add_service %}
                     <div class="panel-footer text-right">
                     <div class="panel-footer text-right">
-                        <a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                            Add secret
+                        <a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
                         </a>
                         </a>
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-        {% endif %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Services</strong>
-            </div>
-            {% if services %}
-                <table class="table table-hover panel-body">
-                    {% for service in services %}
-                        {% include 'ipam/inc/service.html' %}
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">
-                    None
-                </div>
-            {% endif %}
-            {% if perms.ipam.add_service %}
-                <div class="panel-footer text-right">
-                    <a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
-                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
-                    </a>
-                </div>
-            {% endif %}
-        </div>
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Images</strong>
-            </div>
-            {% include 'inc/image_attachments.html' with images=device.images.all %}
-            {% if perms.extras.add_imageattachment %}
-                <div class="panel-footer text-right">
-                    <a href="{% url 'dcim:device_add_image' object_id=device.pk %}" class="btn btn-primary btn-xs">
-                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                        Attach an image
-                    </a>
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Images</strong>
                 </div>
                 </div>
-            {% endif %}
-        </div>
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Related Devices</strong>
+                {% include 'inc/image_attachments.html' with images=device.images.all %}
+                {% if perms.extras.add_imageattachment %}
+                    <div class="panel-footer text-right">
+                        <a href="{% url 'dcim:device_add_image' object_id=device.pk %}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                            Attach an image
+                        </a>
+                    </div>
+                {% endif %}
             </div>
             </div>
-            {% if related_devices %}
-                <table class="table table-hover panel-body">
-                    {% for rd in related_devices %}
-                        <tr>
-                            <td>
-                                <a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
-                            </td>
-                            <td>
-                                {% if rd.rack %}
-                                    <a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
-                            </td>
-                            <td>{{ rd.device_type.full_name }}</td>
-                        </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">None found</div>
-            {% endif %}
-        </div>
-	</div>
-</div>
-<div class="row">
-	<div class="col-md-12">
-        {% if device_bays or device.device_type.is_parent_device %}
-            {% if perms.dcim.delete_devicebay %}
-                <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
-                {% csrf_token %}
-            {% endif %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>Device Bays</strong>
+                    <strong>Related Devices</strong>
                 </div>
                 </div>
-                <table class="table table-hover table-headings panel-body component-list">
-                    <thead>
-                        <tr>
-                            {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
-                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
-                            {% endif %}
-                            <th>Name</th>
-                            <th colspan="2">Installed Device</th>
-                            <th></th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {% for devicebay in device_bays %}
-                            {% include 'dcim/inc/devicebay.html' %}
-                        {% empty %}
+                {% if related_devices %}
+                    <table class="table table-hover panel-body">
+                        {% for rd in related_devices %}
                             <tr>
                             <tr>
-                                <td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
+                                <td>
+                                    <a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
+                                </td>
+                                <td>
+                                    {% if rd.rack %}
+                                        <a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
+                                    {% else %}
+                                        <span class="text-muted">&mdash;</span>
+                                    {% endif %}
+                                </td>
+                                <td>{{ rd.device_type.full_name }}</td>
                             </tr>
                             </tr>
                         {% endfor %}
                         {% endfor %}
-                    </tbody>
-                </table>
-                <div class="panel-footer">
-                    {% if device_bays and perms.dcim.change_devicebay %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
-                        </button>
-                    {% endif %}
-                    {% if device_bays and perms.dcim.delete_devicebay %}
-                        <button type="submit" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
-                        </button>
-                    {% endif %}
-                    {% if perms.dcim.add_devicebay %}
-                        <div class="pull-right">
-                            <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
-                            </a>
-                        </div>
-                        <div class="clearfix"></div>
-                    {% endif %}
-                 </div>
+                    </table>
+                {% else %}
+                    <div class="panel-body text-muted">None found</div>
+                {% endif %}
             </div>
             </div>
-            {% if perms.dcim.delete_devicebay %}
-                </form>
-            {% endif %}
-        {% endif %}
-        {% if interfaces or device.device_type.is_network_device %}
-            {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
-                <form method="post">
-                {% csrf_token %}
-                <input type="hidden" name="device" value="{{ device.pk }}" />
-            {% endif %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Interfaces</strong>
-                    <div class="pull-right">
-                        <button class="btn btn-default btn-xs toggle-ips" selected="selected">
-                            <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
-                        </button>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-12">
+            {% if device_bays or device.device_type.is_parent_device %}
+                {% if perms.dcim.delete_devicebay %}
+                    <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
+                    {% csrf_token %}
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Device Bays</strong>
                     </div>
                     </div>
-                </div>
-                <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
-                    <thead>
-                        <tr>
-                            {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
-                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
-                            {% endif %}
-                            <th>Name</th>
-                            <th>LAG</th>
-                            <th>Description</th>
-                            <th>MTU</th>
-                            <th>MAC Address</th>
-                            <th colspan="2">Connection</th>
-                            <th></th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {% for iface in interfaces %}
-                            {% include 'dcim/inc/interface.html' %}
-                        {% empty %}
+                    <table class="table table-hover table-headings panel-body component-list">
+                        <thead>
                             <tr>
                             <tr>
-                                <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
+                                {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
+                                    <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                {% endif %}
+                                <th>Name</th>
+                                <th colspan="2">Installed Device</th>
+                                <th></th>
                             </tr>
                             </tr>
-                        {% endfor %}
-                    </tbody>
-                </table>
-                <div class="panel-footer">
-                    {% if interfaces and perms.dcim.change_interface %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
-                        </button>
-                        <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
-                        </button>
-                    {% endif %}
-                    {% if interfaces and perms.dcim.delete_interfaceconnection %}
-                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                        </button>
-                    {% endif %}
-                    {% if interfaces and perms.dcim.delete_interface %}
-                        <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                        </button>
-                    {% endif %}
-                    {% if perms.dcim.add_interface %}
+                        </thead>
+                        <tbody>
+                            {% for devicebay in device_bays %}
+                                {% include 'dcim/inc/devicebay.html' %}
+                            {% empty %}
+                                <tr>
+                                    <td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                    <div class="panel-footer">
+                        {% if device_bays and perms.dcim.change_devicebay %}
+                            <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                            </button>
+                        {% endif %}
+                        {% if device_bays and perms.dcim.delete_devicebay %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_devicebay %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
+                                </a>
+                            </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
+                     </div>
+                </div>
+                {% if perms.dcim.delete_devicebay %}
+                    </form>
+                {% endif %}
+            {% endif %}
+            {% if interfaces or device.device_type.is_network_device %}
+                {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+                    <form method="post">
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Interfaces</strong>
                         <div class="pull-right">
                         <div class="pull-right">
-                            <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
-                            </a>
+                            <button class="btn btn-default btn-xs toggle-ips" selected="selected">
+                                <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
+                            </button>
                         </div>
                         </div>
-                        <div class="clearfix"></div>
-                    {% endif %}
-                 </div>
-            </div>
-            {% if perms.dcim.delete_interface %}
-                </form>
-            {% endif %}
-        {% endif %}
-        {% if cs_ports or device.device_type.is_console_server %}
-            {% if perms.dcim.delete_consoleserverport %}
-                <form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}">
-                {% csrf_token %}
-            {% endif %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Console Server Ports</strong>
-                </div>
-                <table class="table table-hover table-headings panel-body component-list">
-                    <thead>
-                        <tr>
-                            {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
-                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
-                            {% endif %}
-                            <th>Name</th>
-                            <th colspan="2">Connection</th>
-                            <th></th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {% for csp in cs_ports %}
-                            {% include 'dcim/inc/consoleserverport.html' %}
-                        {% empty %}
+                    </div>
+                    <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
+                        <thead>
                             <tr>
                             <tr>
-                                <td colspan="5" class="text-center text-muted">&mdash; No console server ports defined &mdash;</td>
+                                {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+                                    <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                {% endif %}
+                                <th>Name</th>
+                                <th>LAG</th>
+                                <th>Description</th>
+                                <th>MTU</th>
+                                <th>MAC Address</th>
+                                <th colspan="2">Connection</th>
+                                <th></th>
                             </tr>
                             </tr>
-                        {% endfor %}
-                    </tbody>
-                </table>
-                <div class="panel-footer">
-                    {% if cs_ports and perms.dcim.change_consoleport %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
-                        </button>
-                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                        </button>
-                    {% endif %}
-                    {% if cs_ports and perms.dcim.delete_consoleserverport %}
-                        <button type="submit" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                        </button>
-                    {% endif %}
-                    {% if perms.dcim.add_consoleserverport %}
-                        <div class="pull-right">
-                            <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
-                            </a>
-                        </div>
-                        <div class="clearfix"></div>
-                    {% endif %}
+                        </thead>
+                        <tbody>
+                            {% for iface in interfaces %}
+                                {% include 'dcim/inc/interface.html' %}
+                            {% empty %}
+                                <tr>
+                                    <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                    <div class="panel-footer">
+                        {% if interfaces and perms.dcim.change_interface %}
+                            <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                            </button>
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
+                            </button>
+                        {% endif %}
+                        {% if interfaces and perms.dcim.delete_interfaceconnection %}
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                            </button>
+                        {% endif %}
+                        {% if interfaces and perms.dcim.delete_interface %}
+                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_interface %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                                </a>
+                            </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
+                     </div>
                 </div>
                 </div>
-            </div>
-            {% if perms.dcim.delete_consoleserverport %}
-                </form>
-            {% endif %}
-        {% endif %}
-        {% if power_outlets or device.device_type.is_pdu %}
-            {% if perms.dcim.delete_poweroutlet %}
-                <form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}">
-                {% csrf_token %}
+                {% if perms.dcim.delete_interface %}
+                    </form>
+                {% endif %}
             {% endif %}
             {% endif %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Power Outlets</strong>
+            {% if cs_ports or device.device_type.is_console_server %}
+                {% if perms.dcim.delete_consoleserverport %}
+                    <form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}">
+                    {% csrf_token %}
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Console Server Ports</strong>
+                    </div>
+                    <table class="table table-hover table-headings panel-body component-list">
+                        <thead>
+                            <tr>
+                                {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
+                                    <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                {% endif %}
+                                <th>Name</th>
+                                <th colspan="2">Connection</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for csp in cs_ports %}
+                                {% include 'dcim/inc/consoleserverport.html' %}
+                            {% empty %}
+                                <tr>
+                                    <td colspan="5" class="text-center text-muted">&mdash; No console server ports defined &mdash;</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                    <div class="panel-footer">
+                        {% if cs_ports and perms.dcim.change_consoleport %}
+                            <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                            </button>
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                            </button>
+                        {% endif %}
+                        {% if cs_ports and perms.dcim.delete_consoleserverport %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_consoleserverport %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
+                                </a>
+                            </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
+                    </div>
                 </div>
                 </div>
-                <table class="table table-hover table-headings panel-body component-list">
-                    <thead>
-                        <tr>
-                            {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
-                                <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
-                            {% endif %}
-                            <th>Name</th>
-                            <th colspan="2">Connection</th>
-                            <th></th>
-                        </tr>
-                    </thead>
-                    <tbody>
-                        {% for po in power_outlets %}
-                            {% include 'dcim/inc/poweroutlet.html' %}
-                        {% empty %}
+                {% if perms.dcim.delete_consoleserverport %}
+                    </form>
+                {% endif %}
+            {% endif %}
+            {% if power_outlets or device.device_type.is_pdu %}
+                {% if perms.dcim.delete_poweroutlet %}
+                    <form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}">
+                    {% csrf_token %}
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Power Outlets</strong>
+                    </div>
+                    <table class="table table-hover table-headings panel-body component-list">
+                        <thead>
                             <tr>
                             <tr>
-                                <td colspan="5" class="text-center text-muted">&mdash; No power outlets defined &mdash;</td>
+                                {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
+                                    <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                {% endif %}
+                                <th>Name</th>
+                                <th colspan="2">Connection</th>
+                                <th></th>
                             </tr>
                             </tr>
-                        {% endfor %}
-                    </tbody>
-                </table>
-                <div class="panel-footer">
-                    {% if power_outlets and perms.dcim.change_powerport %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
-                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
-                        </button>
-                        <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
-                        </button>
-                    {% endif %}
-                    {% if power_outlets and perms.dcim.delete_poweroutlet %}
-                        <button type="submit" class="btn btn-danger btn-xs">
-                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
-                        </button>
-                    {% endif %}
-                    {% if perms.dcim.add_poweroutlet %}
-                        <div class="pull-right">
-                            <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
-                            </a>
-                        </div>
-                        <div class="clearfix"></div>
-                    {% endif %}
+                        </thead>
+                        <tbody>
+                            {% for po in power_outlets %}
+                                {% include 'dcim/inc/poweroutlet.html' %}
+                            {% empty %}
+                                <tr>
+                                    <td colspan="5" class="text-center text-muted">&mdash; No power outlets defined &mdash;</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                    <div class="panel-footer">
+                        {% if power_outlets and perms.dcim.change_powerport %}
+                            <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                            </button>
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                            </button>
+                        {% endif %}
+                        {% if power_outlets and perms.dcim.delete_poweroutlet %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_poweroutlet %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
+                                </a>
+                            </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
+                    </div>
                 </div>
                 </div>
-            </div>
-            {% if perms.dcim.delete_poweroutlet %}
-                </form>
+                {% if perms.dcim.delete_poweroutlet %}
+                    </form>
+                {% endif %}
             {% endif %}
             {% endif %}
-        {% endif %}
-	</div>
-</div>
+        </div>
+    </div>
 {% include 'inc/graphs_modal.html' %}
 {% include 'inc/graphs_modal.html' %}
 {% include 'secrets/inc/private_key_modal.html' %}
 {% include 'secrets/inc/private_key_modal.html' %}
 {% endblock %}
 {% endblock %}

+ 1 - 2
netbox/templates/dcim/device_config.html

@@ -1,11 +1,10 @@
-{% extends '_base.html' %}
+{% extends 'dcim/device.html' %}
 {% load staticfiles %}
 {% load staticfiles %}
 
 
 {% block title %}{{ device }} - Config{% endblock %}
 {% block title %}{{ device }} - Config{% endblock %}
 
 
 {% block content %}
 {% block content %}
     {% include 'inc/ajax_loader.html' %}
     {% include 'inc/ajax_loader.html' %}
-    {% include 'dcim/inc/device_header.html' with active_tab='config' %}
     <div class="row">
     <div class="row">
         <div class="col-md-10 col-md-offset-1">
         <div class="col-md-10 col-md-offset-1">
             <div class="panel panel-default">
             <div class="panel panel-default">

+ 66 - 67
netbox/templates/dcim/device_inventory.html

@@ -1,77 +1,76 @@
-{% extends '_base.html' %}
+{% extends 'dcim/device.html' %}
 
 
 {% block title %}{{ device }} - Inventory{% endblock %}
 {% block title %}{{ device }} - Inventory{% endblock %}
 
 
 {% block content %}
 {% block content %}
-{% include 'dcim/inc/device_header.html' with active_tab='inventory' %}
-<div class="row">
-    <div class="col-md-4">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Chassis</strong>
-            </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Model</td>
-                    <td>{{ device.device_type.full_name }}</td>
-                </tr>
-                <tr>
-                    <td>Serial Number</td>
-                    <td>
-                        {% if device.serial %}
-                            <span>{{ device.serial }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Asset Tag</td>
-                    <td>
-                        {% if device.asset_tag %}
-                            <span>{{ device.asset_tag }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-            </table>
-        </div>
-    </div>
-    <div class="col-md-8">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Hardware</strong>
-            </div>
-            <table class="table table-hover table-condensed panel-body" id="hardware">
-                <thead>
+    <div class="row">
+        <div class="col-md-4">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Chassis</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Model</td>
+                        <td>{{ device.device_type.full_name }}</td>
+                    </tr>
+                    <tr>
+                        <td>Serial Number</td>
+                        <td>
+                            {% if device.serial %}
+                                <span>{{ device.serial }}</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                     <tr>
-                        <th>Name</th>
-                        <th></th>
-                        <th>Manufacturer</th>
-                        <th>Part Number</th>
-                        <th>Serial Number</th>
-                        <th>Asset Tag</th>
-                        <th>Description</th>
-                        <th></th>
+                        <td>Asset Tag</td>
+                        <td>
+                            {% if device.asset_tag %}
+                                <span>{{ device.asset_tag }}</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
                     </tr>
                     </tr>
-                </thead>
-                <tbody>
-                    {% for item in inventory_items %}
-                        {% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
-                            {% include template_name %}
-                        {% endwith %}
-                    {% endfor %}
-                </tbody>
-            </table>
-            {% if perms.dcim.add_inventoryitem %}
-                <div class="panel-footer text-right">
-                    <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
-                        <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
-                    </a>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-8">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Hardware</strong>
                 </div>
                 </div>
-            {% endif %}
+                <table class="table table-hover table-condensed panel-body" id="hardware">
+                    <thead>
+                        <tr>
+                            <th>Name</th>
+                            <th></th>
+                            <th>Manufacturer</th>
+                            <th>Part Number</th>
+                            <th>Serial Number</th>
+                            <th>Asset Tag</th>
+                            <th>Description</th>
+                            <th></th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for item in inventory_items %}
+                            {% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
+                                {% include template_name %}
+                            {% endwith %}
+                        {% endfor %}
+                    </tbody>
+                </table>
+                {% if perms.dcim.add_inventoryitem %}
+                    <div class="panel-footer text-right">
+                        <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
+                            <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
+                        </a>
+                    </div>
+                {% endif %}
+            </div>
         </div>
         </div>
     </div>
     </div>
-</div>
 {% endblock %}
 {% endblock %}

+ 1 - 2
netbox/templates/dcim/device_lldp_neighbors.html

@@ -1,10 +1,9 @@
-{% extends '_base.html' %}
+{% extends 'dcim/device.html' %}
 
 
 {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
 {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
 
 
 {% block content %}
 {% block content %}
     {% include 'inc/ajax_loader.html' %}
     {% include 'inc/ajax_loader.html' %}
-    {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading">
         <div class="panel-heading">
             <strong>LLDP Neighbors</strong>
             <strong>LLDP Neighbors</strong>

+ 1 - 2
netbox/templates/dcim/device_status.html

@@ -1,11 +1,10 @@
-{% extends '_base.html' %}
+{% extends 'dcim/device.html' %}
 {% load staticfiles %}
 {% load staticfiles %}
 
 
 {% block title %}{{ device }} - Status{% endblock %}
 {% block title %}{{ device }} - Status{% endblock %}
 
 
 {% block content %}
 {% block content %}
     {% include 'inc/ajax_loader.html' %}
     {% include 'inc/ajax_loader.html' %}
-    {% include 'dcim/inc/device_header.html' with active_tab='status' %}
     <div class="row">
     <div class="row">
         <div class="col-md-6">
         <div class="col-md-6">
             <div class="panel panel-default">
             <div class="panel panel-default">

+ 40 - 27
netbox/templates/dcim/devicetype.html

@@ -1,34 +1,47 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-12">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:devicetype_list' %}">Device Types</a></li>
-            <li><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></li>
-            <li>{{ devicetype.model }}</li>
-        </ol>
-    </div>
-</div>
-{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
-    <div class="pull-right">
-      {% if perms.dcim.change_devicetype %}
-            <a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
-              <span class="fa fa-pencil" aria-hidden="true"></span>
-              Edit this device type
-            </a>
-      {% endif %}
-      {% if perms.dcim.delete_devicetype %}
-          <a href="{% url 'dcim:devicetype_delete' pk=devicetype.pk %}" class="btn btn-danger">
-          	<span class="fa fa-trash" aria-hidden="true"></span>
-          	Delete this device type
-          </a>
-      {% endif %}
+{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:devicetype_list' %}">Device Types</a></li>
+                <li><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></li>
+                <li>{{ devicetype.model }}</li>
+            </ol>
+        </div>
     </div>
     </div>
-{% endif %}
-<h1>{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=devicetype %}
+    {% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
+        <div class="pull-right">
+          {% if perms.dcim.change_devicetype %}
+                <a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
+                  <span class="fa fa-pencil" aria-hidden="true"></span>
+                  Edit this device type
+                </a>
+          {% endif %}
+          {% if perms.dcim.delete_devicetype %}
+              <a href="{% url 'dcim:devicetype_delete' pk=devicetype.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this device type
+              </a>
+          {% endif %}
+        </div>
+    {% endif %}
+    <h1>{{ devicetype.manufacturer }} {{ devicetype.model }}</h1>
+    {% include 'inc/created_updated.html' with obj=devicetype %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ devicetype.get_absolute_url }}">Device Type</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
     <div class="col-md-5">
     <div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 0 - 65
netbox/templates/dcim/inc/device_header.html

@@ -1,65 +0,0 @@
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-    <ol class="breadcrumb">
-        <li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
-        {% if device.rack %}
-            <li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
-            <li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
-        {% endif %}
-        {% if device.parent_bay %}
-            <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
-            <li>{{ device.parent_bay }}</li>
-        {% endif %}
-        <li>{{ device }}</li>
-    </ol>
-    </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'dcim:device_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search devices" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
-    </div>
-</div>
-<div class="pull-right">
-    {% if perms.dcim.change_device %}
-		<a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
-			Edit this device
-		</a>
-    {% endif %}
-    {% if perms.dcim.delete_device %}
-		<a href="{% url 'dcim:device_delete' pk=device.pk %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
-			Delete this device
-		</a>
-{% endif %}
-</div>
-<h1>{{ device }}</h1>
-{% include 'inc/created_updated.html' with obj=device %}
-<ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
-    </li>
-    <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
-    </li>
-    {% if perms.dcim.napalm_read %}
-        {% if device.status != 1 %}
-            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
-        {% elif not device.platform %}
-            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
-        {% elif not device.platform.napalm_driver %}
-            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
-        {% elif not device.primary_ip %}
-            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
-        {% else %}
-            {% include 'dcim/inc/device_napalm_tabs.html' %}
-        {% endif %}
-    {% endif %}
-</ul>

+ 51 - 40
netbox/templates/dcim/rack.html

@@ -1,48 +1,59 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
-            <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
-            <li>{{ rack }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
+                <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
+                <li>{{ rack }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:rack_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search racks" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'dcim:rack_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search racks" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        <a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
+            <span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
+        </a>
+        <a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
+            <span class="fa fa-chevron-right" aria-hidden="true"></span> Next Rack
+        </a>
+        {% if perms.dcim.change_rack %}
+            <a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span> Edit this rack
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_rack %}
+            <a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span> Delete this rack
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    <a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
-        <span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
-    </a>
-    <a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
-        <span class="fa fa-chevron-right" aria-hidden="true"></span> Next Rack
-    </a>
-    {% if perms.dcim.change_rack %}
-		<a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span> Edit this rack
-		</a>
-    {% endif %}
-    {% if perms.dcim.delete_rack %}
-		<a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span> Delete this rack
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=rack %}
+    <h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=rack %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ rack.get_absolute_url }}">Rack</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 58 - 46
netbox/templates/dcim/site.html

@@ -3,54 +3,66 @@
 {% load tz %}
 {% load tz %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            {% if site.region %}
-                {% for region in site.region.get_ancestors %}
-                    <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
-                {% endfor %}
-                <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
-            {% endif %}
-            <li>{{ site }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
+                {% if site.region %}
+                    {% for region in site.region.get_ancestors %}
+                        <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
+                    {% endfor %}
+                    <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
+                {% endif %}
+                <li>{{ site }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:site_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search sites" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'dcim:site_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search sites" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if show_graphs %}
+            <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
+                <i class="fa fa-signal" aria-hidden="true"></i>
+                Graphs
+            </button>
+        {% endif %}
+        {% if perms.dcim.change_site %}
+            <a href="{% url 'dcim:site_edit' slug=site.slug %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this site
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_site %}
+            <a href="{% url 'dcim:site_delete' slug=site.slug %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this site
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if show_graphs %}
-        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
-            <i class="fa fa-signal" aria-hidden="true"></i>
-            Graphs
-        </button>
-    {% endif %}
-    {% if perms.dcim.change_site %}
-		<a href="{% url 'dcim:site_edit' slug=site.slug %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this site
-		</a>
-    {% endif %}
-    {% if perms.dcim.delete_site %}
-		<a href="{% url 'dcim:site_delete' slug=site.slug %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this site
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ site }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=site %}
+    <h1>{% block title %}{{ site }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=site %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ site.get_absolute_url }}">Site</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-7">
 	<div class="col-md-7">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 8 - 0
netbox/templates/extras/object_changelog.html

@@ -0,0 +1,8 @@
+{% extends base_template %}
+
+{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
+
+{% block content %}
+    {% if obj %}<h1>{{ obj }}</h1>{% endif %}
+    {% include 'panel_table.html' with table=objectchanges_table %}
+{% endblock %}

+ 101 - 0
netbox/templates/extras/objectchange.html

@@ -0,0 +1,101 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}{{ objectchange }}{% endblock %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
+                <li>{{ objectchange }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'extras:objectchange_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-5">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Change</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Time</td>
+                        <td>
+                            {{ objectchange.time }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>User</td>
+                        <td>
+                            {{ objectchange.user|default:objectchange.user_name }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Action</td>
+                        <td>
+                            {{ objectchange.get_action_display }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Object Type</td>
+                        <td>
+                            {{ objectchange.changed_object_type }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Object</td>
+                        <td>
+                            {% if objectchange.changed_object.get_absolute_url %}
+                                <a href="{{ objectchange.changed_object.get_absolute_url }}">{{ objectchange.changed_object }}</a>
+                            {% else %}
+                                {{ objectchange.object_repr }}
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Request ID</td>
+                        <td>
+                            {{ objectchange.request_id }}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-7">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Object Data</strong>
+                </div>
+                <div class="panel-body">
+                    <pre>{{ objectchange.object_data_pretty }}</pre>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'panel_table.html' with table=related_changes_table heading='Related Changes' %}
+            {% if related_changes_count > related_changes_table.rows|length %}
+                <div class="pull-right">
+                    <a href="{% url 'extras:objectchange_list' %}?request_id={{ objectchange.request_id }}" class="btn btn-primary">See all {{ related_changes_count|add:"1" }} changes</a>
+                </div>
+            {% endif %}
+        </div>
+    </div>
+{% endblock %}

+ 17 - 0
netbox/templates/extras/objectchange_list.html

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Changelog{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 3 - 0
netbox/templates/inc/nav_menu.html

@@ -66,6 +66,9 @@
                         <li>
                         <li>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                         </li>
+                        <li>
+                            <a href="{% url 'extras:objectchange_list' %}">Changelog</a>
+                        </li>
                     </ul>
                     </ul>
                 </li>
                 </li>
                 <li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
                 <li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">

+ 47 - 36
netbox/templates/ipam/aggregate.html

@@ -1,44 +1,55 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
-            <li><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
-            <li>{{ aggregate }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
+                <li><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
+                <li>{{ aggregate }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'ipam:aggregate_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search aggregates" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'ipam:aggregate_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search aggregates" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.ipam.change_aggregate %}
+            <a href="{% url 'ipam:aggregate_edit' pk=aggregate.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this aggregate
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_aggregate %}
+            <a href="{% url 'ipam:aggregate_delete' pk=aggregate.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this aggregate
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.change_aggregate %}
-        <a href="{% url 'ipam:aggregate_edit' pk=aggregate.pk %}" class="btn btn-warning">
-            <span class="fa fa-pencil" aria-hidden="true"></span>
-            Edit this aggregate
-        </a>
-    {% endif %}
-    {% if perms.ipam.delete_aggregate %}
-        <a href="{% url 'ipam:aggregate_delete' pk=aggregate.pk %}" class="btn btn-danger">
-            <span class="fa fa-trash" aria-hidden="true"></span>
-            Delete this aggregate
-        </a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=aggregate %}
+    <h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=aggregate %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ aggregate.get_absolute_url }}">Aggregate</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 0 - 55
netbox/templates/ipam/inc/prefix_header.html

@@ -1,55 +0,0 @@
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
-            {% if prefix.vrf %}
-                <li><a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a></li>
-            {% endif %}
-            <li>{{ prefix }}</li>
-        </ol>
-    </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'ipam:prefix_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search prefixes" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
-    </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
-        <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ prefix.vrf.pk }}&site={{ prefix.site.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
-            <i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
-        </a>
-    {% endif %}
-    {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
-		<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add an IP Address
-		</a>
-    {% endif %}
-    {% if perms.ipam.change_prefix %}
-		<a href="{% url 'ipam:prefix_edit' pk=prefix.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this prefix
-		</a>
-    {% endif %}
-    {% if perms.ipam.delete_prefix %}
-		<a href="{% url 'ipam:prefix_delete' pk=prefix.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this prefix
-		</a>
-    {% endif %}
-</div>
-<h1>{{ prefix }}</h1>
-{% include 'inc/created_updated.html' with obj=prefix %}
-<ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
-    <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a></li>
-    <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a></li>
-</ul>

+ 3 - 0
netbox/templates/ipam/inc/service.html

@@ -14,6 +14,9 @@
     </td>
     </td>
     <td>{{ service.description }}</td>
     <td>{{ service.description }}</td>
     <td class="text-right">
     <td class="text-right">
+        <a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Changelog">
+            <i class="fa fa-history"></i>
+        </a>
         {% if perms.ipam.change_service %}
         {% if perms.ipam.change_service %}
             <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
             <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
                 <i class="glyphicon glyphicon-pencil"></i>
                 <i class="glyphicon glyphicon-pencil"></i>

+ 0 - 46
netbox/templates/ipam/inc/vlan_header.html

@@ -1,46 +0,0 @@
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
-            {% if vlan.site %}
-                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
-            {% endif %}
-            {% if vlan.group %}
-                <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
-            {% endif %}
-            <li>{{ vlan }}</li>
-        </ol>
-    </div>
-    <div class="col-sm-4 col-md-3">
-    <form action="{% url 'ipam:vlan_list' %}" method="get">
-        <div class="input-group">
-            <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
-            <span class="input-group-btn">
-                <button type="submit" class="btn btn-primary">
-                    <span class="fa fa-search" aria-hidden="true"></span>
-                </button>
-            </span>
-        </div>
-    </form>
-    </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.change_vlan %}
-        <a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
-            <span class="fa fa-pencil" aria-hidden="true"></span>
-            Edit this VLAN
-        </a>
-    {% endif %}
-    {% if perms.ipam.delete_vlan %}
-        <a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
-            <span class="fa fa-trash" aria-hidden="true"></span>
-            Delete this VLAN
-        </a>
-    {% endif %}
-</div>
-<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=vlan %}
-<ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'vlan' %} class="active"{% endif %}><a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a></li>
-    <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}><a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a></li>
-</ul>

+ 49 - 38
netbox/templates/ipam/ipaddress.html

@@ -1,46 +1,57 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
-            {% if ipaddress.vrf %}
-                <li><a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a></li>
-            {% endif %}
-            <li>{{ ipaddress }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
+                {% if ipaddress.vrf %}
+                    <li><a href="{% url 'ipam:vrf' pk=ipaddress.vrf.pk %}">{{ ipaddress.vrf }}</a></li>
+                {% endif %}
+                <li>{{ ipaddress }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'ipam:ipaddress_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search IPs" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'ipam:ipaddress_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search IPs" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.ipam.change_ipaddress %}
+            <a href="{% url 'ipam:ipaddress_edit' pk=ipaddress.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this IP
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_ipaddress %}
+            <a href="{% url 'ipam:ipaddress_delete' pk=ipaddress.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this IP
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.change_ipaddress %}
-        <a href="{% url 'ipam:ipaddress_edit' pk=ipaddress.pk %}" class="btn btn-warning">
-            <span class="fa fa-pencil" aria-hidden="true"></span>
-            Edit this IP
-        </a>
-    {% endif %}
-    {% if perms.ipam.delete_ipaddress %}
-        <a href="{% url 'ipam:ipaddress_delete' pk=ipaddress.pk %}" class="btn btn-danger">
-            <span class="fa fa-trash" aria-hidden="true"></span>
-            Delete this IP
-        </a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ ipaddress }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=ipaddress %}
+    <h1>{% block title %}{{ ipaddress }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=ipaddress %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ ipaddress.get_absolute_url }}">IP Address</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-4">
 	<div class="col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 205 - 141
netbox/templates/ipam/prefix.html

@@ -1,152 +1,216 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block title %}{{ prefix }}{% endblock %}
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
+                {% if prefix.vrf %}
+                    <li><a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a></li>
+                {% endif %}
+                <li>{{ prefix }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'ipam:prefix_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search prefixes" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
+            <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ prefix.vrf.pk }}&site={{ prefix.site.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
+                <i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
+            </a>
+        {% endif %}
+        {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
+            <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
+                <span class="fa fa-plus" aria-hidden="true"></span>
+                Add an IP Address
+            </a>
+        {% endif %}
+        {% if perms.ipam.change_prefix %}
+            <a href="{% url 'ipam:prefix_edit' pk=prefix.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this prefix
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_prefix %}
+            <a href="{% url 'ipam:prefix_delete' pk=prefix.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this prefix
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ prefix }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=prefix %}
+    <ul class="nav nav-tabs" style="margin-bottom: 20px">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
+        </li>
+        <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
 
 
 {% block content %}
 {% block content %}
-{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %}
-<div class="row">
-	<div class="col-md-5">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Prefix</strong>
-            </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Family</td>
-                    <td>{{ prefix.get_family_display }}</td>
-                </tr>
-                <tr>
-                    <td>VRF</td>
-                    <td>
-                        {% if prefix.vrf %}
-                            <a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a> ({{ prefix.vrf.rd }})
-                        {% else %}
-                            <span>Global</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Tenant</td>
-                    <td>
-                        {% if prefix.tenant %}
-                            {% if prefix.tenant.group %}
-                                <a href="{{ prefix.tenant.group.get_absolute_url }}">{{ prefix.tenant.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+    <div class="row">
+        <div class="col-md-5">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Prefix</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Family</td>
+                        <td>{{ prefix.get_family_display }}</td>
+                    </tr>
+                    <tr>
+                        <td>VRF</td>
+                        <td>
+                            {% if prefix.vrf %}
+                                <a href="{% url 'ipam:vrf' pk=prefix.vrf.pk %}">{{ prefix.vrf }}</a> ({{ prefix.vrf.rd }})
+                            {% else %}
+                                <span>Global</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
-                        {% elif prefix.vrf.tenant %}
-                            {% if prefix.vrf.tenant.group %}
-                                <a href="{{ prefix.vrf.tenant.group.get_absolute_url }}">{{ prefix.vrf.tenant.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tenant</td>
+                        <td>
+                            {% if prefix.tenant %}
+                                {% if prefix.tenant.group %}
+                                    <a href="{{ prefix.tenant.group.get_absolute_url }}">{{ prefix.tenant.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
+                            {% elif prefix.vrf.tenant %}
+                                {% if prefix.vrf.tenant.group %}
+                                    <a href="{{ prefix.vrf.tenant.group.get_absolute_url }}">{{ prefix.vrf.tenant.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
+                                <label class="label label-info">Inherited</label>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
-                            <label class="label label-info">Inherited</label>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Aggregate</td>
-                    <td>
-                        {% if aggregate %}
-                            <a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
-                        {% else %}
-                            <span class="text-warning">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Site</td>
-                    <td>
-                        {% if prefix.site %}
-                            {% if prefix.site.region %}
-                                <a href="{{ prefix.site.region.get_absolute_url }}">{{ prefix.site.region }}</a>
-                                <i class="fa fa-angle-right"></i>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Aggregate</td>
+                        <td>
+                            {% if aggregate %}
+                                <a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
+                            {% else %}
+                                <span class="text-warning">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>VLAN</td>
-                    <td>
-                        {% if prefix.vlan %}
-                            {% if prefix.vlan.group %}
-                                <a href="{{ prefix.vlan.group.get_absolute_url }}">{{ prefix.vlan.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Site</td>
+                        <td>
+                            {% if prefix.site %}
+                                {% if prefix.site.region %}
+                                    <a href="{{ prefix.site.region.get_absolute_url }}">{{ prefix.site.region }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Status</td>
-                    <td>
-                        <span class="label label-{{ prefix.get_status_class }}">{{ prefix.get_status_display }}</span>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Role</td>
-                    <td>
-                        {% if prefix.role %}
-                            <a href="{% url 'ipam:prefix_list' %}?role={{ prefix.role.slug }}">{{ prefix.role }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Description</td>
-                    <td>
-                        {% if prefix.description %}
-                            <span>{{ prefix.description }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Is a pool</td>
-                    <td>
-                        {% if prefix.is_pool %}
-                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
-                        {% else %}
-                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Tags</td>
-                    <td>
-                        {% for tag in prefix.tags.all %}
-                            {% tag 'ipam:prefix_list' tag %}
-                        {% empty %}
-                            <span class="text-muted">N/A</span>
-                        {% endfor %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Utilization</td>
-                    <td>{% utilization_graph prefix.get_utilization %}</td>
-                </tr>
-            </table>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>VLAN</td>
+                        <td>
+                            {% if prefix.vlan %}
+                                {% if prefix.vlan.group %}
+                                    <a href="{{ prefix.vlan.group.get_absolute_url }}">{{ prefix.vlan.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Status</td>
+                        <td>
+                            <span class="label label-{{ prefix.get_status_class }}">{{ prefix.get_status_display }}</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Role</td>
+                        <td>
+                            {% if prefix.role %}
+                                <a href="{% url 'ipam:prefix_list' %}?role={{ prefix.role.slug }}">{{ prefix.role }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Description</td>
+                        <td>
+                            {% if prefix.description %}
+                                <span>{{ prefix.description }}</span>
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Is a pool</td>
+                        <td>
+                            {% if prefix.is_pool %}
+                                <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                            {% else %}
+                                <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tags</td>
+                        <td>
+                            {% for tag in prefix.tags.all %}
+                                {% tag 'ipam:prefix_list' tag %}
+                            {% empty %}
+                                <span class="text-muted">N/A</span>
+                            {% endfor %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Utilization</td>
+                        <td>{% utilization_graph prefix.get_utilization %}</td>
+                    </tr>
+                </table>
+            </div>
+            {% with prefix.get_custom_fields as custom_fields %}
+                {% include 'inc/custom_fields_panel.html' %}
+            {% endwith %}
+            <br />
         </div>
         </div>
-        {% with prefix.get_custom_fields as custom_fields %}
-            {% include 'inc/custom_fields_panel.html' %}
-        {% endwith %}
-        <br />
-	</div>
-	<div class="col-md-7">
-        {% if duplicate_prefix_table.rows %}
-            {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
-        {% endif %}
-        {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
-	</div>
-</div>
+        <div class="col-md-7">
+            {% if duplicate_prefix_table.rows %}
+                {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
+            {% endif %}
+            {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 2 - 3
netbox/templates/ipam/prefix_ipaddresses.html

@@ -1,9 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'ipam/prefix.html' %}
 
 
-{% block title %}{{ prefix }} - IP Addresses{% endblock %}
+{% block title %}{{ block.super }} - IP Addresses{% endblock %}
 
 
 {% block content %}
 {% block content %}
-    {% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
             {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
             {% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}

+ 2 - 3
netbox/templates/ipam/prefix_prefixes.html

@@ -1,9 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'ipam/prefix.html' %}
 
 
-{% block title %}{{ prefix }} - Prefixes{% endblock %}
+{% block title %}{{ block.super }} - Prefixes{% endblock %}
 
 
 {% block content %}
 {% block content %}
-    {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
             {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
             {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}

+ 163 - 108
netbox/templates/ipam/vlan.html

@@ -1,118 +1,173 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
-<div class="row">
-	<div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>VLAN</strong>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
+                {% if vlan.site %}
+                    <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
+                {% endif %}
+                {% if vlan.group %}
+                    <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
+                {% endif %}
+                <li>{{ vlan }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+        <form action="{% url 'ipam:vlan_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
+                <span class="input-group-btn">
+                    <button type="submit" class="btn btn-primary">
+                        <span class="fa fa-search" aria-hidden="true"></span>
+                    </button>
+                </span>
             </div>
             </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Site</td>
-                    <td>
-                        {% if vlan.site %}
-                            {% if vlan.site.region %}
-                                <a href="{{ vlan.site.region.get_absolute_url }}">{{ vlan.site.region }}</a>
-                                <i class="fa fa-angle-right"></i>
+        </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.ipam.change_vlan %}
+            <a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this VLAN
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_vlan %}
+            <a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this VLAN
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=vlan %}
+    <ul class="nav nav-tabs" style="margin-bottom: 20px">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>VLAN</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Site</td>
+                        <td>
+                            {% if vlan.site %}
+                                {% if vlan.site.region %}
+                                    <a href="{{ vlan.site.region.get_absolute_url }}">{{ vlan.site.region }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Group</td>
-                    <td>
-                        {% if vlan.group %}
-                            <a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>VLAN ID</td>
-                    <td>{{ vlan.vid }}</td>
-                </tr>
-                <tr>
-                    <td>Name</td>
-                    <td>{{ vlan.name }}</td>
-                </tr>
-                <tr>
-                    <td>Tenant</td>
-                    <td>
-                        {% if vlan.tenant %}
-                            {% if vlan.tenant.group %}
-                                <a href="{{ vlan.tenant.group.get_absolute_url }}">{{ vlan.tenant.group }}</a>
-                                <i class="fa fa-angle-right"></i>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Group</td>
+                        <td>
+                            {% if vlan.group %}
+                                <a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Status</td>
-                    <td>
-                        <span class="label label-{{ vlan.get_status_class }}">{{ vlan.get_status_display }}</span>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Role</td>
-                    <td>
-                        {% if vlan.role %}
-                            <a href="{% url 'ipam:vlan_list' %}?role={{ vlan.role.slug }}">{{ vlan.role }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Description</td>
-                    <td>
-                        {% if vlan.description %}
-                            {{ vlan.description }}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Tags</td>
-                    <td>
-                        {% for tag in vlan.tags.all %}
-                            {% tag 'ipam:vlan_list' tag %}
-                        {% empty %}
-                            <span class="text-muted">N/A</span>
-                        {% endfor %}
-                    </td>
-                </tr>
-		    </table>
-        </div>
-        {% with vlan.get_custom_fields as custom_fields %}
-            {% include 'inc/custom_fields_panel.html' %}
-        {% endwith %}
-	</div>
-	<div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Prefixes</strong>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>VLAN ID</td>
+                        <td>{{ vlan.vid }}</td>
+                    </tr>
+                    <tr>
+                        <td>Name</td>
+                        <td>{{ vlan.name }}</td>
+                    </tr>
+                    <tr>
+                        <td>Tenant</td>
+                        <td>
+                            {% if vlan.tenant %}
+                                {% if vlan.tenant.group %}
+                                    <a href="{{ vlan.tenant.group.get_absolute_url }}">{{ vlan.tenant.group }}</a>
+                                    <i class="fa fa-angle-right"></i>
+                                {% endif %}
+                                <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Status</td>
+                        <td>
+                            <span class="label label-{{ vlan.get_status_class }}">{{ vlan.get_status_display }}</span>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Role</td>
+                        <td>
+                            {% if vlan.role %}
+                                <a href="{% url 'ipam:vlan_list' %}?role={{ vlan.role.slug }}">{{ vlan.role }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Description</td>
+                        <td>
+                            {% if vlan.description %}
+                                {{ vlan.description }}
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tags</td>
+                        <td>
+                            {% for tag in vlan.tags.all %}
+                                {% tag 'ipam:vlan_list' tag %}
+                            {% empty %}
+                                <span class="text-muted">N/A</span>
+                            {% endfor %}
+                        </td>
+                    </tr>
+                </table>
             </div>
             </div>
-            {% include 'responsive_table.html' with table=prefix_table %}
-            {% if perms.ipam.add_prefix %}
-                <div class="panel-footer text-right">
-                    <a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs">
-                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                        Add a prefix
-                    </a>
+            {% with vlan.get_custom_fields as custom_fields %}
+                {% include 'inc/custom_fields_panel.html' %}
+            {% endwith %}
+        </div>
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Prefixes</strong>
                 </div>
                 </div>
-            {% endif %}
+                {% include 'responsive_table.html' with table=prefix_table %}
+                {% if perms.ipam.add_prefix %}
+                    <div class="panel-footer text-right">
+                        <a href="{% url 'ipam:prefix_add' %}?{% if vlan.tenant %}tenant={{ vlan.tenant.pk }}&{% endif %}site={{ vlan.site.pk }}&vlan={{ vlan.pk }}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                            Add a prefix
+                        </a>
+                    </div>
+                {% endif %}
+            </div>
         </div>
         </div>
-	</div>
-</div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 2 - 3
netbox/templates/ipam/vlan_members.html

@@ -1,9 +1,8 @@
-{% extends '_base.html' %}
+{% extends 'ipam/vlan.html' %}
 
 
-{% block title %}{{ vlan }} - Members{% endblock %}
+{% block title %}{{ block.super }} - Members{% endblock %}
 
 
 {% block content %}
 {% block content %}
-    {% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
             {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
             {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}

+ 46 - 35
netbox/templates/ipam/vrf.html

@@ -1,43 +1,54 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
-            <li>{{ vrf }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
+                <li>{{ vrf }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'ipam:vrf_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search VRFs" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'ipam:vrf_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search VRFs" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.ipam.change_vrf %}
+            <a href="{% url 'ipam:vrf_edit' pk=vrf.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this VRF
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_vrf %}
+            <a href="{% url 'ipam:vrf_delete' pk=vrf.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this VRF
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.change_vrf %}
-        <a href="{% url 'ipam:vrf_edit' pk=vrf.pk %}" class="btn btn-warning">
-            <span class="fa fa-pencil" aria-hidden="true"></span>
-            Edit this VRF
-        </a>
-    {% endif %}
-    {% if perms.ipam.delete_vrf %}
-        <a href="{% url 'ipam:vrf_delete' pk=vrf.pk %}" class="btn btn-danger">
-            <span class="fa fa-trash" aria-hidden="true"></span>
-            Delete this VRF
-        </a>
-    {% endif %}
-</div>
-<h1>{% block title %}VRF {{ vrf }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=vrf %}
+    <h1>{% block title %}VRF {{ vrf }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=vrf %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ aggregate.get_absolute_url }}">VRF</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 36 - 25
netbox/templates/secrets/secret.html

@@ -3,32 +3,43 @@
 {% load helpers %}
 {% load helpers %}
 {% load secret_helpers %}
 {% load secret_helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-12">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
-            <li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li>
-            <li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
+                <li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li>
+                <li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
+            </ol>
+        </div>
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.secrets.change_secret %}
-		<a href="{% url 'secrets:secret_edit' pk=secret.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this secret
-		</a>
-    {% endif %}
-    {% if perms.secrets.delete_secret %}
-		<a href="{% url 'secrets:secret_delete' pk=secret.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this secret
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ secret }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=secret %}
+    <div class="pull-right">
+        {% if perms.secrets.change_secret %}
+            <a href="{% url 'secrets:secret_edit' pk=secret.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this secret
+            </a>
+        {% endif %}
+        {% if perms.secrets.delete_secret %}
+            <a href="{% url 'secrets:secret_delete' pk=secret.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this secret
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ secret }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=secret %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ secret.get_absolute_url }}">Secret</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 49 - 38
netbox/templates/tenancy/tenant.html

@@ -1,46 +1,57 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
-            {% if tenant.group %}
-                <li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
-            {% endif %}
-            <li>{{ tenant }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
+                {% if tenant.group %}
+                    <li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
+                {% endif %}
+                <li>{{ tenant }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'tenancy:tenant_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Name" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'tenancy:tenant_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Name" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.tenancy.change_tenant %}
+            <a href="{% url 'tenancy:tenant_edit' slug=tenant.slug %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this tenant
+            </a>
+        {% endif %}
+        {% if perms.tenancy.delete_tenant %}
+            <a href="{% url 'tenancy:tenant_delete' slug=tenant.slug %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this tenant
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.tenancy.change_tenant %}
-		<a href="{% url 'tenancy:tenant_edit' slug=tenant.slug %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this tenant
-		</a>
-    {% endif %}
-    {% if perms.tenancy.delete_tenant %}
-		<a href="{% url 'tenancy:tenant_delete' slug=tenant.slug %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this tenant
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ tenant }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=tenant %}
+    <h1>{% block title %}{{ tenant }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=tenant %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ tenant.get_absolute_url }}">Tenant</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-7">
 	<div class="col-md-7">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 49 - 38
netbox/templates/virtualization/cluster.html

@@ -1,46 +1,57 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row" xmlns="http://www.w3.org/1999/html">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
-            {% if cluster.group %}
-                <li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
-            {% endif %}
-            <li>{{ cluster }}</li>
-        </ol>
+{% block header %}
+    <div class="row" xmlns="http://www.w3.org/1999/html">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
+                {% if cluster.group %}
+                    <li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
+                {% endif %}
+                <li>{{ cluster }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'virtualization:cluster_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search clusters" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'virtualization:cluster_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search clusters" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.virtualization.change_cluster %}
+            <a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this cluster
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_cluster %}
+            <a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this cluster
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.virtualization.change_cluster %}
-		<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this cluster
-		</a>
-    {% endif %}
-    {% if perms.dcim.delete_cluster %}
-		<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this cluster
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=cluster %}
+    <h1>{% block title %}{{ cluster }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=cluster %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ cluster.get_absolute_url }}">Cluster</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-5">
 	<div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 91 - 80
netbox/templates/virtualization/virtualmachine.html

@@ -1,45 +1,56 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            {% if vm.cluster %}
-                <li><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></li>
-            {% endif %}
-            <li>{{ vm }}</li>
-        </ol>
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                {% if virtualmachine.cluster %}
+                    <li><a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a></li>
+                {% endif %}
+                <li>{{ virtualmachine }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
     </div>
     </div>
-    <div class="col-sm-4 col-md-3">
-        <form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
-                <span class="input-group-btn">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search"></span>
-                    </button>
-                </span>
-            </div>
-        </form>
+    <div class="pull-right">
+        {% if perms.virtualization.change_virtualmachine %}
+            <a href="{% url 'virtualization:virtualmachine_edit' pk=virtualmachine.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil"></span>
+                Edit this VM
+            </a>
+        {% endif %}
+        {% if perms.virtualization.delete_virtualmachine %}
+            <a href="{% url 'virtualization:virtualmachine_delete' pk=virtualmachine.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash"></span>
+                Delete this VM
+            </a>
+        {% endif %}
     </div>
     </div>
-</div>
-<div class="pull-right">
-    {% if perms.virtualization.change_virtualmachine %}
-		<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil"></span>
-			Edit this VM
-		</a>
-    {% endif %}
-    {% if perms.virtualization.delete_virtualmachine %}
-		<a href="{% url 'virtualization:virtualmachine_delete' pk=vm.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash"></span>
-			Delete this VM
-		</a>
-    {% endif %}
-</div>
-<h1>{% block title %}{{ vm }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=vm %}
+    <h1>{% block title %}{{ virtualmachine }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=virtualmachine %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
+        </li>
+    </ul>
+{% endblock %}
+
+{% block content %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -49,19 +60,19 @@
             <table class="table table-hover panel-body attr-table">
             <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Name</td>
                     <td>Name</td>
-                    <td>{{ vm }}</td>
+                    <td>{{ virtualmachine }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Status</td>
                     <td>Status</td>
                     <td>
                     <td>
-                        <span class="label label-{{ vm.get_status_class }}">{{ vm.get_status_display }}</span>
+                        <span class="label label-{{ virtualmachine.get_status_class }}">{{ virtualmachine.get_status_display }}</span>
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Role</td>
                     <td>Role</td>
                     <td>
                     <td>
-                        {% if vm.role %}
-                            <a href="{% url 'virtualization:virtualmachine_list' %}?role={{ vm.role.slug }}">{{ vm.role }}</a>
+                        {% if virtualmachine.role %}
+                            <a href="{% url 'virtualization:virtualmachine_list' %}?role={{ virtualmachine.role.slug }}">{{ virtualmachine.role }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -70,8 +81,8 @@
                 <tr>
                 <tr>
                     <td>Platform</td>
                     <td>Platform</td>
                     <td>
                     <td>
-                        {% if vm.platform %}
-                            <a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ vm.platform.slug }}">{{ vm.platform }}</a>
+                        {% if virtualmachine.platform %}
+                            <a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ virtualmachine.platform.slug }}">{{ virtualmachine.platform }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -80,12 +91,12 @@
                 <tr>
                 <tr>
                     <td>Tenant</td>
                     <td>Tenant</td>
                     <td>
                     <td>
-                        {% if vm.tenant %}
-                            {% if vm.tenant.group %}
-                                <a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group }}</a>
+                        {% if virtualmachine.tenant %}
+                            {% if virtualmachine.tenant.group %}
+                                <a href="{{ virtualmachine.tenant.group.get_absolute_url }}">{{ virtualmachine.tenant.group }}</a>
                                 <i class="fa fa-angle-right"></i>
                                 <i class="fa fa-angle-right"></i>
                             {% endif %}
                             {% endif %}
-                            <a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a>
+                            <a href="{{ virtualmachine.tenant.get_absolute_url }}">{{ virtualmachine.tenant }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -94,12 +105,12 @@
                 <tr>
                 <tr>
                     <td>Primary IPv4</td>
                     <td>Primary IPv4</td>
                     <td>
                     <td>
-                        {% if vm.primary_ip4 %}
-                            <a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a>
-                            {% if vm.primary_ip4.nat_inside %}
-                                <span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span>
-                            {% elif vm.primary_ip4.nat_outside %}
-                                <span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span>
+                        {% if virtualmachine.primary_ip4 %}
+                            <a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip4.pk %}">{{ virtualmachine.primary_ip4.address.ip }}</a>
+                            {% if virtualmachine.primary_ip4.nat_inside %}
+                                <span>(NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }})</span>
+                            {% elif virtualmachine.primary_ip4.nat_outside %}
+                                <span>(NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }})</span>
                             {% endif %}
                             {% endif %}
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
@@ -109,12 +120,12 @@
                 <tr>
                 <tr>
                     <td>Primary IPv6</td>
                     <td>Primary IPv6</td>
                     <td>
                     <td>
-                        {% if vm.primary_ip6 %}
-                            <a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a>
-                            {% if vm.primary_ip6.nat_inside %}
-                                <span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span>
-                            {% elif vm.primary_ip6.nat_outside %}
-                                <span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span>
+                        {% if virtualmachine.primary_ip6 %}
+                            <a href="{% url 'ipam:ipaddress' pk=virtualmachine.primary_ip6.pk %}">{{ virtualmachine.primary_ip6.address.ip }}</a>
+                            {% if virtualmachine.primary_ip6.nat_inside %}
+                                <span>(NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }})</span>
+                            {% elif virtualmachine.primary_ip6.nat_outside %}
+                                <span>(NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }})</span>
                             {% endif %}
                             {% endif %}
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
@@ -124,7 +135,7 @@
                 <tr>
                 <tr>
                     <td>Tags</td>
                     <td>Tags</td>
                     <td>
                     <td>
-                        {% for tag in vm.tags.all %}
+                        {% for tag in virtualmachine.tags.all %}
                             {% tag 'virtualization:virtualmachine_list' tag %}
                             {% tag 'virtualization:virtualmachine_list' tag %}
                         {% empty %}
                         {% empty %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
@@ -133,14 +144,14 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
-        {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
+        {% include 'inc/custom_fields_panel.html' with custom_fields=virtualmachine.get_custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
             <div class="panel-body">
             <div class="panel-body">
-                {% if vm.comments %}
-                    {{ vm.comments|gfm }}
+                {% if virtualmachine.comments %}
+                    {{ virtualmachine.comments|gfm }}
                 {% else %}
                 {% else %}
                     <span class="text-muted">None</span>
                     <span class="text-muted">None</span>
                 {% endif %}
                 {% endif %}
@@ -156,16 +167,16 @@
                 <tr>
                 <tr>
                     <td>Cluster</td>
                     <td>Cluster</td>
                     <td>
                     <td>
-                        {% if vm.cluster.group %}
-                            <a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a>
+                        {% if virtualmachine.cluster.group %}
+                            <a href="{{ virtualmachine.cluster.group.get_absolute_url }}">{{ virtualmachine.cluster.group }}</a>
                             <i class="fa fa-angle-right"></i>
                             <i class="fa fa-angle-right"></i>
                         {% endif %}
                         {% endif %}
-                        <a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a>
+                        <a href="{{ virtualmachine.cluster.get_absolute_url }}">{{ virtualmachine.cluster }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Cluster Type</td>
                     <td>Cluster Type</td>
-                    <td>{{ vm.cluster.type }}</td>
+                    <td>{{ virtualmachine.cluster.type }}</td>
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
@@ -177,8 +188,8 @@
                 <tr>
                 <tr>
                     <td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
                     <td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
                     <td>
                     <td>
-                        {% if vm.vcpus %}
-                            {{ vm.vcpus }}
+                        {% if virtualmachine.vcpus %}
+                            {{ virtualmachine.vcpus }}
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
@@ -187,8 +198,8 @@
                 <tr>
                 <tr>
                     <td><i class="fa fa-microchip"></i> Memory</td>
                     <td><i class="fa fa-microchip"></i> Memory</td>
                     <td>
                     <td>
-                        {% if vm.memory %}
-                            {{ vm.memory }} MB
+                        {% if virtualmachine.memory %}
+                            {{ virtualmachine.memory }} MB
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
@@ -197,8 +208,8 @@
                 <tr>
                 <tr>
                     <td><i class="fa fa-hdd-o"></i> Disk Space</td>
                     <td><i class="fa fa-hdd-o"></i> Disk Space</td>
                     <td>
                     <td>
-                        {% if vm.disk %}
-                            {{ vm.disk }} GB
+                        {% if virtualmachine.disk %}
+                            {{ virtualmachine.disk }} GB
                         {% else %}
                         {% else %}
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
@@ -223,7 +234,7 @@
             {% endif %}
             {% endif %}
             {% if perms.ipam.add_service %}
             {% if perms.ipam.add_service %}
                 <div class="panel-footer text-right">
                 <div class="panel-footer text-right">
-                    <a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=vm.pk %}" class="btn btn-xs btn-primary">
+                    <a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=virtualmachine.pk %}" class="btn btn-xs btn-primary">
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
                     </a>
                     </a>
                 </div>
                 </div>
@@ -236,7 +247,7 @@
         {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
         {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
             <form method="post">
             <form method="post">
             {% csrf_token %}
             {% csrf_token %}
-            <input type="hidden" name="virtual_machine" value="{{ vm.pk }}" />
+            <input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
         {% endif %}
         {% endif %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
@@ -264,7 +275,7 @@
                 </thead>
                 </thead>
                 <tbody>
                 <tbody>
                     {% for iface in interfaces %}
                     {% for iface in interfaces %}
-                        {% include 'dcim/inc/interface.html' with device=vm %}
+                        {% include 'dcim/inc/interface.html' with device=virtualmachine %}
                     {% empty %}
                     {% empty %}
                         <tr>
                         <tr>
                             <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
                             <td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
@@ -275,21 +286,21 @@
             {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
             {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
                 <div class="panel-footer">
                 <div class="panel-footer">
                     {% if interfaces and perms.dcim.change_interface %}
                     {% if interfaces and perms.dcim.change_interface %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ vm.get_absolute_url }}" class="btn btn-warning btn-xs">
+                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                         </button>
                         </button>
-                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
+                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if interfaces and perms.dcim.delete_interface %}
                     {% if interfaces and perms.dcim.delete_interface %}
-                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=vm.pk %}" class="btn btn-danger btn-xs">
+                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}" class="btn btn-danger btn-xs">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if perms.dcim.add_interface %}
                     {% if perms.dcim.add_interface %}
                         <div class="pull-right">
                         <div class="pull-right">
-                            <a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
+                            <a href="{% url 'virtualization:interface_add' pk=virtualmachine.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                             </a>
                             </a>
                         </div>
                         </div>

+ 35 - 0
netbox/tenancy/migrations/0005_change_logging.py

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0004_tags'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='tenant',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='tenant',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 5 - 7
netbox/tenancy/models.py

@@ -7,11 +7,11 @@ from django.utils.encoding import python_2_unicode_compatible
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class TenantGroup(models.Model):
+class TenantGroup(ChangeLoggedModel):
     """
     """
     An arbitrary collection of Tenants.
     An arbitrary collection of Tenants.
     """
     """
@@ -23,9 +23,8 @@ class TenantGroup(models.Model):
         unique=True
         unique=True
     )
     )
 
 
-    csv_headers = ['name', 'slug']
-
     serializer = 'tenancy.api.serializers.TenantGroupSerializer'
     serializer = 'tenancy.api.serializers.TenantGroupSerializer'
+    csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -44,7 +43,7 @@ class TenantGroup(models.Model):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Tenant(CreatedUpdatedModel, CustomFieldModel):
+class Tenant(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
     department.
     department.
@@ -79,9 +78,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['name', 'slug', 'group', 'description', 'comments']
-
     serializer = 'tenancy.api.serializers.TenantSerializer'
     serializer = 'tenancy.api.serializers.TenantSerializer'
+    csv_headers = ['name', 'slug', 'group', 'description', 'comments']
 
 
     class Meta:
     class Meta:
         ordering = ['group', 'name']
         ordering = ['group', 'name']

+ 3 - 0
netbox/tenancy/tables.py

@@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
 TENANTGROUP_ACTIONS = """
 TENANTGROUP_ACTIONS = """
+<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.tenancy.change_tenantgroup %}
 {% if perms.tenancy.change_tenantgroup %}
     <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}

+ 4 - 0
netbox/tenancy/urls.py

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
+from .models import Tenant, TenantGroup
 
 
 app_name = 'tenancy'
 app_name = 'tenancy'
 urlpatterns = [
 urlpatterns = [
@@ -13,6 +15,7 @@ urlpatterns = [
     url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
     url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
     url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
     url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
     url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
+    url(r'^tenant-groups/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
 
 
     # Tenants
     # Tenants
     url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
     url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
@@ -23,5 +26,6 @@ urlpatterns = [
     url(r'^tenants/(?P<slug>[\w-]+)/$', views.TenantView.as_view(), name='tenant'),
     url(r'^tenants/(?P<slug>[\w-]+)/$', views.TenantView.as_view(), name='tenant'),
     url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
     url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
     url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
     url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
+    url(r'^tenants/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
 
 
 ]
 ]

+ 16 - 0
netbox/utilities/api.py

@@ -16,6 +16,8 @@ from rest_framework.response import Response
 from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
 from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
 from rest_framework.viewsets import GenericViewSet, ViewSet
 from rest_framework.viewsets import GenericViewSet, ViewSet
 
 
+from .utils import dynamic_import
+
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 
 
 
 
@@ -24,6 +26,20 @@ class ServiceUnavailable(APIException):
     default_detail = "Service temporarily unavailable, please try again later."
     default_detail = "Service temporarily unavailable, please try again later."
 
 
 
 
+def get_serializer_for_model(model, prefix=''):
+    """
+    Dynamically resolve and return the appropriate serializer for a model.
+    """
+    app_name, model_name = model._meta.label.split('.')
+    serializer_name = '{}.api.serializers.{}{}Serializer'.format(
+        app_name, prefix, model_name
+    )
+    try:
+        return dynamic_import(serializer_name)
+    except ImportError:
+        return None
+
+
 #
 #
 # Authentication
 # Authentication
 #
 #

+ 31 - 3
netbox/utilities/models.py

@@ -2,10 +2,38 @@ from __future__ import unicode_literals
 
 
 from django.db import models
 from django.db import models
 
 
+from extras.models import ObjectChange
+from utilities.utils import serialize_object
 
 
-class CreatedUpdatedModel(models.Model):
-    created = models.DateField(auto_now_add=True)
-    last_updated = models.DateTimeField(auto_now=True)
+
+class ChangeLoggedModel(models.Model):
+    """
+    An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
+    null to facilitate adding these fields to existing instances via a database migration.
+    """
+    created = models.DateField(
+        auto_now_add=True,
+        blank=True,
+        null=True
+    )
+    last_updated = models.DateTimeField(
+        auto_now=True,
+        blank=True,
+        null=True
+    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
+
+    def log_change(self, user, request_id, action):
+        """
+        Create a new ObjectChange representing a change made to this object. This will typically be called automatically
+        by extras.middleware.ChangeLoggingMiddleware.
+        """
+        ObjectChange(
+            user=user,
+            request_id=request_id,
+            changed_object=self,
+            action=action,
+            object_data=serialize_object(self)
+        ).save()

+ 14 - 0
netbox/utilities/utils.py

@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import datetime
 import datetime
+import json
 import six
 import six
 
 
+from django.core.serializers import serialize
 from django.http import HttpResponse
 from django.http import HttpResponse
 
 
 
 
@@ -82,3 +84,15 @@ def dynamic_import(name):
     for comp in components[1:]:
     for comp in components[1:]:
         mod = getattr(mod, comp)
         mod = getattr(mod, comp)
     return mod
     return mod
+
+
+def serialize_object(obj, extra=None):
+    """
+    Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
+    change logging, not the REST API.) Optionally include a dictionary to supplement the object data.
+    """
+    json_str = serialize('json', [obj])
+    data = json.loads(json_str)[0]['fields']
+    if extra is not None:
+        data.update(extra)
+    return data

+ 1 - 11
netbox/utilities/views.py

@@ -19,7 +19,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
-from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
+from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.webhooks import bulk_operation_signal
 from extras.webhooks import bulk_operation_signal
 from utilities.utils import queryset_to_csv
 from utilities.utils import queryset_to_csv
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.forms import BootstrapMixin, CSVDataField
@@ -213,11 +213,6 @@ class ObjectEditView(GetReturnURLMixin, View):
                 msg = '{} {}'.format(msg, escape(obj))
                 msg = '{} {}'.format(msg, escape(obj))
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
 
 
-            if obj_created:
-                UserAction.objects.log_create(request.user, obj, msg)
-            else:
-                UserAction.objects.log_edit(request.user, obj, msg)
-
             if '_addanother' in request.POST:
             if '_addanother' in request.POST:
                 return redirect(request.get_full_path())
                 return redirect(request.get_full_path())
 
 
@@ -279,7 +274,6 @@ class ObjectDeleteView(GetReturnURLMixin, View):
 
 
             msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
             msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
             messages.success(request, msg)
             messages.success(request, msg)
-            UserAction.objects.log_delete(request.user, obj, msg)
 
 
             return_url = form.cleaned_data.get('return_url')
             return_url = form.cleaned_data.get('return_url')
             if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
             if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
@@ -365,7 +359,6 @@ class BulkCreateView(View):
                     # If we make it to this point, validation has succeeded on all new objects.
                     # If we make it to this point, validation has succeeded on all new objects.
                     msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
                     msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
                     messages.success(request, msg)
                     messages.success(request, msg)
-                    UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
 
 
                     if '_addanother' in request.POST:
                     if '_addanother' in request.POST:
                         return redirect(request.path)
                         return redirect(request.path)
@@ -450,7 +443,6 @@ class BulkImportView(View):
                 if new_objs:
                 if new_objs:
                     msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
                     msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
                     messages.success(request, msg)
                     messages.success(request, msg)
-                    UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
 
 
                     return render(request, "import_success.html", {
                     return render(request, "import_success.html", {
                         'table': obj_table,
                         'table': obj_table,
@@ -566,7 +558,6 @@ class BulkEditView(View):
                     if updated_count:
                     if updated_count:
                         msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
                         msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
                         messages.success(self.request, msg)
                         messages.success(self.request, msg)
-                        UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
 
 
                     return redirect(return_url)
                     return redirect(return_url)
 
 
@@ -661,7 +652,6 @@ class BulkDeleteView(View):
 
 
                 msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
                 msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
                 messages.success(request, msg)
                 messages.success(request, msg)
-                UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
                 return redirect(return_url)
                 return redirect(return_url)
 
 
         else:
         else:

+ 55 - 0
netbox/virtualization/migrations/0007_change_logging.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-06-13 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0006_tags'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='clustergroup',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='clustergroup',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='clustertype',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='clustertype',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='cluster',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='cluster',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='virtualmachine',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='virtualmachine',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 9 - 11
netbox/virtualization/models.py

@@ -10,7 +10,7 @@ from taggit.managers import TaggableManager
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
-from utilities.models import CreatedUpdatedModel
+from utilities.models import ChangeLoggedModel
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
 
 
 
@@ -19,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ClusterType(models.Model):
+class ClusterType(ChangeLoggedModel):
     """
     """
     A type of Cluster.
     A type of Cluster.
     """
     """
@@ -31,6 +31,7 @@ class ClusterType(models.Model):
         unique=True
         unique=True
     )
     )
 
 
+    serializer = 'virtualization.api.serializers.ClusterTypeSerializer'
     csv_headers = ['name', 'slug']
     csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
@@ -54,7 +55,7 @@ class ClusterType(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ClusterGroup(models.Model):
+class ClusterGroup(ChangeLoggedModel):
     """
     """
     An organizational group of Clusters.
     An organizational group of Clusters.
     """
     """
@@ -66,9 +67,8 @@ class ClusterGroup(models.Model):
         unique=True
         unique=True
     )
     )
 
 
-    csv_headers = ['name', 'slug']
-
     serializer = 'virtualization.api.serializers.ClusterGroupSerializer'
     serializer = 'virtualization.api.serializers.ClusterGroupSerializer'
+    csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -91,7 +91,7 @@ class ClusterGroup(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Cluster(CreatedUpdatedModel, CustomFieldModel):
+class Cluster(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     """
     """
@@ -129,9 +129,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
-    csv_headers = ['name', 'type', 'group', 'site', 'comments']
-
     serializer = 'virtualization.api.serializers.ClusterSerializer'
     serializer = 'virtualization.api.serializers.ClusterSerializer'
+    csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -169,7 +168,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
+class VirtualMachine(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A virtual machine which runs inside a Cluster.
     A virtual machine which runs inside a Cluster.
     """
     """
@@ -251,12 +250,11 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
 
 
     tags = TaggableManager()
     tags = TaggableManager()
 
 
+    serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
     csv_headers = [
     csv_headers = [
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     ]
     ]
 
 
-    serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
-
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 

+ 6 - 0
netbox/virtualization/tables.py

@@ -9,12 +9,18 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 CLUSTERTYPE_ACTIONS = """
 CLUSTERTYPE_ACTIONS = """
+<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.virtualization.change_clustertype %}
 {% if perms.virtualization.change_clustertype %}
     <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
 """
 """
 
 
 CLUSTERGROUP_ACTIONS = """
 CLUSTERGROUP_ACTIONS = """
+<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.virtualization.change_clustergroup %}
 {% if perms.virtualization.change_clustergroup %}
     <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}

+ 6 - 0
netbox/virtualization/urls.py

@@ -2,8 +2,10 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from extras.views import ObjectChangeLogView
 from ipam.views import ServiceCreateView
 from ipam.views import ServiceCreateView
 from . import views
 from . import views
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 app_name = 'virtualization'
 app_name = 'virtualization'
 urlpatterns = [
 urlpatterns = [
@@ -14,6 +16,7 @@ urlpatterns = [
     url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
     url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
     url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
     url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
     url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
     url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
+    url(r'^cluster-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
 
 
     # Cluster groups
     # Cluster groups
     url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
     url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
@@ -21,6 +24,7 @@ urlpatterns = [
     url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
     url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
     url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
     url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
     url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
     url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
+    url(r'^cluster-groups/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
 
 
     # Clusters
     # Clusters
     url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
     url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
@@ -31,6 +35,7 @@ urlpatterns = [
     url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
     url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
     url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
     url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
     url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
     url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
+    url(r'^clusters/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
     url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
     url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
     url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
     url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
 
 
@@ -43,6 +48,7 @@ urlpatterns = [
     url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
     url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
     url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
     url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
     url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
     url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
+    url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
     url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
     url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
 
 
     # VM interfaces
     # VM interfaces

+ 4 - 4
netbox/virtualization/views.py

@@ -258,12 +258,12 @@ class VirtualMachineView(View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
-        interfaces = Interface.objects.filter(virtual_machine=vm)
-        services = Service.objects.filter(virtual_machine=vm)
+        virtualmachine = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
+        interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
+        services = Service.objects.filter(virtual_machine=virtualmachine)
 
 
         return render(request, 'virtualization/virtualmachine.html', {
         return render(request, 'virtualization/virtualmachine.html', {
-            'vm': vm,
+            'virtualmachine': virtualmachine,
             'interfaces': interfaces,
             'interfaces': interfaces,
             'services': services,
             'services': services,
         })
         })