diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5ffac3f5c..606b2edfc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -18,16 +18,41 @@ from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit -from extras.models import CustomFieldModel +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 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. + """ + 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 # @@ -866,7 +891,7 @@ class DeviceType(ChangeLoggedModel, 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 +911,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 +936,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 +961,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 +986,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 +1021,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,6 +1046,9 @@ class DeviceBayTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + # # Devices @@ -1502,7 +1545,7 @@ class Device(ChangeLoggedModel, CustomFieldModel): # @python_2_unicode_compatible -class ConsolePort(models.Model): +class ConsolePort(ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -1539,6 +1582,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, @@ -1564,7 +1610,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. """ @@ -1588,6 +1634,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 @@ -1605,7 +1654,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. """ @@ -1641,6 +1690,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, @@ -1666,7 +1718,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. """ @@ -1690,6 +1742,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 @@ -1707,7 +1762,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. @@ -1797,6 +1852,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 @@ -1867,6 +1925,7 @@ class Interface(models.Model): return super(Interface, self).save(*args, **kwargs) + # TODO: Replace `parent` with get_component_parent() (from ComponentModel) @property def parent(self): return self.device or self.virtual_machine @@ -1977,7 +2036,7 @@ class InterfaceConnection(models.Model): # @python_2_unicode_compatible -class DeviceBay(models.Model): +class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device """ @@ -2008,6 +2067,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 @@ -2026,7 +2088,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. @@ -2095,6 +2157,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 + '}', diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 534057a8d..7d30cff34 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -132,10 +132,10 @@ class TopologyMapAdmin(admin.ModelAdmin): @admin.register(ObjectChange) class ObjectChangeAdmin(admin.ModelAdmin): actions = None - fields = ['time', 'content_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data'] - list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user', 'request_id'] + 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 = ['content_type', 'user'] + list_select_related = ['changed_object_type', 'user'] readonly_fields = fields search_fields = ['user_name', 'object_repr', 'request_id'] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index dbdf26c6f..71c9314cd 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -133,7 +133,7 @@ class ObjectChangeFilter(django_filters.FilterSet): class Meta: model = ObjectChange - fields = ['user', 'user_name', 'request_id', 'action', 'content_type', 'object_repr'] + fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py index a8a7d7ee3..de4762a46 100644 --- a/netbox/extras/migrations/0013_objectchange.py +++ b/netbox/extras/migrations/0013_objectchange.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-06-19 19:34 +# Generated by Django 1.11.12 on 2018-06-22 18:13 from __future__ import unicode_literals from django.conf import settings @@ -11,8 +11,8 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), ('extras', '0012_webhooks'), ] @@ -25,10 +25,12 @@ class Migration(migrations.Migration): ('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')])), - ('object_id', models.PositiveIntegerField()), + ('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)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('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={ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 2a225b0fe..7b02271c7 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -665,7 +665,9 @@ class ReportResult(models.Model): @python_2_unicode_compatible class ObjectChange(models.Model): """ - Record a change to an object and the user account associated with that change. + 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, @@ -688,14 +690,30 @@ class ObjectChange(models.Model): action = models.PositiveSmallIntegerField( choices=OBJECTCHANGE_ACTION_CHOICES ) - content_type = models.ForeignKey( + changed_object_type = models.ForeignKey( to=ContentType, - on_delete=models.CASCADE + on_delete=models.PROTECT, + related_name='+' ) - object_id = models.PositiveIntegerField() + changed_object_id = models.PositiveIntegerField() changed_object = GenericForeignKey( - ct_field='content_type', - fk_field='object_id' + 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, @@ -706,14 +724,17 @@ class ObjectChange(models.Model): ) serializer = 'extras.api.serializers.ObjectChangeSerializer' - csv_headers = ['time', 'user', 'request_id', 'action', 'content_type', 'object_id', 'object_repr', 'object_data'] + 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.content_type, + self.changed_object_type, self.object_repr, self.get_action_display().lower(), self.user_name @@ -722,8 +743,7 @@ class ObjectChange(models.Model): def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings - if self.user is not None: - self.user_name = self.user.username + self.user_name = self.user.username self.object_repr = str(self.changed_object) return super(ObjectChange, self).save(*args, **kwargs) @@ -734,11 +754,14 @@ class ObjectChange(models.Model): def to_csv(self): return ( self.time, - self.user or self.user_name, + self.user, + self.user_name, self.request_id, self.get_action_display(), - self.content_type, - self.object_id, + self.changed_object_type, + self.changed_object_id, + self.related_object_type, + self.related_object_id, self.object_repr, self.object_data, ) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index afc5f2a53..bd190c7e5 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -52,6 +52,9 @@ class ObjectChangeTable(BaseTable): 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' @@ -62,4 +65,4 @@ class ObjectChangeTable(BaseTable): class Meta(BaseTable.Meta): model = ObjectChange - fields = ('time', 'user_name', 'action', 'content_type', 'object_repr', 'request_id') + fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 34a94b9df..e5e04d06b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,7 +4,7 @@ from django import template from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType -from django.db.models import Count +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 @@ -94,13 +94,13 @@ class ObjectChangeLogView(View): # Get object my model and kwargs (e.g. slug='foo') obj = get_object_or_404(model, **kwargs) - # Gather all changes for this object + # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) objectchanges = ObjectChange.objects.select_related( - 'user', 'content_type' + 'user', 'changed_object_type' ).filter( - content_type=content_type, - object_id=obj.pk + 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, diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 144c712a7..df606bacc 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -53,9 +53,9 @@