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

View File

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

View File

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

View File

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

View File

@ -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,7 +743,6 @@ 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.object_repr = str(self.changed_object)
@ -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,
)

View File

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

View File

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

View File

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