diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 600927394..ce31cfd46 100644 --- a/docs/configuration/optional-settings.md +++ b/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 Default: False diff --git a/netbox/circuits/migrations/0012_change_logging.py b/netbox/circuits/migrations/0012_change_logging.py new file mode 100644 index 000000000..db5057858 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 0a36ba366..10ca8d7db 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,12 +9,12 @@ from taggit.managers import TaggableManager from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField 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 @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 stores information pertinent to the user's relationship with the Provider. @@ -59,9 +59,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() - csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] - serializer = 'circuits.api.serializers.ProviderSerializer' + csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] class Meta: ordering = ['name'] @@ -86,7 +85,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): @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 "Long Haul," "Metro," or "Out-of-Band". @@ -99,6 +98,7 @@ class CircuitType(models.Model): unique=True ) + serializer = 'circuits.api.serializers.CircuitTypeSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -118,7 +118,7 @@ class CircuitType(models.Model): @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 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() + serializer = 'circuits.api.serializers.CircuitSerializer' csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', ] - serializer = 'circuits.api.serializers.CircuitSerializer' - class Meta: ordering = ['provider', 'cid'] unique_together = ['provider', 'cid'] diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 46dac3c31..6bf3114d9 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider CIRCUITTYPE_ACTIONS = """ + + + {% if perms.circuit.change_circuittype %} {% endif %} diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 569c1eb9a..449da3964 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Circuit, CircuitType, Provider app_name = 'circuits' urlpatterns = [ @@ -16,6 +18,7 @@ urlpatterns = [ url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), + url(r'^providers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types 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/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + url(r'^circuit-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), @@ -33,6 +37,7 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), + url(r'^circuits/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py b/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py deleted file mode 100644 index 6a16656ed..000000000 --- a/netbox/dcim/migrations/0059_devicetype_add_created_updated_times.py +++ /dev/null @@ -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), - ), - ] diff --git a/netbox/dcim/migrations/0060_site_latitude_longitude.py b/netbox/dcim/migrations/0059_site_latitude_longitude.py similarity index 91% rename from netbox/dcim/migrations/0060_site_latitude_longitude.py rename to netbox/dcim/migrations/0059_site_latitude_longitude.py index 750a0f10b..15e666f35 100644 --- a/netbox/dcim/migrations/0060_site_latitude_longitude.py +++ b/netbox/dcim/migrations/0059_site_latitude_longitude.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0059_devicetype_add_created_updated_times'), + ('dcim', '0058_relax_rack_naming_constraints'), ] operations = [ diff --git a/netbox/dcim/migrations/0060_change_logging.py b/netbox/dcim/migrations/0060_change_logging.py new file mode 100644 index 000000000..8a40f4e4e --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 9a0587485..ecb83ecaa 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -18,22 +18,48 @@ from taggit.managers import TaggableManager from timezone_field import TimeZoneField 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 utilities.fields import ColorField, NullableCharField 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 .fields import ASNField, MACAddressField 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 # @python_2_unicode_compatible -class Region(MPTTModel): +class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. """ @@ -53,6 +79,7 @@ class Region(MPTTModel): unique=True ) + serializer = 'dcim.api.serializers.RegionSerializer' csv_headers = ['name', 'slug', 'parent'] class MPTTMeta: @@ -81,7 +108,7 @@ class SiteManager(NaturalOrderByManager): @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 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() tags = TaggableManager() + serializer = 'dcim.api.serializers.SiteSerializer' csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] - serializer = 'dcim.api.serializers.SiteSerializer' - class Meta: ordering = ['name'] @@ -245,7 +271,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): # @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 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' ) - csv_headers = ['site', 'name', 'slug'] - serializer = 'dcim.api.serializers.RackGroupSerializer' + csv_headers = ['site', 'name', 'slug'] class Meta: ordering = ['site', 'name'] @@ -287,7 +312,7 @@ class RackGroup(models.Model): @python_2_unicode_compatible -class RackRole(models.Model): +class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -300,6 +325,7 @@ class RackRole(models.Model): ) color = ColorField() + serializer = 'dcim.api.serializers.RackRoleSerializer' csv_headers = ['name', 'slug', 'color'] class Meta: @@ -324,7 +350,7 @@ class RackManager(NaturalOrderByManager): @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. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -406,13 +432,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): objects = RackManager() tags = TaggableManager() + serializer = 'dcim.api.serializers.RackSerializer' csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'desc_units', 'comments', ] - serializer = 'dcim.api.serializers.RackSerializer' - class Meta: ordering = ['site', 'group', 'name'] unique_together = [ @@ -584,7 +609,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class RackReservation(models.Model): +class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. """ @@ -596,9 +621,6 @@ class RackReservation(models.Model): units = ArrayField( base_field=models.PositiveSmallIntegerField() ) - created = models.DateTimeField( - auto_now_add=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -614,6 +636,8 @@ class RackReservation(models.Model): max_length=100 ) + serializer = 'dcim.api.serializers.RackReservationSerializer' + class Meta: ordering = ['created'] @@ -661,7 +685,7 @@ class RackReservation(models.Model): # @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. """ @@ -673,6 +697,7 @@ class Manufacturer(models.Model): unique=True ) + serializer = 'dcim.api.serializers.ManufacturerSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -692,7 +717,7 @@ class Manufacturer(models.Model): @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 well as high-level functional role(s). @@ -767,6 +792,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel): tags = TaggableManager() + serializer = 'dcim.api.serializers.DeviceTypeSerializer' csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', @@ -866,7 +892,7 @@ class DeviceType(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class ConsolePortTemplate(models.Model): +class ConsolePortTemplate(ComponentModel): """ A template for a ConsolePort to be created for a new Device. """ @@ -886,9 +912,12 @@ class ConsolePortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class ConsoleServerPortTemplate(models.Model): +class ConsoleServerPortTemplate(ComponentModel): """ A template for a ConsoleServerPort to be created for a new Device. """ @@ -908,9 +937,12 @@ class ConsoleServerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerPortTemplate(models.Model): +class PowerPortTemplate(ComponentModel): """ A template for a PowerPort to be created for a new Device. """ @@ -930,9 +962,12 @@ class PowerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerOutletTemplate(models.Model): +class PowerOutletTemplate(ComponentModel): """ A template for a PowerOutlet to be created for a new Device. """ @@ -952,9 +987,12 @@ class PowerOutletTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class InterfaceTemplate(models.Model): +class InterfaceTemplate(ComponentModel): """ A template for a physical data interface on a new Device. """ @@ -984,9 +1022,12 @@ class InterfaceTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class DeviceBayTemplate(models.Model): +class DeviceBayTemplate(ComponentModel): """ A template for a DeviceBay to be created for a new parent Device. """ @@ -1006,13 +1047,16 @@ class DeviceBayTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + # # Devices # @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 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' ) + serializer = 'dcim.api.serializers.DeviceRoleSerializer' csv_headers = ['name', 'slug', 'color', 'vm_role'] class Meta: @@ -1053,7 +1098,7 @@ class DeviceRole(models.Model): @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". 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' ) + serializer = 'dcim.api.serializers.PlatformSerializer' csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] class Meta: @@ -1112,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager): @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, 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() tags = TaggableManager() + serializer = 'dcim.api.serializers.DeviceSerializer' csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] - serializer = 'dcim.api.serializers.DeviceSerializer' - class Meta: ordering = ['name'] unique_together = [ @@ -1501,7 +1546,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class ConsolePort(models.Model): +class ConsolePort(ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -1538,6 +1583,9 @@ class ConsolePort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.cs_port.device.identifier if self.cs_port else None, @@ -1563,7 +1611,7 @@ class ConsoleServerPortManager(models.Manager): @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. """ @@ -1587,6 +1635,9 @@ class ConsoleServerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1604,7 +1655,7 @@ class ConsoleServerPort(models.Model): # @python_2_unicode_compatible -class PowerPort(models.Model): +class PowerPort(ComponentModel): """ 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): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.power_outlet.device.identifier if self.power_outlet else None, @@ -1665,7 +1719,7 @@ class PowerOutletManager(models.Manager): @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. """ @@ -1689,6 +1743,9 @@ class PowerOutlet(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1706,7 +1763,7 @@ class PowerOutlet(models.Model): # @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 Interface via the creation of an InterfaceConnection. @@ -1796,6 +1853,9 @@ class Interface(models.Model): def get_absolute_url(self): return self.parent.get_absolute_url() + def get_component_parent(self): + return self.device or self.virtual_machine + def clean(self): # 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) + 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 def parent(self): return self.device or self.virtual_machine @@ -1970,13 +2047,40 @@ class InterfaceConnection(models.Model): 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 # @python_2_unicode_compatible -class DeviceBay(models.Model): +class DeviceBay(ComponentModel): """ 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): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Validate that the parent Device can have DeviceBays @@ -2025,7 +2132,7 @@ class DeviceBay(models.Model): # @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. InventoryItems are used only for inventory purposes. @@ -2094,6 +2201,9 @@ class InventoryItem(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.device.name or '{' + self.device.pk + '}', @@ -2112,7 +2222,7 @@ class InventoryItem(models.Model): # @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). """ @@ -2126,6 +2236,8 @@ class VirtualChassis(models.Model): blank=True ) + serializer = 'dcim.api.serializers.VirtualChassisSerializer' + class Meta: ordering = ['master'] verbose_name_plural = 'virtual chassis' diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 159c70db5..b78c9ce55 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -41,12 +41,18 @@ DEVICE_LINK = """ """ REGION_ACTIONS = """ + + + {% if perms.dcim.change_region %} {% endif %} """ RACKGROUP_ACTIONS = """ + + + @@ -58,6 +64,9 @@ RACKGROUP_ACTIONS = """ """ RACKROLE_ACTIONS = """ + + + {% if perms.dcim.change_rackrole %} {% endif %} @@ -76,20 +85,29 @@ RACK_DEVICE_COUNT = """ """ RACKRESERVATION_ACTIONS = """ + + + {% if perms.dcim.change_rackreservation %} {% endif %} """ -DEVICEROLE_ACTIONS = """ -{% if perms.dcim.change_devicerole %} - +MANUFACTURER_ACTIONS = """ + + + +{% if perms.dcim.change_manufacturer %} + {% endif %} """ -MANUFACTURER_ACTIONS = """ -{% if perms.dcim.change_manufacturer %} - +DEVICEROLE_ACTIONS = """ + + + +{% if perms.dcim.change_devicerole %} + {% endif %} """ @@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """ """ PLATFORM_ACTIONS = """ + + + {% if perms.dcim.change_platform %} {% endif %} @@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """ """ VIRTUALCHASSIS_ACTIONS = """ + + + {% if perms.dcim.change_virtualchassis %} {% endif %} diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 5682bd8e7..de1cbd4cc 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,11 +2,14 @@ from __future__ import unicode_literals from django.conf.urls import url -from extras.views import ImageAttachmentEditView +from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceCreateView from secrets.views import secret_add 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' urlpatterns = [ @@ -17,6 +20,7 @@ urlpatterns = [ 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/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), + url(r'^regions/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), @@ -26,6 +30,7 @@ urlpatterns = [ url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups @@ -34,6 +39,7 @@ urlpatterns = [ 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/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + url(r'^rack-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles 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/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), + url(r'^rack-roles/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations 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/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + url(r'^rack-reservations/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), @@ -59,6 +67,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), + url(r'^racks/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), url(r'^racks/(?P\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/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + url(r'^manufacturers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -78,6 +88,7 @@ urlpatterns = [ url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + url(r'^device-types/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates url(r'^device-types/(?P\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/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + url(r'^device-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms 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/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), + url(r'^platforms/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), @@ -128,6 +141,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), + url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P\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/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + url(r'^virtual-chassis/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6e7aa070c..0e783d39c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,7 +18,7 @@ from django.views.generic import View from natsort import natsorted 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 utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator @@ -945,6 +945,7 @@ class DeviceInventoryView(View): return render(request, 'dcim/device_inventory.html', { 'device': device, 'inventory_items': inventory_items, + 'active_tab': 'inventory', }) @@ -957,6 +958,7 @@ class DeviceStatusView(PermissionRequiredMixin, View): return render(request, 'dcim/device_status.html', { 'device': device, + 'active_tab': 'status', }) @@ -975,6 +977,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): return render(request, 'dcim/device_lldp_neighbors.html', { 'device': device, 'interfaces': interfaces, + 'active_tab': 'lldp-neighbors', }) @@ -987,6 +990,7 @@ class DeviceConfigView(PermissionRequiredMixin, View): return render(request, 'dcim/device_config.html', { 'device': device, + 'active_tab': 'config', }) @@ -1104,7 +1108,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View): escape(consoleport.cs_port.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) @@ -1155,7 +1158,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View): escape(cs_port.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) @@ -1244,7 +1246,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View): escape(consoleserverport.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) @@ -1296,7 +1297,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View): escape(consoleserverport.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) @@ -1390,7 +1390,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View): escape(powerport.power_outlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) @@ -1441,7 +1440,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View): escape(power_outlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) @@ -1529,7 +1527,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View): escape(poweroutlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) @@ -1580,7 +1577,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View): escape(poweroutlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) @@ -1910,7 +1906,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie escape(interfaceconnection.interface_b.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, interfaceconnection, msg) if '_addanother' in request.POST: base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) @@ -1961,7 +1956,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, escape(interfaceconnection.interface_b.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, interfaceconnection, msg) return redirect(self.get_return_url(request, interfaceconnection)) @@ -2241,7 +2235,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi membership_form.save() msg = 'Added member {}'.format(device.get_absolute_url(), escape(device)) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, device, msg) if '_addanother' in request.POST: return redirect(request.get_full_path()) @@ -2296,7 +2289,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) messages.success(request, msg) - UserAction.objects.log_edit(request.user, device, msg) return redirect(self.get_return_url(request, device)) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e96ae9ac8..7d30cff34 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -5,9 +5,9 @@ from django.contrib import admin from django.utils.safestring import mark_safe from utilities.forms import LaxURLField +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE 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('{}'.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('{}'.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 # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6a3c6256f..10afee954 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -6,11 +6,12 @@ from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer 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 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() +# +# 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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 4e1f9d2ef..3b4e59ef2 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -37,6 +37,9 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet) # Reports router.register(r'reports', views.ReportViewSet, base_name='report') +# Change logging +router.register(r'object-changes', views.ObjectChangeViewSet) + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37d07060b..d65a099ad 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -11,7 +11,9 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from taggit.models import Tag 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 utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from . import serializers @@ -206,6 +208,19 @@ class ReportViewSet(ViewSet): 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 # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 39daa572b..6807af9d9 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -66,6 +66,16 @@ TOPOLOGYMAP_TYPE_CHOICES = ( (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 ACTION_CREATE = 1 ACTION_IMPORT = 2 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index bb1202e28..71c9314cd 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -8,7 +8,7 @@ from taggit.models import Tag from dcim.models import Site 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): @@ -124,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet): 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): username = django_filters.ModelMultipleChoiceFilter( name='user__username', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 9088d1b3d..a39814eb6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,12 +3,16 @@ from __future__ import unicode_literals from collections import OrderedDict from django import forms +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType 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: model = ImageAttachment 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 + ) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py new file mode 100644 index 000000000..a7dd3b44e --- /dev/null +++ b/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) diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py new file mode 100644 index 000000000..de4762a46 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 865ff9fbb..4b41a523a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -2,12 +2,14 @@ from __future__ import unicode_literals from collections import OrderedDict from datetime import date +import json import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField +from django.urls import reverse from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -656,6 +658,119 @@ class ReportResult(models.Model): 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 # diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 921b9f273..bd190c7e5 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -4,6 +4,7 @@ import django_tables2 as tables from taggit.models import Tag from utilities.tables import BaseTable, ToggleColumn +from .models import ObjectChange TAG_ACTIONS = """ {% if perms.taggit.change_tag %} @@ -14,6 +15,24 @@ TAG_ACTIONS = """ {% endif %} """ +OBJECTCHANGE_ACTION = """ +{% if record.action == 1 %} + Created +{% elif record.action == 2 %} + Updated +{% elif record.action == 3 %} + Deleted +{% endif %} +""" + +OBJECTCHANGE_OBJECT = """ +{% if record.action != 3 and record.changed_object.get_absolute_url %} + {{ record.object_repr }} +{% else %} + {{ record.object_repr }} +{% endif %} +""" + class TagTable(BaseTable): pk = ToggleColumn() @@ -26,3 +45,24 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag 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') diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index d3c200334..d92303264 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -22,4 +22,8 @@ urlpatterns = [ url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + # Change logging + url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'), + url(r'^changelog/(?P\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 130437356..46cddabf4 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,8 +1,10 @@ from __future__ import unicode_literals +from django import template from django.contrib import messages 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.shortcuts import get_object_or_404, redirect, render, reverse from django.utils.safestring import mark_safe @@ -11,10 +13,11 @@ from taggit.models import Tag from utilities.forms import ConfirmationForm 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 .tables import TagTable +from .tables import ObjectChangeTable, TagTable # @@ -50,6 +53,77 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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 # @@ -149,6 +223,5 @@ class ReportRunView(PermissionRequiredMixin, View): result = 'failed' if report.failed else 'passed' msg = "Ran report {} ({})".format(report.full_name, result) messages.success(request, mark_safe(msg)) - UserAction.objects.log_create(request.user, report.result, msg) return redirect('extras:report', name=report.full_name) diff --git a/netbox/ipam/migrations/0023_change_logging.py b/netbox/ipam/migrations/0023_change_logging.py new file mode 100644 index 000000000..d548fdf15 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 70713cd88..e1bd93f97 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,14 +14,14 @@ from taggit.managers import TaggableManager from dcim.models import Interface from extras.models import CustomFieldModel -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @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 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() - csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - serializer = 'ipam.api.serializers.VRFSerializer' + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class Meta: ordering = ['name', 'rd'] @@ -91,7 +90,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): @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 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' ) + serializer = 'ipam.api.serializers.RIRSerializer' csv_headers = ['name', 'slug', 'is_private'] class Meta: @@ -131,7 +131,7 @@ class RIR(models.Model): @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 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() - csv_headers = ['prefix', 'rir', 'date_added', 'description'] - serializer = 'ipam.api.serializers.AggregateSerializer' + csv_headers = ['prefix', 'rir', 'date_added', 'description'] class Meta: ordering = ['family', 'prefix'] @@ -228,7 +227,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): @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 "Management." @@ -244,6 +243,7 @@ class Role(models.Model): default=1000 ) + serializer = 'ipam.api.serializers.RoleSerializer' csv_headers = ['name', 'slug', 'weight'] class Meta: @@ -261,7 +261,7 @@ class Role(models.Model): @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 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() tags = TaggableManager() + serializer = 'ipam.api.serializers.PrefixSerializer' csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] - serializer = 'ipam.api.serializers.PrefixSerializer' - class Meta: ordering = ['vrf', 'family', 'prefix'] verbose_name_plural = 'prefixes' @@ -503,7 +502,7 @@ class IPAddressManager(models.Manager): @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 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() tags = TaggableManager() + serializer = 'ipam.api.serializers.IPAddressSerializer' csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'description', ] - serializer = 'ipam.api.serializers.IPAddressSerializer' - class Meta: ordering = ['family', 'address'] verbose_name = 'IP address' @@ -663,7 +661,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): @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. """ @@ -679,9 +677,8 @@ class VLANGroup(models.Model): null=True ) - csv_headers = ['name', 'slug', 'site'] - serializer = 'ipam.api.serializers.VLANGroupSerializer' + csv_headers = ['name', 'slug', 'site'] class Meta: ordering = ['site', 'name'] @@ -717,7 +714,7 @@ class VLANGroup(models.Model): @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 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() - csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] - serializer = 'ipam.api.serializers.VLANSerializer' + csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] class Meta: ordering = ['site', 'group', 'vid'] @@ -835,7 +831,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @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 optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 7f8b8918d..2cb1c6606 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -28,6 +28,9 @@ RIR_UTILIZATION = """ """ RIR_ACTIONS = """ + + + {% if perms.ipam.change_rir %} {% endif %} @@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """ """ ROLE_ACTIONS = """ + + + {% if perms.ipam.change_role %} {% endif %} @@ -127,6 +133,9 @@ VLAN_ROLE_LINK = """ """ VLANGROUP_ACTIONS = """ + + + {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index d22c32561..5b5f7df33 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF app_name = 'ipam' @@ -17,6 +19,7 @@ urlpatterns = [ url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), + url(r'^vrfs/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs 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/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), + url(r'^vrfs/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), @@ -34,6 +38,7 @@ urlpatterns = [ url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + url(r'^aggregates/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles 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/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), + url(r'^roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), @@ -51,6 +57,7 @@ urlpatterns = [ url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), + url(r'^prefixes/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), url(r'^prefixes/(?P\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), url(r'^prefixes/(?P\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/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/(?P\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/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P\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/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + url(r'^vlan-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), @@ -83,6 +92,7 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), + url(r'^vlans/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services url(r'^services/$', views.ServiceListView.as_view(), name='service_list'), @@ -91,5 +101,6 @@ urlpatterns = [ url(r'^services/(?P\d+)/$', views.ServiceView.as_view(), name='service'), url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), + url(r'^services/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e60d1bd79..3c88da5fd 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -522,6 +522,7 @@ class PrefixPrefixesView(View): 'prefix_table': prefix_table, 'permissions': permissions, '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, 'permissions': permissions, '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_table = tables.VLANMemberTable(members) - # if request.user.has_perm('dcim.change_interface'): - # members_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, @@ -868,18 +868,10 @@ class VLANMembersView(View): } 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', { 'vlan': vlan, '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', }) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 27a615c32..23d6ba221 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -50,6 +50,9 @@ BANNER_LOGIN = '' # BASE_PATH = 'netbox/' 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 # 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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7acb611f3..f526ebf19 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: 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_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) @@ -174,6 +175,7 @@ MIDDLEWARE = ( 'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', + 'extras.middleware.ChangeLoggingMiddleware', ) ROOT_URLCONF = 'netbox.urls' diff --git a/netbox/secrets/migrations/0005_change_logging.py b/netbox/secrets/migrations/0005_change_logging.py new file mode 100644 index 000000000..947087934 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index dcb38db70..3c253c104 100644 --- a/netbox/secrets/models.py +++ b/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 taggit.managers import TaggableManager -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -48,12 +48,18 @@ def decrypt_master_key(master_key_cipher, private_key): @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 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. """ + created = models.DateField( + auto_now_add=True + ) + last_updated = models.DateTimeField( + auto_now=True + ) user = models.OneToOneField( to=User, on_delete=models.CASCADE, @@ -251,7 +257,7 @@ class SessionKey(models.Model): @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 such as "Login Credentials" or "SNMP Communities." @@ -277,6 +283,7 @@ class SecretRole(models.Model): blank=True ) + serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['name', 'slug'] class Meta: @@ -304,7 +311,7 @@ class SecretRole(models.Model): @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 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() plaintext = None + serializer = 'ipam.api.secrets.SecretSerializer' csv_headers = ['device', 'role', 'name', 'plaintext'] class Meta: diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index d68ac37fe..4cfb1a6ea 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn from .models import SecretRole, Secret SECRETROLE_ACTIONS = """ + + + {% if perms.secrets.change_secretrole %} {% endif %} diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index cd6415719..952725b54 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Secret, SecretRole app_name = 'secrets' urlpatterns = [ @@ -13,6 +15,7 @@ urlpatterns = [ 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/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + url(r'^secret-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), @@ -22,5 +25,6 @@ urlpatterns = [ url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), url(r'^secrets/(?P\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), + url(r'^secrets/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f34c0fbde..27ebb052d 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -34,6 +34,7 @@ {{ message }} {% endfor %} + {% block header %}{% endblock %} {% block content %}{% endblock %}
{% if settings.BANNER_BOTTOM %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 509c6da89..048c16862 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,44 +1,57 @@ {% extends '_base.html' %} {% load helpers %} +{% block title %}{{ circuit }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.circuits.change_circuit %} + + + Edit this circuit + + {% endif %} + {% if perms.circuits.delete_circuit %} + + + Delete this circuit + + {% endif %} +
+

{{ circuit }}

+ {% include 'inc/created_updated.html' with obj=circuit %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.circuits.change_circuit %} - - - Edit this circuit - - {% endif %} - {% if perms.circuits.delete_circuit %} - - - Delete this circuit - - {% endif %} -
-

{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=circuit %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index e19175c7f..d2fed8647 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -2,49 +2,62 @@ {% load static from staticfiles %} {% load helpers %} +{% block title %}{{ provider }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if show_graphs %} + + {% endif %} + {% if perms.circuits.change_provider %} + + + Edit this provider + + {% endif %} + {% if perms.circuits.delete_provider %} + + + Delete this provider + + {% endif %} +
+

{{ provider }}

+ {% include 'inc/created_updated.html' with obj=provider %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if show_graphs %} - - {% endif %} - {% if perms.circuits.change_provider %} - - - Edit this provider - - {% endif %} - {% if perms.circuits.delete_provider %} - - - Delete this provider - - {% endif %} -
-

{% block title %}{{ provider }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=provider %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1b1d3d23a..1c1539f89 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -4,629 +4,699 @@ {% block title %}{{ device }}{% endblock %} -{% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='info' %} -
-
-
-
- Device -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Site - {% if device.site.region %} - {{ device.site.region }} - - {% endif %} - {{ device.site }} -
Rack - {% if device.rack %} - {% if device.rack.group %} - {{ device.rack.group }} - - {% endif %} - {{ device.rack }} - {% else %} - None - {% endif %} -
Position - {% if device.parent_bay %} - {% with device.parent_bay.device as parent %} - {{ parent }} {{ device.parent_bay }} - {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif device.rack and device.position %} - U{{ device.position }} / {{ device.get_face_display }} - {% elif device.rack and device.device_type.u_height %} - Not racked - {% else %} - N/A - {% endif %} -
Tenant - {% if device.tenant %} - {% if device.tenant.group %} - {{ device.tenant.group }} - - {% endif %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
Device Type - {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) -
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
Tags - {% for tag in device.tags.all %} - {% tag 'dcim:device_list' tag %} - {% empty %} - N/A - {% endfor %} -
+{% block header %} +
+
+
- {% if vc_members %} +
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_device %} + + + Edit this device + + {% endif %} + {% if perms.dcim.delete_device %} + + + Delete this device + + {% endif %} +
+

{{ device }}

+ {% include 'inc/created_updated.html' with obj=device %} + +{% endblock %} + +{% block content %} +
+
- Virtual Chassis + Device
- - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {% for vc_member in vc_members %} - - - - - - - {% endfor %}
DevicePositionMasterPrioritySite + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} + {{ device.site }} +
Rack + {% if device.rack %} + {% if device.rack.group %} + {{ device.rack.group }} + + {% endif %} + {{ device.rack }} + {% else %} + None + {% endif %} +
Position + {% if device.parent_bay %} + {% with device.parent_bay.device as parent %} + {{ parent }} {{ device.parent_bay }} + {% if parent.position %} + (U{{ parent.position }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif device.rack and device.position %} + U{{ device.position }} / {{ device.get_face_display }} + {% elif device.rack and device.device_type.u_height %} + Not racked + {% else %} + N/A + {% endif %} +
Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
Device Type + {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) +
Serial Number + {% if device.serial %} + {{ device.serial }} + {% else %} + N/A + {% endif %} +
Asset Tag + {% if device.asset_tag %} + {{ device.asset_tag }} + {% else %} + N/A + {% endif %} +
Tags + {% for tag in device.tags.all %} + {% tag 'dcim:device_list' tag %} + {% empty %} + N/A + {% endfor %} +
- {{ vc_member }} - {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
- + {% if vc_members %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vc_member }} + {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
+ +
+ {% endif %} +
+
+ Management +
+ + + + + + + + + + + + + + + + + + + + + + {% if device.cluster %} + + + + {% endif %} - {% if perms.dcim.delete_virtualchassis %} - - Delete Virtual Chassis - +
Role + {{ device.device_role }} +
Platform + {% if device.platform %} + {{ device.platform }} + {% else %} + None + {% endif %} +
Status + {{ device.get_status_display }} +
Primary IPv4 + {% if device.primary_ip4 %} + {{ device.primary_ip4.address.ip }} + {% if device.primary_ip4.nat_inside %} + (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) + {% elif device.primary_ip4.nat_outside %} + (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Primary IPv6 + {% if device.primary_ip6 %} + {{ device.primary_ip6.address.ip }} + {% if device.primary_ip6.nat_inside %} + (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) + {% elif device.primary_ip6.nat_outside %} + (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Cluster + {% if device.cluster.group %} + {{ device.cluster.group }} + + {% endif %} + {{ device.cluster }} +
+
+ {% with device.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
+
+ Comments +
+
+ {% if device.comments %} + {{ device.comments|gfm }} + {% else %} + None {% endif %}
- {% endif %} -
-
- Management -
- - - - - - - - - - - - - - - - - - - - - - {% if device.cluster %} - - - - - {% endif %} -
Role - {{ device.device_role }} -
Platform - {% if device.platform %} - {{ device.platform }} - {% else %} - None - {% endif %} -
Status - {{ device.get_status_display }} -
Primary IPv4 - {% if device.primary_ip4 %} - {{ device.primary_ip4.address.ip }} - {% if device.primary_ip4.nat_inside %} - (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) - {% elif device.primary_ip4.nat_outside %} - (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Primary IPv6 - {% if device.primary_ip6 %} - {{ device.primary_ip6.address.ip }} - {% if device.primary_ip6.nat_inside %} - (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) - {% elif device.primary_ip6.nat_outside %} - (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Cluster - {% if device.cluster.group %} - {{ device.cluster.group }} - - {% endif %} - {{ device.cluster }} -
- {% with device.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
- Comments -
-
- {% if device.comments %} - {{ device.comments|gfm }} - {% else %} - None +
+
+
+ Console / Power +
+ + {% for cp in console_ports %} + {% include 'dcim/inc/consoleport.html' %} + {% empty %} + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} + {% endfor %} + {% for pp in power_ports %} + {% include 'dcim/inc/powerport.html' %} + {% empty %} + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} + {% endfor %} +
+ No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
+ No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
+ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} + {% endif %}
-
-
-
-
-
- Console / Power -
- - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% empty %} - {% if device.device_type.console_port_templates.exists %} - - - + {% if request.user.is_authenticated %} +
+
+ Secrets +
+ {% if secrets %} +
- No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
+ {% for secret in secrets %} + {% include 'secrets/inc/secret_tr.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
{% endif %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% empty %} - {% if device.device_type.power_port_templates.exists %} - - - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} - - - {% endif %} - {% endfor %} - - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - {% endif %} -
- {% if request.user.is_authenticated %}
- Secrets + Services
- {% if secrets %} + {% if services %} - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} + {% for service in services %} + {% include 'ipam/inc/service.html' %} {% endfor %}
{% else %}
- None found + None
{% endif %} - {% if perms.secrets.add_secret %} -
- {% csrf_token %} -
+ {% if perms.ipam.add_service %} {% endif %}
- {% endif %} -
-
- Services -
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} -
- None -
- {% endif %} - {% if perms.ipam.add_service %} - - {% endif %} -
-
-
- Images -
- {% include 'inc/image_attachments.html' with images=device.images.all %} - {% if perms.extras.add_imageattachment %} - - {% endif %} -
-
-
- Related Devices -
- {% if related_devices %} - - {% for rd in related_devices %} - - - - - - {% endfor %} -
- {{ rd }} - - {% if rd.rack %} - Rack {{ rd.rack }} - {% else %} - - {% endif %} - {{ rd.device_type.full_name }}
- {% else %} -
None found
- {% endif %} -
-
-
-
-
- {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
- {% csrf_token %} - {% endif %}
- Device Bays + Images
- - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} + {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} + +
+
+ Related Devices +
+ {% if related_devices %} +
NameInstalled Device
+ {% for rd in related_devices %} - + + + {% endfor %} - -
— No device bays defined — + {{ rd }} + + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} + {{ rd.device_type.full_name }}
- + + {% else %} +
None found
+ {% endif %}
- {% if perms.dcim.delete_devicebay %} -
+
+
+
+
+ {% if device_bays or device.device_type.is_parent_device %} + {% if perms.dcim.delete_devicebay %} +
+ {% csrf_token %} + {% endif %} +
+
+ Device Bays +
+ + + + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + + + {% endfor %} + +
NameInstalled Device
— No device bays defined —
+ +
+ {% if perms.dcim.delete_devicebay %} +
+ {% endif %} {% endif %} - {% endif %} - {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
- {% csrf_token %} - + {% if interfaces or device.device_type.is_network_device %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% csrf_token %} + + {% endif %} +
+
+ Interfaces +
+ +
+
+ + + + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + + + + + + + + + + {% for iface in interfaces %} + {% include 'dcim/inc/interface.html' %} + {% empty %} + + + + {% endfor %} + +
NameLAGDescriptionMTUMAC AddressConnection
— No interfaces defined —
+ +
+ {% if perms.dcim.delete_interface %} +
+ {% endif %} {% endif %} -
-
- Interfaces -
- + {% if cs_ports or device.device_type.is_console_server %} + {% if perms.dcim.delete_consoleserverport %} +
+ {% csrf_token %} + {% endif %} +
+
+ Console Server Ports +
+ + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + {% for csp in cs_ports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% empty %} + + + + {% endfor %} + +
NameConnection
— No console server ports defined —
+
- - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% empty %} + {% if perms.dcim.delete_consoleserverport %} + + {% endif %} + {% endif %} + {% if power_outlets or device.device_type.is_pdu %} + {% if perms.dcim.delete_poweroutlet %} + + {% csrf_token %} + {% endif %} +
+
+ Power Outlets +
+
NameLAGDescriptionMTUMAC AddressConnection
+ - + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + - {% endfor %} - -
— No interfaces defined —NameConnection
- -
- {% if perms.dcim.delete_interface %} - - {% endif %} - {% endif %} - {% if cs_ports or device.device_type.is_console_server %} - {% if perms.dcim.delete_consoleserverport %} -
- {% csrf_token %} - {% endif %} -
-
- Console Server Ports + + + {% for po in power_outlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% empty %} + + — No power outlets defined — + + {% endfor %} + + +
- - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - {% for csp in cs_ports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No console server ports defined —
- -
- {% if perms.dcim.delete_consoleserverport %} -
+ {% if perms.dcim.delete_poweroutlet %} + + {% endif %} {% endif %} - {% endif %} - {% if power_outlets or device.device_type.is_pdu %} - {% if perms.dcim.delete_poweroutlet %} -
- {% csrf_token %} - {% endif %} -
-
- Power Outlets -
- - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - {% for po in power_outlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No power outlets defined —
- -
- {% if perms.dcim.delete_poweroutlet %} -
- {% endif %} - {% endif %} -
-
+
+
{% include 'inc/graphs_modal.html' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} diff --git a/netbox/templates/dcim/device_config.html b/netbox/templates/dcim/device_config.html index b62ff0211..210a9379a 100644 --- a/netbox/templates/dcim/device_config.html +++ b/netbox/templates/dcim/device_config.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Config{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='config' %}
diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1db2dcefa..1efbd0fbc 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -1,77 +1,76 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% block title %}{{ device }} - Inventory{% endblock %} {% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='inventory' %} -
-
-
-
- Chassis -
- - - - - - - - - - - - - -
Model{{ device.device_type.full_name }}
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
-
-
-
-
-
- Hardware -
- - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
NameManufacturerPart NumberSerial NumberAsset TagDescription
- {% if perms.dcim.add_inventoryitem %} - {% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 0e423ad56..c0c82f459 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/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 content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
LLDP Neighbors diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 7c62b3971..7743cc635 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='status' %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 25d33101e..151e27018 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,34 +1,47 @@ {% extends '_base.html' %} {% load helpers %} +{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} + +{% block header %} +
+
+ +
+
+ {% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %} +
+ {% if perms.dcim.change_devicetype %} + + + Edit this device type + + {% endif %} + {% if perms.dcim.delete_devicetype %} + + + Delete this device type + + {% endif %} +
+ {% endif %} +

{{ devicetype.manufacturer }} {{ devicetype.model }}

+ {% include 'inc/created_updated.html' with obj=devicetype %} + +{% endblock %} + {% block content %} -
-
- -
-
-{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %} -
- {% if perms.dcim.change_devicetype %} - - - Edit this device type - - {% endif %} - {% if perms.dcim.delete_devicetype %} - - - Delete this device type - - {% endif %} -
-{% endif %} -

{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=devicetype %}
diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html deleted file mode 100644 index 92acd297d..000000000 --- a/netbox/templates/dcim/inc/device_header.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.dcim.change_device %} - - - Edit this device - - {% endif %} - {% if perms.dcim.delete_device %} - - - Delete this device - -{% endif %} -
-

{{ device }}

-{% include 'inc/created_updated.html' with obj=device %} - diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 82348e6fe..5ff8a3259 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,48 +1,59 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ + Previous Rack + + + Next Rack + + {% if perms.dcim.change_rack %} + + Edit this rack + + {% endif %} + {% if perms.dcim.delete_rack %} + + Delete this rack + + {% endif %} +
+

{% block title %}Rack {{ rack }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=rack %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- - Previous Rack - - - Next Rack - - {% if perms.dcim.change_rack %} - - Edit this rack - - {% endif %} - {% if perms.dcim.delete_rack %} - - Delete this rack - - {% endif %} -
-

{% block title %}Rack {{ rack }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=rack %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 6e53f6716..442242214 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -3,54 +3,66 @@ {% load tz %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if show_graphs %} + + {% endif %} + {% if perms.dcim.change_site %} + + + Edit this site + + {% endif %} + {% if perms.dcim.delete_site %} + + + Delete this site + + {% endif %} +
+

{% block title %}{{ site }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=site %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if show_graphs %} - - {% endif %} - {% if perms.dcim.change_site %} - - - Edit this site - - {% endif %} - {% if perms.dcim.delete_site %} - - - Delete this site - - {% endif %} -
-

{% block title %}{{ site }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=site %}
diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html new file mode 100644 index 000000000..ac79be2a6 --- /dev/null +++ b/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 %}

{{ obj }}

{% endif %} + {% include 'panel_table.html' with table=objectchanges_table %} +{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html new file mode 100644 index 000000000..df606bacc --- /dev/null +++ b/netbox/templates/extras/objectchange.html @@ -0,0 +1,101 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ objectchange }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+{% endblock %} + +{% block content %} +
+
+
+
+ Change +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Time + {{ objectchange.time }} +
User + {{ objectchange.user|default:objectchange.user_name }} +
Action + {{ objectchange.get_action_display }} +
Object Type + {{ objectchange.changed_object_type }} +
Object + {% if objectchange.changed_object.get_absolute_url %} + {{ objectchange.changed_object }} + {% else %} + {{ objectchange.object_repr }} + {% endif %} +
Request ID + {{ objectchange.request_id }} +
+
+
+
+
+
+ Object Data +
+
+
{{ objectchange.object_data_pretty }}
+
+
+
+
+
+
+ {% include 'panel_table.html' with table=related_changes_table heading='Related Changes' %} + {% if related_changes_count > related_changes_table.rows|length %} + + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html new file mode 100644 index 000000000..46ddc1d94 --- /dev/null +++ b/netbox/templates/extras/objectchange_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Changelog{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 7b9e6ac3c..ced87768e 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -66,6 +66,9 @@
  • Reports
  • +
  • + Changelog +