Jeremy Stretch 7 лет назад
Родитель
Сommit
ddd878683d

+ 4 - 4
netbox/circuits/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
 from .models import Circuit, CircuitType, Provider
 from .models import Circuit, CircuitType, Provider
 
 
@@ -18,7 +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/$', ChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
+    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'),
@@ -26,7 +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/$', ChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+    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'),
@@ -37,7 +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/$', ChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
+    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

+ 13 - 13
netbox/dcim/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView, 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
@@ -20,7 +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/$', ChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
+    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'),
@@ -30,7 +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/$', ChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
+    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
@@ -39,7 +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/$', ChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
+    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'),
@@ -47,7 +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/$', ChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
+    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'),
@@ -55,7 +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/$', ChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
+    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'),
@@ -67,7 +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/$', ChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
+    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}),
 
 
@@ -77,7 +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/$', ChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
+    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'),
@@ -88,7 +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/$', ChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
+    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'),
@@ -121,7 +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/$', ChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
+    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'),
@@ -129,7 +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/$', ChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
+    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'),
@@ -141,7 +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/$', ChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
+    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'),
@@ -235,7 +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/$', ChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
+    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'),
 
 

+ 1 - 0
netbox/extras/filters.py

@@ -129,6 +129,7 @@ class ObjectChangeFilter(django_filters.FilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    time = django_filters.DateTimeFromToRangeFilter()
 
 
     class Meta:
     class Meta:
         model = ObjectChange
         model = ObjectChange

+ 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
+    )

+ 22 - 3
netbox/extras/models.py

@@ -9,6 +9,7 @@ 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.core.urlresolvers 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
@@ -704,15 +705,18 @@ class ObjectChange(models.Model):
         editable=False
         editable=False
     )
     )
 
 
+    serializer = 'extras.api.serializers.ObjectChangeSerializer'
+    csv_headers = ['time', 'user', 'request_id', 'action', 'content_type', 'object_id', 'object_repr', 'object_data']
+
     class Meta:
     class Meta:
         ordering = ['-time']
         ordering = ['-time']
 
 
     def __str__(self):
     def __str__(self):
-        attribution = 'by {}'.format(self.user_name) if self.user_name else '(no attribution)'
-        return '{} {} {}'.format(
+        return '{} {} {} by {}'.format(
+            self.content_type,
             self.object_repr,
             self.object_repr,
             self.get_action_display().lower(),
             self.get_action_display().lower(),
-            attribution
+            self.user_name
         )
         )
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -724,6 +728,21 @@ class ObjectChange(models.Model):
 
 
         return super(ObjectChange, self).save(*args, **kwargs)
         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 or self.user_name,
+            self.request_id,
+            self.get_action_display(),
+            self.content_type,
+            self.object_id,
+            self.object_repr,
+            self.object_data,
+        )
+
     @property
     @property
     def object_data_pretty(self):
     def object_data_pretty(self):
         return json.dumps(self.object_data, indent=4, sort_keys=True)
         return json.dumps(self.object_data, indent=4, sort_keys=True)

+ 37 - 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,21 @@ 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
+    )
+    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', 'content_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'),
+
 ]
 ]

+ 44 - 7
netbox/extras/views.py

@@ -13,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 . import filters
+from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm
 from .models import ImageAttachment, ObjectChange, ReportResult, UserAction
 from .models import ImageAttachment, ObjectChange, ReportResult, UserAction
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
-from .tables import TagTable
+from .tables import ObjectChangeTable, TagTable
 
 
 
 
 #
 #
@@ -56,9 +57,36 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Change logging
 # Change logging
 #
 #
 
 
