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:
@ -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 + '}',
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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():
|
||||
|
@ -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={
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user