Browse Source

Merge pull request #5999 from netbox-community/151-journaling

Closes #151: Add object journaling
Jeremy Stretch 4 years ago
parent
commit
9a68a61ad3

+ 5 - 0
docs/models/extras/journalentry.md

@@ -0,0 +1,5 @@
+# Journal Entries
+
+All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
+
+Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created.

+ 4 - 0
docs/release-notes/version-2.11.md

@@ -9,6 +9,10 @@ later will be required.
 
 ### New Features
 
+#### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151))
+
+NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
+
 #### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
 
 Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0.

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

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

@@ -182,6 +182,51 @@ 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)
+    kind = ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = [
+            'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
+            'created_by', 'kind', 'comments',
+        ]
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        if 'assigned_object_type' in data and 'assigned_object_id' in data:
+            try:
+                data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
+            except ObjectDoesNotExist:
+                raise serializers.ValidationError(
+                    f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_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
 #

+ 26 - 0
netbox/extras/choices.py

@@ -87,6 +87,32 @@ class ObjectChangeActionChoices(ChoiceSet):
     }
 
 
+#
+# Jounral entries
+#
+
+class JournalEntryKindChoices(ChoiceSet):
+
+    KIND_INFO = 'info'
+    KIND_SUCCESS = 'success'
+    KIND_WARNING = 'warning'
+    KIND_DANGER = 'danger'
+
+    CHOICES = (
+        (KIND_INFO, 'Info'),
+        (KIND_SUCCESS, 'Success'),
+        (KIND_WARNING, 'Warning'),
+        (KIND_DANGER, 'Danger'),
+    )
+
+    CSS_CLASSES = {
+        KIND_INFO: 'default',
+        KIND_SUCCESS: 'success',
+        KIND_WARNING: 'warning',
+        KIND_DANGER: 'danger',
+    }
+
+
 #
 # Log Levels for Reports and Scripts
 #

+ 32 - 0
netbox/extras/filters.py

@@ -21,6 +21,7 @@ __all__ = (
     'CustomFieldModelFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
+    'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'TagFilterSet',
@@ -117,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         fields = ['id', 'content_type_id', 'object_id', 'name']
 
 
+class JournalEntryFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    created = django_filters.DateTimeFromToRangeFilter()
+    assigned_object_type = ContentTypeFilter()
+    created_by_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    created_by = django_filters.ModelMultipleChoiceFilter(
+        field_name='created_by__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User (name)',
+    )
+    kind = django_filters.MultipleChoiceFilter(
+        choices=JournalEntryKindChoices
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(comments__icontains=value)
+
+
 class TagFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 74 - 2
netbox/extras/forms.py

@@ -8,12 +8,12 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
+    CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 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,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
+#
+# Journal entries
+#
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = JournalEntry
+        fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
+        widgets = {
+            'assigned_object_type': forms.HiddenInput,
+            'assigned_object_id': forms.HiddenInput,
+        }
+
+
+class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=JournalEntry.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    kind = forms.ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+    comments = forms.CharField(
+        required=False,
+        widget=forms.Textarea()
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class JournalEntryFilterForm(BootstrapMixin, forms.Form):
+    model = JournalEntry
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
+    created_after = forms.DateTimeField(
+        required=False,
+        label=_('After'),
+        widget=DateTimePicker()
+    )
+    created_before = forms.DateTimeField(
+        required=False,
+        label=_('Before'),
+        widget=DateTimePicker()
+    )
+    created_by_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        )
+    )
+    assigned_object_type_id = DynamicModelMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        required=False,
+        label=_('Object Type'),
+        widget=APISelectMultiple(
+            api_url='/api/extras/content-types/',
+        )
+    )
+    kind = forms.ChoiceField(
+        choices=add_blank_choice(JournalEntryKindChoices),
+        required=False,
+        widget=StaticSelect2()
+    )
+
+
 #
 # Change logging
 #

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

