Sfoglia il codice sorgente

Initial work on #151: Object journaling

Jeremy Stretch 4 anni fa
parent
commit
1f1a62da67

+ 3 - 1
netbox/circuits/urls.py

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

+ 10 - 1
netbox/dcim/urls.py

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

+ 5 - 1
netbox/dcim/views.py

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

+ 9 - 0
netbox/extras/api/nested_serializers.py

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

+ 40 - 0
netbox/extras/api/serializers.py

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

+ 3 - 0
netbox/extras/api/urls.py

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

+ 11 - 0
netbox/extras/api/views.py

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

+ 19 - 0
netbox/extras/filters.py

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

+ 16 - 1
netbox/extras/forms.py

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

+ 29 - 0
netbox/extras/migrations/0058_journalentry.py

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

+ 2 - 1
netbox/extras/models/__init__.py

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

+ 40 - 0
netbox/extras/models/models.py

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

+ 15 - 1
netbox/extras/tables.py

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

+ 5 - 0
netbox/extras/urls.py

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

+ 93 - 1
netbox/extras/views.py

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

+ 8 - 1
netbox/ipam/urls.py

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

+ 9 - 1
netbox/netbox/models.py

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

+ 2 - 1
netbox/secrets/urls.py

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

+ 5 - 0
netbox/templates/dcim/device/base.html

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

+ 30 - 0
netbox/templates/extras/object_journal.html

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

+ 6 - 0
netbox/templates/generic/object.html

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

+ 2 - 1
netbox/tenancy/urls.py

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

+ 3 - 1
netbox/virtualization/urls.py

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