1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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

This commit is contained in:
Jeremy Stretch
2018-06-22 15:05:40 -04:00
parent 6c1b5fdf3a
commit 2d198403c7
8 changed files with 136 additions and 43 deletions

View File

@ -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 + '}',

View File

@ -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'] fields = ['time', 'changed_object_type', 'display_object', 'action', 'display_user', 'request_id', 'object_data']
list_display = ['time', 'content_type', 'display_object', 'display_action', 'display_user', 'request_id'] 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']

View File

@ -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():

View File

@ -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={

View File

@ -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', ct_field='changed_object_type',
fk_field='object_id' 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.changed_object_type,
self.object_id, self.changed_object_id,
self.related_object_type,
self.related_object_id,
self.object_repr, self.object_repr,
self.object_data, self.object_data,
) )

View File

@ -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')

View File

@ -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, Q(changed_object_type=content_type, changed_object_id=obj.pk) |
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,

View File

@ -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>