diff --git a/docs/models/extras/journalentry.md b/docs/models/extras/journalentry.md new file mode 100644 index 000000000..c95340a01 --- /dev/null +++ b/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. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 973b00f1c..6bfdd414b 100644 --- a/docs/release-notes/version-2.11.md +++ b/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. diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 964d5d59c..0b47b4b2c 100644 --- a/netbox/circuits/urls.py +++ b/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//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//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//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 290049010..e7c29ae9f 100644 --- a/netbox/dcim/urls.py +++ b/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//edit/', views.SiteEditView.as_view(), name='site_edit'), path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Locations @@ -70,6 +71,7 @@ urlpatterns = [ path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path('rack-reservations//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//edit/', views.RackEditView.as_view(), name='rack_edit'), path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers @@ -104,6 +107,7 @@ urlpatterns = [ path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path('device-types//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//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices//changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), @@ -365,6 +370,7 @@ urlpatterns = [ path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path('cables//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//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), @@ -394,6 +401,7 @@ urlpatterns = [ path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + path('power-panels//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//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index da733843c..4ca9b8498 100644 --- a/netbox/dcim/views.py +++ b/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 diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 1e9b3caee..4acde31ab 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/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) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 3a8e0014f..d1eea15ee 100644 --- a/netbox/extras/api/serializers.py +++ b/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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index a76f461fd..565f2cdc7 100644 --- a/netbox/extras/api/urls.py +++ b/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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1793d2fa5..cee5146a6 100644 --- a/netbox/extras/api/views.py +++ b/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 # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 47c3a1039..33c70f70d 100644 --- a/netbox/extras/choices.py +++ b/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 # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 72e6e372e..3ac25eec4 100644 --- a/netbox/extras/filters.py +++ b/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', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 9b65645ad..b12ecc11d 100644 --- a/netbox/extras/forms.py +++ b/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 # diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py new file mode 100644 index 000000000..14be2a50d --- /dev/null +++ b/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',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 2d6feb298..84676453f 100644 --- a/netbox/extras/models/__init__.py +++ b/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', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 60dc4d861..61d06d264 100644 --- a/netbox/extras/models/models.py +++ b/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 # diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7aeddb48f..e034b915a 100644 --- a/netbox/extras/tables.py +++ b/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') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 0bd4aea02..da0e6dbb2 100644 --- a/netbox/extras/tests/test_api.py +++ b/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'] diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 2d5eeca6f..5e1b0401d 100644 --- a/netbox/extras/tests/test_filters.py +++ b/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 diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 703072601..286fa7613 100644 --- a/netbox/extras/tests/test_views.py +++ b/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'] diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index d2f0a2eb2..ee435307d 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,14 @@ urlpatterns = [ path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments//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//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), + path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), + # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', views.ObjectChangeView.as_view(), name='objectchange'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 48dcb81fd..976c13760 100644 --- a/netbox/extras/views.py +++ b/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, "/.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 "/.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 # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 07bd2c69f..4b576d21f 100644 --- a/netbox/ipam/urls.py +++ b/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//edit/', views.VRFEditView.as_view(), name='vrf_edit'), path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path('vrfs//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//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), + path('route-targets//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//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path('aggregates//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//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path('prefixes//journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}), path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), path('prefixes//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//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path('ip-addresses//journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}), path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), @@ -109,6 +114,7 @@ urlpatterns = [ path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path('vlans//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//edit/', views.ServiceEditView.as_view(), name='service_edit'), path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), ] diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index ce5424817..0e66ba90d 100644 --- a/netbox/netbox/models.py +++ b/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 diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 7352a7de0..7c72b848c 100644 --- a/netbox/secrets/urls.py +++ b/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//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path('secrets//journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': Secret}), ] diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 72a40134b..b9409472b 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -147,6 +147,11 @@ Config Context {% endif %} + {% if perms.extras.view_journalentry %} + + {% endif %} {% if perms.extras.view_objectchange %} + {% if perms.extras.view_journalentry %} + {% with journal_viewname=object|viewname:'journal' %} + {% url journal_viewname pk=object.pk as journal_url %} + {% if journal_url %} + + {% endif %} + {% endwith %} + {% endif %} {% if perms.extras.view_objectchange %} - + {% with changelog_viewname=object|viewname:'changelog' %} + {% url changelog_viewname pk=object.pk as changelog_url %} + + {% endwith %} {% endif %} {% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 621251ffb..4fff16141 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -520,6 +520,9 @@