Skip to content

Commit

Permalink
Initial work on #151: Object journaling
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Mar 16, 2021
1 parent e97adcb commit 1f1a62d
Show file tree
Hide file tree
Showing 23 changed files with 365 additions and 12 deletions.
4 changes: 3 additions & 1 deletion netbox/circuits/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.urls import path

from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Circuit, CircuitTermination, CircuitType, Provider

Expand All @@ -18,6 +18,7 @@
path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),

# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
Expand All @@ -39,6 +40,7 @@
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),

# Circuit terminations
Expand Down
11 changes: 10 additions & 1 deletion netbox/dcim/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.urls import path

from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from . import views
from .models import *
Expand Down Expand Up @@ -38,6 +38,7 @@
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),

# Locations
Expand Down Expand Up @@ -70,6 +71,7 @@
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),

# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
Expand All @@ -82,6 +84,7 @@
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),

# Manufacturers
Expand All @@ -104,6 +107,7 @@
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),

# Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
Expand Down Expand Up @@ -210,6 +214,7 @@
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
Expand Down Expand Up @@ -365,6 +370,7 @@
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),

# Console/power/interface connections (read-only)
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
Expand All @@ -381,6 +387,7 @@
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),

Expand All @@ -394,6 +401,7 @@
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),

# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
Expand All @@ -406,6 +414,7 @@
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),

]
6 changes: 5 additions & 1 deletion netbox/dcim/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.views.generic import View

from circuits.models import Circuit
from extras.views import ObjectChangeLogView, ObjectConfigContextView
from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic
Expand Down Expand Up @@ -1383,6 +1383,10 @@ class DeviceChangeLogView(ObjectChangeLogView):
base_template = 'dcim/device/base.html'


class DeviceJournalView(ObjectJournalView):
base_template = 'dcim/device/base.html'


class DeviceEditView(generic.ObjectEditView):
queryset = Device.objects.all()
model_form = forms.DeviceForm
Expand Down
9 changes: 9 additions & 0 deletions netbox/extras/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer',
'NestedJobResultSerializer',
'NestedJournalEntrySerializer',
'NestedTagSerializer', # Defined in netbox.api.serializers
'NestedWebhookSerializer',
]
Expand Down Expand Up @@ -65,6 +66,14 @@ class Meta:
fields = ['id', 'url', 'display', 'name', 'image']


class NestedJournalEntrySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')

class Meta:
model = models.JournalEntry
fields = ['id', 'url', 'display', 'created']


class NestedJobResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
status = ChoiceField(choices=choices.JobResultStatusChoices)
Expand Down
40 changes: 40 additions & 0 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,46 @@ def get_parent(self, obj):
return serializer(obj.parent, context={'request': self.context['request']}).data


#
# Journal entries
#

class JournalEntrySerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.all()
)
assigned_object = serializers.SerializerMethodField(read_only=True)

class Meta:
model = JournalEntry
fields = [
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'comments',
]

def validate(self, data):

# Validate that the parent object exists
try:
data['content_type'].get_object_for_this_type(id=data['object_id'])
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
)

# Enforce model validation
super().validate(data)

return data

@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data


#
# Config contexts
#
Expand Down
3 changes: 3 additions & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
# Image attachments
router.register('image-attachments', views.ImageAttachmentViewSet)

# Journal entries
router.register('journal-entries', views.JournalEntryViewSet)

# Config contexts
router.register('config-contexts', views.ConfigContextViewSet)

Expand Down
11 changes: 11 additions & 0 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ class ImageAttachmentViewSet(ModelViewSet):
filterset_class = filters.ImageAttachmentFilterSet


#
# Journal entries
#

class JournalEntryViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = JournalEntry.objects.all()
serializer_class = serializers.JournalEntrySerializer
filterset_class = filters.JournalEntryFilterSet


#
# Config contexts
#
Expand Down
19 changes: 19 additions & 0 deletions netbox/extras/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
Expand Down Expand Up @@ -117,6 +118,24 @@ class Meta:
fields = ['id', 'content_type_id', 'object_id', 'name']


class JournalEntryFilterSet(BaseFilterSet):
assigned_object_type = ContentTypeFilter()
# created_by_id = django_filters.ModelMultipleChoiceFilter(
# queryset=User.objects.all(),
# label='User (ID)',
# )
# created_by = django_filters.ModelMultipleChoiceFilter(
# field_name='user__username',
# queryset=User.objects.all(),
# to_field_name='username',
# label='User (name)',
# )

class Meta:
model = JournalEntry
fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created']


class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
Expand Down
17 changes: 16 additions & 1 deletion netbox/extras/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag


#
Expand Down Expand Up @@ -371,6 +371,21 @@ class Meta:
]


#
# Journal entries
#

class JournalEntryForm(BootstrapMixin, forms.ModelForm):

class Meta:
model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'comments']
widgets = {
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}


#
# Change logging
#
Expand Down
29 changes: 29 additions & 0 deletions netbox/extras/migrations/0058_journalentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0057_customlink_rename_fields'),
]

operations = [
migrations.CreateModel(
name='JournalEntry',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('assigned_object_id', models.PositiveIntegerField()),
('created', models.DateTimeField(auto_now_add=True)),
('comments', models.TextField()),
('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created',),
},
),
]
3 changes: 2 additions & 1 deletion netbox/extras/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .change_logging import ObjectChange
from .configcontexts import ConfigContext, ConfigContextModel
from .customfields import CustomField
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, Webhook
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
from .tags import Tag, TaggedItem

__all__ = (
Expand All @@ -12,6 +12,7 @@
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'ObjectChange',
'Report',
'Script',
Expand Down
40 changes: 40 additions & 0 deletions netbox/extras/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'Report',
'Script',
'Webhook',
Expand Down Expand Up @@ -370,6 +371,45 @@ def size(self):
return None


#
# Journal entries
#

class JournalEntry(BigIDModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
"""
assigned_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
assigned_object_id = models.PositiveIntegerField()
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
created = models.DateTimeField(
auto_now_add=True
)
created_by = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
blank=True,
null=True
)
comments = models.TextField()

objects = RestrictedQuerySet.as_manager()

class Meta:
ordering = ('-created',)

def __str__(self):
return f"{self.created}"


#
# Custom scripts
#
Expand Down
Loading

0 comments on commit 1f1a62d

Please sign in to comment.