-class ChangeLogView(View):
+class ObjectChangeListView(ObjectListView):
+    queryset = ObjectChange.objects.select_related('user', 'content_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 an object.
+    Present a history of changes made to a particular object.
     """
     """
 
 
     def get(self, request, model, **kwargs):
     def get(self, request, model, **kwargs):
@@ -68,7 +96,16 @@ class ChangeLogView(View):
 
 
         # Gather all changes for this object
         # Gather all changes for this object
         content_type = ContentType.objects.get_for_model(model)
         content_type = ContentType.objects.get_for_model(model)
-        changes = ObjectChange.objects.filter(content_type=content_type, object_id=obj.pk)
+        objectchanges = ObjectChange.objects.select_related(
+            'user', 'content_type'
+        ).filter(
+            content_type=content_type,
+            object_id=obj.pk
+        )
+        objectchanges_table = ObjectChangeTable(
+            data=objectchanges,
+            orderable=False
+        )
 
 
         # Check whether a header template exists for this model
         # Check whether a header template exists for this model
         base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
         base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
@@ -79,9 +116,9 @@ class ChangeLogView(View):
             base_template = '_base.html'
             base_template = '_base.html'
             object_var = 'obj'
             object_var = 'obj'
 
 
-        return render(request, 'extras/changelog.html', {
+        return render(request, 'extras/object_changelog.html', {
             object_var: obj,
             object_var: obj,
-            'changes': changes,
+            'objectchanges_table': objectchanges_table,
             'base_template': base_template,
             'base_template': base_template,
             'active_tab': 'changelog',
             'active_tab': 'changelog',
         })
         })

+ 10 - 10
netbox/ipam/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
@@ -19,7 +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/$', ChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
+    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'),
@@ -27,7 +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/$', ChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
+    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'),
@@ -38,7 +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/$', ChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
+    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'),
@@ -46,7 +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/$', ChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
+    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'),
@@ -57,7 +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/$', ChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
+    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'),
 
 
@@ -68,7 +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/$', ChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
+    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'),
@@ -80,7 +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/$', ChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
+    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'),
@@ -92,11 +92,11 @@ 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/$', ChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
+    url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
 
 
     # Services
     # Services
     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/$', ChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
+    url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
 
 
 ]
 ]

+ 3 - 3
netbox/secrets/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
@@ -15,7 +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/$', ChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
+    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'),
@@ -25,6 +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/$', ChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
+    url(r'^secrets/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
 
 
 ]
 ]

+ 0 - 36
netbox/templates/extras/changelog.html

@@ -1,36 +0,0 @@
-{% extends base_template %}
-
-{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
-
-{% block content %}
-    {% if obj %}<h1>{{ obj }}</h1>{% endif %}
-    <table class="table">
-        <thead>
-            <tr>
-                <th>Time</th>
-                <th>User</th>
-                <th>Action</th>
-                <th></th>
-            </tr>
-        </thead>
-        <tbody>
-            {% for change in changes %}
-                <tr>
-                    <td>{{ change.time }}</td>
-                    <td>{{ change.user }}</td>
-                    <td>{{ change.get_action_display }}</td>
-                    <td>
-                        <button class="btn btn-xs btn-primary" type="button" data-toggle="collapse" data-target="#change{{ change.pk }}" aria-expanded="false" aria-controls="collapseExample">
-                            <i class="fa fa-search"></i>
-                        </button>
-                    </td>
-                </tr>
-                <tr class="collapse" id="change{{ change.pk }}">
-                    <td colspan="4">
-                        <pre class="well">{{ change.object_data_pretty }}</pre>
-                    </td>
-                </tr>
-            {% endfor %}
-        </tbody>
-    </table>
-{% endblock %}

+ 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>Content Type</td>
+                        <td>
+                            {{ objectchange.content_type }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Object</td>
+                        <td>
+                            {% if objectchange.changed_object.get_absolute_url %}
+                                <a href="{{ objectchange.changed_object.get_aboslute_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 %}">

+ 3 - 3
netbox/tenancy/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView
+from extras.views import ObjectChangeLogView
 from . import views
 from . import views
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
@@ -15,7 +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/$', ChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
+    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'),
@@ -26,6 +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/$', ChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
+    url(r'^tenants/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
 
 
 ]
 ]

+ 5 - 5
netbox/virtualization/urls.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 from django.conf.urls import url
 from django.conf.urls import url
 
 
-from extras.views import ChangeLogView
+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
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -16,7 +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/$', ChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
+    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'),
@@ -24,7 +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/$', ChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
+    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'),
@@ -35,7 +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/$', ChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
+    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'),
 
 
@@ -48,7 +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/$', ChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
+    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