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

Extend ObjectChange to optionally indicate a related object (e.g. a parent device)

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

+ 79 - 14
netbox/dcim/models.py

@@ -18,16 +18,41 @@ from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, ObjectChange
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from utilities.fields import ColorField, NullableCharField
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
+from utilities.utils import serialize_object
 from .constants import *
 from .constants import *
 from .fields import ASNField, MACAddressField
 from .fields import ASNField, MACAddressField
 from .querysets import InterfaceQuerySet
 from .querysets import InterfaceQuerySet
 
 
 
 
+class ComponentModel(models.Model):
+
+    class Meta:
+        abstract = True
+
+    def get_component_parent(self):
+        raise NotImplementedError(
+            "ComponentModel must implement get_component_parent()"
+        )
+
+    def log_change(self, user, request_id, action):
+        """
+        Log an ObjectChange including the parent Device.
+        """
+        ObjectChange(
+            user=user,
+            request_id=request_id,
+            changed_object=self,
+            related_object=self.get_component_parent(),
+            action=action,
+            object_data=serialize_object(self)
+        ).save()
+
+
 #
 #
 # Regions
 # Regions
 #
 #
@@ -866,7 +891,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsolePortTemplate(models.Model):
+class ConsolePortTemplate(ComponentModel):
     """
     """
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
     """
     """
@@ -886,9 +911,12 @@ class ConsolePortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsoleServerPortTemplate(models.Model):
+class ConsoleServerPortTemplate(ComponentModel):
     """
     """
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
     """
     """
@@ -908,9 +936,12 @@ class ConsoleServerPortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerPortTemplate(models.Model):
+class PowerPortTemplate(ComponentModel):
     """
     """
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
     """
     """
@@ -930,9 +961,12 @@ class PowerPortTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerOutletTemplate(models.Model):
+class PowerOutletTemplate(ComponentModel):
     """
     """
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
     """
     """
@@ -952,9 +986,12 @@ class PowerOutletTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class InterfaceTemplate(models.Model):
+class InterfaceTemplate(ComponentModel):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
     """
     """
@@ -984,9 +1021,12 @@ class InterfaceTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceBayTemplate(models.Model):
+class DeviceBayTemplate(ComponentModel):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     A template for a DeviceBay to be created for a new parent Device.
     """
     """
