diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 964d5d59ca..0b47b4b2c3 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 @@ 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 @@ 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 290049010b..e7c29ae9fa 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 da733843cc..4ca9b8498c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView +from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic @@ -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 1e9b3caee9..4acde31ab7 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -12,6 +12,7 @@ 'NestedExportTemplateSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', + 'NestedJournalEntrySerializer', 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -65,6 +66,14 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'image'] +class NestedJournalEntrySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + + class Meta: + model = models.JournalEntry + fields = ['id', 'url', 'display', 'created'] + + class NestedJobResultSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') status = ChoiceField(choices=choices.JobResultStatusChoices) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 3a8e0014f2..100edc458e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -182,6 +182,46 @@ def get_parent(self, obj): return serializer(obj.parent, context={'request': self.context['request']}).data +# +# Journal entries +# + +class JournalEntrySerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = JournalEntry + fields = [ + 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', + 'created_by', 'comments', + ] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + # Enforce model validation + super().validate(data) + + return data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data + + # # Config contexts # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index a76f461fdc..565f2cdc74 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -23,6 +23,9 @@ # Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) +# Journal entries +router.register('journal-entries', views.JournalEntryViewSet) + # Config contexts router.register('config-contexts', views.ConfigContextViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1793d2fa50..cee5146a67 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/filters.py b/netbox/extras/filters.py index 72e6e372e0..7893b050f9 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -21,6 +21,7 @@ 'CustomFieldModelFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', + 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -117,6 +118,24 @@ class Meta: fields = ['id', 'content_type_id', 'object_id', 'name'] +class JournalEntryFilterSet(BaseFilterSet): + assigned_object_type = ContentTypeFilter() + # created_by_id = django_filters.ModelMultipleChoiceFilter( + # queryset=User.objects.all(), + # label='User (ID)', + # ) + # created_by = django_filters.ModelMultipleChoiceFilter( + # field_name='user__username', + # queryset=User.objects.all(), + # to_field_name='username', + # label='User (name)', + # ) + + class Meta: + model = JournalEntry + fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created'] + + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 9b65645ad9..f6a960bd91 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -13,7 +13,7 @@ ) from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag +from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag # @@ -371,6 +371,21 @@ class Meta: ] +# +# Journal entries +# + +class JournalEntryForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = JournalEntry + fields = ['assigned_object_type', 'assigned_object_id', 'comments'] + widgets = { + 'assigned_object_type': forms.HiddenInput, + 'assigned_object_id': forms.HiddenInput, + } + + # # Change logging # diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py new file mode 100644 index 0000000000..a3a83cb788 --- /dev/null +++ b/netbox/extras/migrations/0058_journalentry.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0057_customlink_rename_fields'), + ] + + operations = [ + migrations.CreateModel( + name='JournalEntry', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('assigned_object_id', models.PositiveIntegerField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('comments', models.TextField()), + ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 2d6feb2980..84676453f3 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 @@ 'ExportTemplate', 'ImageAttachment', 'JobResult', + 'JournalEntry', 'ObjectChange', 'Report', 'Script', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 60dc4d861c..6970265e97 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -23,6 +23,7 @@ 'ExportTemplate', 'ImageAttachment', 'JobResult', + 'JournalEntry', 'Report', 'Script', 'Webhook', @@ -370,6 +371,45 @@ def size(self): return None +# +# Journal entries +# + +class JournalEntry(BigIDModel): + """ + A historical remark concerning an object; collectively, these form an object's journal. The journal is used to + preserve historical context around an object, and complements NetBox's built-in change logging. For example, you + might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. + """ + assigned_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + assigned_object_id = models.PositiveIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) + created = models.DateTimeField( + auto_now_add=True + ) + created_by = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True + ) + comments = models.TextField() + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('-created',) + + def __str__(self): + return f"{self.created}" + + # # Custom scripts # diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7aeddb48fb..ff3befc11a 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -2,7 +2,7 @@ from django.conf import settings from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn -from .models import ConfigContext, ObjectChange, Tag, TaggedItem +from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem TAGGED_ITEM = """ {% if value.get_absolute_url %} @@ -96,3 +96,17 @@ class ObjectChangeTable(BaseTable): class Meta(BaseTable.Meta): model = ObjectChange fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') + + +class ObjectJournalTable(BaseTable): + created = tables.DateTimeColumn( + format=settings.SHORT_DATETIME_FORMAT + ) + actions = ButtonsColumn( + model=JournalEntry, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ('created', 'created_by', 'comments', 'actions') diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index d2f0a2eb28..1b28eea841 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,11 @@ 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/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), + 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 48dcb81fd1..b41f09af35 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,6 +4,7 @@ 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.views import ContentTypePermissionRequiredMixin from . import filters, forms, tables from .choices import JobResultStatusChoices -from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem +from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem from .reports import get_report, get_reports, run_report from .scripts import get_scripts, run_script @@ -281,6 +282,97 @@ def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() +# +# Journal entries +# + +class JournalEntryEditView(generic.ObjectEditView): + queryset = JournalEntry.objects.all() + model_form = forms.JournalEntryForm + + def alter_obj(self, obj, request, args, kwargs): + if not obj.pk: + obj.created_by = request.user + return obj + + def get_return_url(self, request, instance): + obj = instance.assigned_object + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal' + return reverse(viewname, kwargs={'pk': obj.pk}) + + +class JournalEntryDeleteView(generic.ObjectDeleteView): + queryset = JournalEntry.objects.all() + + def get_return_url(self, request, instance): + obj = instance.assigned_object + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal' + return reverse(viewname, kwargs={'pk': obj.pk}) + + +class ObjectJournalView(View): + """ + Show all journal entries for an object. + + base_template: The name of the template to extend. If not provided, "/.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 07bd2c69fc..4b576d21ff 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 ce54248176..0e66ba90d3 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 7352a7de0c..7c72b848c3 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 @@ 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 72a40134bd..b9409472b0 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 %} + + {% endif %} {% if perms.extras.view_objectchange %}