@@ -0,0 +1,31 @@
+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)),
+                ('kind', models.CharField(default='info', max_length=30)),
+                ('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={
+                'verbose_name_plural': 'journal entries',
+                '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',

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

@@ -23,6 +23,7 @@ __all__ = (
     'ExportTemplate',
     'ImageAttachment',
     'JobResult',
+    'JournalEntry',
     'Report',
     'Script',
     'Webhook',
@@ -370,6 +371,54 @@ 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
+    )
+    kind = models.CharField(
+        max_length=30,
+        choices=JournalEntryKindChoices,
+        default=JournalEntryKindChoices.KIND_INFO
+    )
+    comments = models.TextField()
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('-created',)
+        verbose_name_plural = 'journal entries'
+
+    def __str__(self):
+        return f"{self.created} - {self.get_kind_display()}"
+
+    def get_kind_class(self):
+        return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
+
+
 #
 # Custom scripts
 #

+ 45 - 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,47 @@ class ObjectChangeTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ObjectChange
         fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
+
+
+class JournalEntryTable(BaseTable):
+    pk = ToggleColumn()
+    created = tables.DateTimeColumn(
+        format=settings.SHORT_DATETIME_FORMAT
+    )
+    assigned_object_type = tables.Column(
+        verbose_name='Object type'
+    )
+    assigned_object = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='Object'
+    )
+    kind = ChoiceFieldColumn()
+    actions = ButtonsColumn(
+        model=JournalEntry,
+        buttons=('edit', 'delete')
+    )
+
+    class Meta(BaseTable.Meta):
+        model = JournalEntry
+        fields = (
+            'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
+        )
+
+
+class ObjectJournalTable(BaseTable):
+    """
+    Used for displaying a set of JournalEntries within the context of a single object.
+    """
+    created = tables.DateTimeColumn(
+        format=settings.SHORT_DATETIME_FORMAT
+    )
+    kind = ChoiceFieldColumn()
+    actions = ButtonsColumn(
+        model=JournalEntry,
+        buttons=('edit', 'delete')
+    )
+
+    class Meta(BaseTable.Meta):
+        model = JournalEntry
+        fields = ('created', 'created_by', 'kind', 'comments', 'actions')

+ 51 - 0
netbox/extras/tests/test_api.py

@@ -1,6 +1,7 @@
 import datetime
 from unittest import skipIf
 
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
@@ -309,6 +310,56 @@ class ImageAttachmentTest(
         ImageAttachment.objects.bulk_create(image_attachments)
 
 
+class JournalEntryTest(APIViewTestCases.APIViewTestCase):
+    model = JournalEntry
+    brief_fields = ['created', 'display', 'id', 'url']
+    bulk_update_data = {
+        'comments': 'Overwritten',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        user = User.objects.first()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        journal_entries = (
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Fourth entry',
+            ),
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Fifth entry',
+            ),
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Sixth entry',
+            ),
+        )
+        JournalEntry.objects.bulk_create(journal_entries)
+
+        cls.create_data = [
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'First entry',
+            },
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'Second entry',
+            },
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'Third entry',
+            },
+        ]
+
+
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     brief_fields = ['display', 'id', 'name', 'url']

+ 95 - 1
netbox/extras/tests/test_filters.py

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup
-from extras.choices import ObjectChangeActionChoices
+from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.filters import *
 from extras.models import *
 from ipam.models import IPAddress