@@ -1006,6 +1046,9 @@ class DeviceBayTemplate(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def get_component_parent(self):
+        return self.device_type
+
 
 
 #
 #
 # Devices
 # Devices
@@ -1502,7 +1545,7 @@ class Device(ChangeLoggedModel, CustomFieldModel):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsolePort(models.Model):
+class ConsolePort(ComponentModel):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
@@ -1539,6 +1582,9 @@ class ConsolePort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.cs_port.device.identifier if self.cs_port else None,
             self.cs_port.device.identifier if self.cs_port else None,
@@ -1564,7 +1610,7 @@ class ConsoleServerPortManager(models.Manager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class ConsoleServerPort(models.Model):
+class ConsoleServerPort(ComponentModel):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
     """
@@ -1588,6 +1634,9 @@ class ConsoleServerPort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a console server
         # Check that the parent device's DeviceType is a console server
@@ -1605,7 +1654,7 @@ class ConsoleServerPort(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerPort(models.Model):
+class PowerPort(ComponentModel):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
@@ -1641,6 +1690,9 @@ class PowerPort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.power_outlet.device.identifier if self.power_outlet else None,
             self.power_outlet.device.identifier if self.power_outlet else None,
@@ -1666,7 +1718,7 @@ class PowerOutletManager(models.Manager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class PowerOutlet(models.Model):
+class PowerOutlet(ComponentModel):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
     """
@@ -1690,6 +1742,9 @@ class PowerOutlet(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a PDU
         # Check that the parent device's DeviceType is a PDU
@@ -1707,7 +1762,7 @@ class PowerOutlet(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Interface(models.Model):
+class Interface(ComponentModel):
     """
     """
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
     Interface via the creation of an InterfaceConnection.
     Interface via the creation of an InterfaceConnection.
@@ -1797,6 +1852,9 @@ class Interface(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.parent.get_absolute_url()
         return self.parent.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device or self.virtual_machine
+
     def clean(self):
     def clean(self):
 
 
         # Check that the parent device's DeviceType is a network device
         # Check that the parent device's DeviceType is a network device
@@ -1867,6 +1925,7 @@ class Interface(models.Model):
 
 
         return super(Interface, self).save(*args, **kwargs)
         return super(Interface, self).save(*args, **kwargs)
 
 
+    # TODO: Replace `parent` with get_component_parent() (from ComponentModel)
     @property
     @property
     def parent(self):
     def parent(self):
         return self.device or self.virtual_machine
         return self.device or self.virtual_machine
@@ -1977,7 +2036,7 @@ class InterfaceConnection(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class DeviceBay(models.Model):
+class DeviceBay(ComponentModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
     """
     """
@@ -2008,6 +2067,9 @@ class DeviceBay(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def clean(self):
     def clean(self):
 
 
         # Validate that the parent Device can have DeviceBays
         # Validate that the parent Device can have DeviceBays
@@ -2026,7 +2088,7 @@ class DeviceBay(models.Model):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class InventoryItem(models.Model):
+class InventoryItem(ComponentModel):
     """
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     InventoryItems are used only for inventory purposes.
     InventoryItems are used only for inventory purposes.
@@ -2095,6 +2157,9 @@ class InventoryItem(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
+    def get_component_parent(self):
+        return self.device
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.device.name or '{' + self.device.pk + '}',
             self.device.name or '{' + self.device.pk + '}',

+ 3 - 3
netbox/extras/admin.py

@@ -132,10 +132,10 @@ class TopologyMapAdmin(admin.ModelAdmin):
 @admin.register(ObjectChange)
 @admin.register(ObjectChange)
 class ObjectChangeAdmin(admin.ModelAdmin):
 class ObjectChangeAdmin(admin.ModelAdmin):
     actions = None
     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_filter = ['time', 'action', 'user__username']
-    list_select_related = ['content_type', 'user']
+    list_select_related = ['changed_object_type', 'user']
     readonly_fields = fields
     readonly_fields = fields
     search_fields = ['user_name', 'object_repr', 'request_id']
     search_fields = ['user_name', 'object_repr', 'request_id']
 
 

+ 1 - 1
netbox/extras/filters.py

@@ -133,7 +133,7 @@ class ObjectChangeFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = ObjectChange
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 6 - 4
netbox/extras/migrations/0013_objectchange.py

@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
 # -*- 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 __future__ import unicode_literals
 
 
 from django.conf import settings
 from django.conf import settings
@@ -11,8 +11,8 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('contenttypes', '0002_remove_content_type_name'),
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contenttypes', '0002_remove_content_type_name'),
         ('extras', '0012_webhooks'),
         ('extras', '0012_webhooks'),
     ]
     ]
 
 
@@ -25,10 +25,12 @@ class Migration(migrations.Migration):
                 ('user_name', models.CharField(editable=False, max_length=150)),
                 ('user_name', models.CharField(editable=False, max_length=150)),
                 ('request_id', models.UUIDField(editable=False)),
                 ('request_id', models.UUIDField(editable=False)),
                 ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])),
                 ('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_repr', models.CharField(editable=False, max_length=200)),
                 ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)),
                 ('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)),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
             ],
             ],
             options={
             options={

+ 36 - 13
netbox/extras/models.py

@@ -665,7 +665,9 @@ class ReportResult(models.Model):
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class ObjectChange(models.Model):
 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(
     time = models.DateTimeField(
         auto_now_add=True,
         auto_now_add=True,
@@ -688,14 +690,30 @@ class ObjectChange(models.Model):
     action = models.PositiveSmallIntegerField(
     action = models.PositiveSmallIntegerField(
         choices=OBJECTCHANGE_ACTION_CHOICES
         choices=OBJECTCHANGE_ACTION_CHOICES
     )
     )
-    content_type = models.ForeignKey(
+    changed_object_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
-        on_delete=models.CASCADE
+        on_delete=models.PROTECT,
+        related_name='+'
     )
     )
-    object_id = models.PositiveIntegerField()
+    changed_object_id = models.PositiveIntegerField()
     changed_object = GenericForeignKey(
     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(
     object_repr = models.CharField(
         max_length=200,
         max_length=200,
@@ -706,14 +724,17 @@ class ObjectChange(models.Model):
     )
     )
 
 
     serializer = 'extras.api.serializers.ObjectChangeSerializer'
     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:
     class Meta:
         ordering = ['-time']
         ordering = ['-time']
 
 
     def __str__(self):
     def __str__(self):
         return '{} {} {} by {}'.format(
         return '{} {} {} by {}'.format(
-            self.content_type,
+            self.changed_object_type,
             self.object_repr,
             self.object_repr,
             self.get_action_display().lower(),
             self.get_action_display().lower(),
             self.user_name
             self.user_name
@@ -722,8 +743,7 @@ class ObjectChange(models.Model):
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Record the user's name and the object's representation as static strings
         # 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)
         self.object_repr = str(self.changed_object)
 
 
         return super(ObjectChange, self).save(*args, **kwargs)
         return super(ObjectChange, self).save(*args, **kwargs)
@@ -734,11 +754,14 @@ class ObjectChange(models.Model):
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.time,
             self.time,
-            self.user or self.user_name,
+            self.user,
+            self.user_name,
             self.request_id,
             self.request_id,
             self.get_action_display(),
             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_repr,
             self.object_data,
             self.object_data,
         )
         )

+ 4 - 1
netbox/extras/tables.py

@@ -52,6 +52,9 @@ class ObjectChangeTable(BaseTable):
     action = tables.TemplateColumn(
     action = tables.TemplateColumn(
         template_code=OBJECTCHANGE_ACTION
         template_code=OBJECTCHANGE_ACTION
     )
     )
+    changed_object_type = tables.Column(
+        verbose_name='Type'
+    )
     object_repr = tables.TemplateColumn(
     object_repr = tables.TemplateColumn(
         template_code=OBJECTCHANGE_OBJECT,
         template_code=OBJECTCHANGE_OBJECT,
         verbose_name='Object'
         verbose_name='Object'
@@ -62,4 +65,4 @@ class ObjectChangeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ObjectChange
         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')

+ 5 - 5
netbox/extras/views.py

@@ -4,7 +4,7 @@ from django import template
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 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.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render, reverse
 from django.shortcuts import get_object_or_404, redirect, render, reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -94,13 +94,13 @@ class ObjectChangeLogView(View):
         # Get object my model and kwargs (e.g. slug='foo')
         # Get object my model and kwargs (e.g. slug='foo')
         obj = get_object_or_404(model, **kwargs)
         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)
         content_type = ContentType.objects.get_for_model(model)
         objectchanges = ObjectChange.objects.select_related(
         objectchanges = ObjectChange.objects.select_related(
-            'user', 'content_type'
+            'user', 'changed_object_type'
         ).filter(
         ).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(
         objectchanges_table = ObjectChangeTable(
             data=objectchanges,
             data=objectchanges,

+ 2 - 2
netbox/templates/extras/objectchange.html

@@ -53,9 +53,9 @@
                         </td>
                         </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
-                        <td>Content Type</td>
+                        <td>Object Type</td>
                         <td>
                         <td>
-                            {{ objectchange.content_type }}
+                            {{ objectchange.changed_object_type }}
                         </td>
                         </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>