Initial work on #151: Object journaling

This commit is contained in:
Jeremy Stretch 2021-03-16 15:00:08 -04:00
parent e97adcb614
commit 1f1a62da67
23 changed files with 365 additions and 12 deletions

View File

@ -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
@ -18,6 +18,7 @@ urlpatterns = [
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'),
@ -39,6 +40,7 @@ urlpatterns = [
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

View File

@ -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 *
@ -38,6 +38,7 @@ urlpatterns = [
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
@ -70,6 +71,7 @@ urlpatterns = [
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'),
@ -82,6 +84,7 @@ urlpatterns = [
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
@ -104,6 +107,7 @@ urlpatterns = [
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'),
@ -210,6 +214,7 @@ urlpatterns = [
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'),
@ -365,6 +370,7 @@ urlpatterns = [
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'),
@ -381,6 +387,7 @@ urlpatterns = [
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'),
@ -394,6 +401,7 @@ urlpatterns = [
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'),
@ -406,6 +414,7 @@ urlpatterns = [
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}),
]

View File

@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
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
@ -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

View File

@ -12,6 +12,7 @@ __all__ = [
'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer',
'NestedJobResultSerializer',
'NestedJournalEntrySerializer',
'NestedTagSerializer', # Defined in netbox.api.serializers
'NestedWebhookSerializer',
]
@ -65,6 +66,14 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
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)

View File

@ -182,6 +182,46 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
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
#

View File

@ -23,6 +23,9 @@ router.register('tags', views.TagViewSet)
# Image attachments
router.register('image-attachments', views.ImageAttachmentViewSet)
# Journal entries
router.register('journal-entries', views.JournalEntryViewSet)
# Config contexts
router.register('config-contexts', views.ConfigContextViewSet)

View File

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

View File

@ -21,6 +21,7 @@ __all__ = (
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
@ -117,6 +118,24 @@ class ImageAttachmentFilterSet(BaseFilterSet):
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',

View File

@ -13,7 +13,7 @@ from utilities.forms import (
)
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
#
@ -371,6 +371,21 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
]
#
# 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
#

View File

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

View File

@ -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__ = (
@ -12,6 +12,7 @@ __all__ = (
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'ObjectChange',
'Report',
'Script',

View File

@ -23,6 +23,7 @@ __all__ = (
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'Report',
'Script',
'Webhook',
@ -370,6 +371,45 @@ class ImageAttachment(BigIDModel):
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
#

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django.conf import settings
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
TAGGED_ITEM = """
{% if value.get_absolute_url %}
@ -96,3 +96,17 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class ObjectJournalTable(BaseTable):
created = tables.DateTimeColumn(
format=settings.SHORT_DATETIME_FORMAT
)
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = ('created', 'created_by', 'comments', 'actions')

View File

@ -31,6 +31,11 @@ urlpatterns = [
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Journal entries
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from django_rq.queues import get_connection
from django_tables2 import RequestConfig
@ -16,7 +17,7 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
@ -281,6 +282,97 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
return imageattachment.parent.get_absolute_url()
#
# Journal entries
#
class JournalEntryEditView(generic.ObjectEditView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryForm
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
obj.created_by = request.user
return obj
def get_return_url(self, request, instance):
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
return reverse(viewname, kwargs={'pk': obj.pk})
class JournalEntryDeleteView(generic.ObjectDeleteView):
queryset = JournalEntry.objects.all()
def get_return_url(self, request, instance):
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
return reverse(viewname, kwargs={'pk': obj.pk})
class ObjectJournalView(View):
"""
Show all journal entries for an object.
base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
"""
base_template = None
def get(self, request, model, **kwargs):
# Handle QuerySet restriction of parent object if needed
if hasattr(model.objects, 'restrict'):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
else:
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
assigned_object_type=content_type,
assigned_object_id=obj.pk
)
journalentry_table = tables.ObjectJournalTable(
data=journalentries,
orderable=False
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(journalentry_table)
if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm(
initial={
'assigned_object_type': ContentType.objects.get_for_model(obj),
'assigned_object_id': obj.pk
}
)
else:
form = None
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html.
if self.base_template is None:
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
# TODO: This can be removed once an object view has been established for every model.
try:
template.loader.get_template(self.base_template)
except template.TemplateDoesNotExist:
self.base_template = 'base.html'
return render(request, 'extras/object_journal.html', {
'object': obj,
'form': form,
'table': journalentry_table,
'base_template': self.base_template,
'active_tab': 'journal',
})
#
# Reports
#

View File

@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
@ -17,6 +17,7 @@ urlpatterns = [
path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
path('vrfs/<int:pk>/journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}),
# Route targets
path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'),
@ -28,6 +29,7 @@ urlpatterns = [
path('route-targets/<int:pk>/edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'),
path('route-targets/<int:pk>/delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'),
path('route-targets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}),
path('route-targets/<int:pk>/journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}),
# RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'),
@ -49,6 +51,7 @@ urlpatterns = [
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
path('aggregates/<int:pk>/journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}),
# Roles
path('roles/', views.RoleListView.as_view(), name='role_list'),
@ -70,6 +73,7 @@ urlpatterns = [
path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
path('prefixes/<int:pk>/journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}),
path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
@ -81,6 +85,7 @@ urlpatterns = [
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
path('ip-addresses/<int:pk>/journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}),
path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
@ -109,6 +114,7 @@ urlpatterns = [
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}),
# Services
path('services/', views.ServiceListView.as_view(), name='service_list'),
@ -119,5 +125,6 @@ urlpatterns = [
path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
]

View File

@ -1,6 +1,7 @@
import logging
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError
from django.db import models
@ -149,7 +150,14 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel):
"""
Primary models represent real objects within the infrastructure being modeled.
"""
tags = TaggableManager(through='extras.TaggedItem')
journal_entries = GenericRelation(
to='extras.JournalEntry',
object_id_field='assigned_object_id',
content_type_field='assigned_object_type'
)
tags = TaggableManager(
through='extras.TaggedItem'
)
class Meta:
abstract = True

View File

@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Secret, SecretRole
@ -27,5 +27,6 @@ urlpatterns = [
path('secrets/<int:pk>/edit/', views.SecretEditView.as_view(), name='secret_edit'),
path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
path('secrets/<int:pk>/journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': Secret}),
]

View File

@ -147,6 +147,11 @@
<a href="{% url 'dcim:device_configcontext' pk=object.pk %}">Config Context</a>
</li>
{% endif %}
{% if perms.extras.view_journalentry %}
<li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_journal' pk=object.pk %}">Journal</a>
</li>
{% endif %}
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_changelog' pk=object.pk %}">Change Log</a>

View File

@ -0,0 +1,30 @@
{% extends base_template %}
{% load form_helpers %}
{% block title %}{{ block.super }} - Journal{% endblock %}
{% block content %}
{% if perms.extras.add_journalentry %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>New Journal Entry</strong>
</div>
<form action="{% url 'extras:journalentry_add' %}" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row panel-body">
<div class="col-md-10">
{% render_field form.comments %}
</div>
<div class="col-md-9 col-md-offset-3">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ object.get_absolute_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
</div>
{% endif %}
{% include 'panel_table.html' %}
{% endblock %}

View File

@ -52,6 +52,12 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
</li>
{% if perms.extras.view_journalentry %}
<li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
{# TODO: Fix journal URL resolution hack #}
<a href="{{ object.get_absolute_url }}journal/">Journal</a>
</li>
{% endif %}
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
{# TODO: Fix changelog URL resolution hack #}

View File

@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Tenant, TenantGroup
@ -27,5 +27,6 @@ urlpatterns = [
path('tenants/<int:pk>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
path('tenants/<int:pk>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
path('tenants/<int:pk>/journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
]

View File

@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -38,6 +38,7 @@ urlpatterns = [
path('clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
path('clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
path('clusters/<int:pk>/journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}),
path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
path('clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
@ -52,6 +53,7 @@ urlpatterns = [
path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
path('virtual-machines/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}),
path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces