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

Closes #151: Add object journaling
This commit is contained in:
Jeremy Stretch 2021-03-17 13:14:50 -04:00 committed by GitHub
commit 9a68a61ad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 743 additions and 21 deletions

View File

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

View File

@ -9,6 +9,10 @@ later will be required.
### New Features ### 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)) #### 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. 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.

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from dcim.views import CableCreateView, PathTraceView from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views from . import views
from .models import Circuit, CircuitTermination, CircuitType, Provider 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>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), 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>/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 # Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), 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>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), 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>/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'), path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView from ipam.views import ServiceEditView
from . import views from . import views
from .models import * from .models import *
@ -38,6 +38,7 @@ urlpatterns = [
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'), 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>/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>/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}), path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Locations # Locations
@ -70,6 +71,7 @@ urlpatterns = [
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), 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>/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>/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 # Racks
path('racks/', views.RackListView.as_view(), name='rack_list'), 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>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'), 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>/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}), path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers # Manufacturers
@ -104,6 +107,7 @@ urlpatterns = [
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), 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>/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>/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 # Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), 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>/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>/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>/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>/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>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'), 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>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'), 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>/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) # Console/power/interface connections (read-only)
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), 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>/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>/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>/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/<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'), 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>/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>/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>/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 # Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), 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>/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>/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>/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}), path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
] ]

View File

@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit 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.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
@ -1383,6 +1383,10 @@ class DeviceChangeLogView(ObjectChangeLogView):
base_template = 'dcim/device/base.html' base_template = 'dcim/device/base.html'
class DeviceJournalView(ObjectJournalView):
base_template = 'dcim/device/base.html'
class DeviceEditView(generic.ObjectEditView): class DeviceEditView(generic.ObjectEditView):
queryset = Device.objects.all() queryset = Device.objects.all()
model_form = forms.DeviceForm model_form = forms.DeviceForm

View File

@ -12,6 +12,7 @@ __all__ = [
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer', 'NestedImageAttachmentSerializer',
'NestedJobResultSerializer', 'NestedJobResultSerializer',
'NestedJournalEntrySerializer',
'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedTagSerializer', # Defined in netbox.api.serializers
'NestedWebhookSerializer', 'NestedWebhookSerializer',
] ]
@ -65,6 +66,14 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'image'] 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): class NestedJobResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
status = ChoiceField(choices=choices.JobResultStatusChoices) status = ChoiceField(choices=choices.JobResultStatusChoices)

View File

@ -182,6 +182,51 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
return serializer(obj.parent, context={'request': self.context['request']}).data 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 # Config contexts
# #

View File

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

View File

@ -138,6 +138,17 @@ class ImageAttachmentViewSet(ModelViewSet):
filterset_class = filters.ImageAttachmentFilterSet 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 # Config contexts
# #

View File

@ -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 # Log Levels for Reports and Scripts
# #

View File

@ -21,6 +21,7 @@ __all__ = (
'CustomFieldModelFilterSet', 'CustomFieldModelFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'TagFilterSet', 'TagFilterSet',
@ -117,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet):
fields = ['id', 'content_type_id', 'object_id', 'name'] 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): class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -8,12 +8,12 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, 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, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * 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 # Change logging
# #

View File

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

View File

@ -1,7 +1,7 @@
from .change_logging import ObjectChange from .change_logging import ObjectChange
from .configcontexts import ConfigContext, ConfigContextModel from .configcontexts import ConfigContext, ConfigContextModel
from .customfields import CustomField 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 from .tags import Tag, TaggedItem
__all__ = ( __all__ = (
@ -12,6 +12,7 @@ __all__ = (
'ExportTemplate', 'ExportTemplate',
'ImageAttachment', 'ImageAttachment',
'JobResult', 'JobResult',
'JournalEntry',
'ObjectChange', 'ObjectChange',
'Report', 'Report',
'Script', 'Script',

View File

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

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django.conf import settings from django.conf import settings
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn 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 = """ TAGGED_ITEM = """
{% if value.get_absolute_url %} {% if value.get_absolute_url %}
@ -96,3 +96,47 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ObjectChange model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') 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')

View File

@ -1,6 +1,7 @@
import datetime import datetime
from unittest import skipIf from unittest import skipIf
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
@ -309,6 +310,56 @@ class ImageAttachmentTest(
ImageAttachment.objects.bulk_create(image_attachments) 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): class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext model = ConfigContext
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']

View File

@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup 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.filters import *
from extras.models import * from extras.models import *
from ipam.models import IPAddress from ipam.models import IPAddress
@ -255,6 +255,100 @@ class ImageAttachmentTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 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): class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet filterset = ConfigContextFilterSet

View File

@ -3,12 +3,11 @@ import uuid
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices 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 from utilities.testing import ViewTestCases, TestCase
@ -128,6 +127,43 @@ class ObjectChangeTestCase(TestCase):
self.assertHttpStatus(response, 200) 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): class CustomLinkTest(TestCase):
user_permissions = ['dcim.view_site'] user_permissions = ['dcim.view_site']

View File

@ -31,6 +31,14 @@ urlpatterns = [
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), 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'), 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 # Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'), path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View from django.views.generic import View
from django_rq.queues import get_connection from django_rq.queues import get_connection
from django_tables2 import RequestConfig 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 utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables from . import filters, forms, tables
from .choices import JobResultStatusChoices 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 .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script from .scripts import get_scripts, run_script
@ -281,6 +282,120 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
return imageattachment.parent.get_absolute_url() 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 # Reports
# #

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF 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>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), 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>/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 # Route targets
path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), 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>/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>/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>/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 # RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'), 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>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), 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>/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 # Roles
path('roles/', views.RoleListView.as_view(), name='role_list'), 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>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), 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>/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>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), 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/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), 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>/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/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'), path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), 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>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), 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>/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 # Services
path('services/', views.ServiceListView.as_view(), name='service_list'), 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>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'), 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>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
] ]

View File

@ -1,6 +1,7 @@
import logging import logging
from collections import OrderedDict from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
@ -149,7 +150,14 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel):
""" """
Primary models represent real objects within the infrastructure being modeled. 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: class Meta:
abstract = True abstract = True

View File

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

View File

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

View File

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

View File

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

View File

@ -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> <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"> <ul class="dropdown-menu">
<li class="dropdown-header">Logging</li> <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 %}> <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
<a href="{% url 'extras:objectchange_list' %}">Change Log</a> <a href="{% url 'extras:objectchange_list' %}">Change Log</a>
</li> </li>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView from ipam.views import ServiceEditView
from . import views from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface 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>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), 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>/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/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
path('clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_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>/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>/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>/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'), path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces # VM interfaces