@@ -255,6 +255,100 @@ class ImageAttachmentTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+class JournalEntryTestCase(TestCase):
+    queryset = JournalEntry.objects.all()
+    filterset = JournalEntryFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        users = (
+            User(username='Alice'),
+            User(username='Bob'),
+            User(username='Charlie'),
+        )
+        User.objects.bulk_create(users)
+
+        journal_entries = (
+            JournalEntry(
+                assigned_object=sites[0],
+                created_by=users[0],
+                kind=JournalEntryKindChoices.KIND_INFO,
+                comments='New journal entry'
+            ),
+            JournalEntry(
+                assigned_object=sites[0],
+                created_by=users[1],
+                kind=JournalEntryKindChoices.KIND_SUCCESS,
+                comments='New journal entry'
+            ),
+            JournalEntry(
+                assigned_object=sites[1],
+                created_by=users[2],
+                kind=JournalEntryKindChoices.KIND_WARNING,
+                comments='New journal entry'
+            ),
+            JournalEntry(
+                assigned_object=racks[0],
+                created_by=users[0],
+                kind=JournalEntryKindChoices.KIND_INFO,
+                comments='New journal entry'
+            ),
+            JournalEntry(
+                assigned_object=racks[0],
+                created_by=users[1],
+                kind=JournalEntryKindChoices.KIND_SUCCESS,
+                comments='New journal entry'
+            ),
+            JournalEntry(
+                assigned_object=racks[1],
+                created_by=users[2],
+                kind=JournalEntryKindChoices.KIND_WARNING,
+                comments='New journal entry'
+            ),
+        )
+        JournalEntry.objects.bulk_create(journal_entries)
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_created_by(self):
+        users = User.objects.filter(username__in=['Alice', 'Bob'])
+        params = {'created_by': [users[0].username, users[1].username]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'created_by_id': [users[0].pk, users[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_assigned_object_type(self):
+        params = {'assigned_object_type': 'dcim.site'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_assigned_object(self):
+        params = {
+            'assigned_object_type': 'dcim.site',
+            'assigned_object_id': [Site.objects.first().pk],
+        }
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_kind(self):
+        params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+
 class ConfigContextTestCase(TestCase):
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet

+ 38 - 2
netbox/extras/tests/test_views.py

@@ -3,12 +3,11 @@ import uuid
 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
 from django.urls import reverse
 
 from dcim.models import Site
 from extras.choices import ObjectChangeActionChoices
-from extras.models import ConfigContext, CustomLink, ObjectChange, Tag
+from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag
 from utilities.testing import ViewTestCases, TestCase
 
 
@@ -128,6 +127,43 @@ class ObjectChangeTestCase(TestCase):
         self.assertHttpStatus(response, 200)
 
 
+class JournalEntryTestCase(
+    # ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = JournalEntry
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        user = User.objects.create(username='User 1')
+
+        JournalEntry.objects.bulk_create((
+            JournalEntry(assigned_object=site, created_by=user, comments='First entry'),
+            JournalEntry(assigned_object=site, created_by=user, comments='Second entry'),
+            JournalEntry(assigned_object=site, created_by=user, comments='Third entry'),
+        ))
+
+        cls.form_data = {
+            'assigned_object_type': site_ct.pk,
+            'assigned_object_id': site.pk,
+            'kind': 'info',
+            'comments': 'A new entry',
+        }
+
+        cls.bulk_edit_data = {
+            'kind': 'success',
+            'comments': 'Overwritten',
+        }
+
+
 class CustomLinkTest(TestCase):
     user_permissions = ['dcim.view_site']
 

+ 8 - 0
netbox/extras/urls.py

@@ -31,6 +31,14 @@ 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/', views.JournalEntryListView.as_view(), name='journalentry_list'),
+    path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
+    path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
+    path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
+    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'),

+ 116 - 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,120 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
         return imageattachment.parent.get_absolute_url()
 
 
+#
+# Journal entries
+#
+
+class JournalEntryListView(generic.ObjectListView):
+    queryset = JournalEntry.objects.all()
+    filterset = filters.JournalEntryFilterSet
+    filterset_form = forms.JournalEntryFilterForm
+    table = tables.JournalEntryTable
+    action_buttons = ('export',)
+
+
+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):
+        if not instance.assigned_object:
+            return reverse('extras:journalentry_list')
+        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 JournalEntryBulkEditView(generic.BulkEditView):
+    queryset = JournalEntry.objects.prefetch_related('created_by')
+    filterset = filters.JournalEntryFilterSet
+    table = tables.JournalEntryTable
+    form = forms.JournalEntryBulkEditForm
+
+
+class JournalEntryBulkDeleteView(generic.BulkDeleteView):
+    queryset = JournalEntry.objects.prefetch_related('created_by')
+    filterset = filters.JournalEntryFilterSet
+    table = tables.JournalEntryTable
+
+
+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>

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

@@ -0,0 +1,32 @@
+{% 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.kind %}
+                        {% 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' %}
+    {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+{% endblock %}

+ 16 - 4
netbox/templates/generic/object.html

@@ -52,11 +52,23 @@
       <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 %}
+        {% with journal_viewname=object|viewname:'journal' %}
+          {% url journal_viewname pk=object.pk as journal_url %}
+          {% if journal_url %}
+            <li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
+              <a href="{{ journal_url }}">Journal</a>
+            </li>
+          {% endif %}
+        {% endwith %}
+      {% endif %}
       {% if perms.extras.view_objectchange %}
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-          {# TODO: Fix changelog URL resolution hack #}
-          <a href="{{ object.get_absolute_url }}changelog/">Change Log</a>
-        </li>
+        {% with changelog_viewname=object|viewname:'changelog' %}
+          {% url changelog_viewname pk=object.pk as changelog_url %}
+          <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{{ changelog_url }}">Change Log</a>
+          </li>
+        {% endwith %}
       {% endif %}
     </ul>
   {% endblock %}

+ 3 - 0
netbox/templates/inc/nav_menu.html

@@ -520,6 +520,9 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Other <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Logging</li>
+                        <li{% if not perms.extras.view_journalentry %} class="disabled"{% endif %}>
+                            <a href="{% url 'extras:journalentry_list' %}">Journal Entries</a>
+                        </li>
                         <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:objectchange_list' %}">Change Log</a>
                         </li>

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

+ 1 - 1
netbox/utilities/testing/views.py

@@ -580,7 +580,7 @@ class ViewTestCases:
             if hasattr(self.model, 'name'):
                 self.assertIn(instance1.name, content)
                 self.assertNotIn(instance2.name, content)
-            else:
+            elif hasattr(self.model, 'get_absolute_url'):
                 self.assertIn(instance1.get_absolute_url(), content)
                 self.assertNotIn(instance2.get_absolute_url(), content)
 

+ 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