mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 02:48:38 -06:00
Merge pull request #5999 from netbox-community/151-journaling
Closes #151: Add object journaling
This commit is contained in:
commit
2479f1d95d
5
docs/models/extras/journalentry.md
Normal file
5
docs/models/extras/journalentry.md
Normal 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.
|
@ -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.
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from dcim.views import CableCreateView, PathTraceView
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
@ -18,6 +18,7 @@ urlpatterns = [
|
||||
path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
|
||||
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
|
||||
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
|
||||
path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
|
||||
|
||||
# Circuit types
|
||||
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
|
||||
@ -39,6 +40,7 @@ urlpatterns = [
|
||||
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
|
||||
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
|
||||
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
|
||||
path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
|
||||
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
|
||||
|
||||
# Circuit terminations
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
|
||||
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
|
||||
from ipam.views import ServiceEditView
|
||||
from . import views
|
||||
from .models import *
|
||||
@ -38,6 +38,7 @@ urlpatterns = [
|
||||
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
|
||||
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
|
||||
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
|
||||
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
|
||||
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
|
||||
|
||||
# Locations
|
||||
@ -70,6 +71,7 @@ urlpatterns = [
|
||||
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
||||
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
|
||||
path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
|
||||
|
||||
# Racks
|
||||
path('racks/', views.RackListView.as_view(), name='rack_list'),
|
||||
@ -82,6 +84,7 @@ urlpatterns = [
|
||||
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
|
||||
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
|
||||
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
|
||||
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
|
||||
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
|
||||
|
||||
# Manufacturers
|
||||
@ -104,6 +107,7 @@ urlpatterns = [
|
||||
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
|
||||
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
|
||||
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
|
||||
path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
|
||||
|
||||
# Console port templates
|
||||
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
|
||||
@ -210,6 +214,7 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
|
||||
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
|
||||
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
|
||||
path('devices/<int:pk>/journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
|
||||
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
@ -365,6 +370,7 @@ urlpatterns = [
|
||||
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
|
||||
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
|
||||
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
|
||||
path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
|
||||
|
||||
# Console/power/interface connections (read-only)
|
||||
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
||||
@ -381,6 +387,7 @@ urlpatterns = [
|
||||
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
|
||||
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
|
||||
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
|
||||
path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
|
||||
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
|
||||
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
|
||||
|
||||
@ -394,6 +401,7 @@ urlpatterns = [
|
||||
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
|
||||
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
|
||||
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
|
||||
path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
|
||||
|
||||
# Power feeds
|
||||
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
|
||||
@ -406,6 +414,7 @@ urlpatterns = [
|
||||
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
|
||||
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
|
||||
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
|
||||
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -8,12 +8,12 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
|
||||
CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
|
||||
CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
|
||||
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
|
||||
|
||||
|
||||
#
|
||||
@ -371,6 +371,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
|
||||
widgets = {
|
||||
'assigned_object_type': forms.HiddenInput,
|
||||
'assigned_object_id': forms.HiddenInput,
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=JournalEntry.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
required=False
|
||||
)
|
||||
comments = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
model = JournalEntry
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
created_after = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('After'),
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
created_before = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('Before'),
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
created_by_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
required=False,
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
31
netbox/extras/migrations/0058_journalentry.py
Normal file
31
netbox/extras/migrations/0058_journalentry.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
@ -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',
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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')
|
||||
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -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']
|
||||
|
||||
|
@ -31,6 +31,14 @@ urlpatterns = [
|
||||
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
# Journal entries
|
||||
path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'),
|
||||
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
|
||||
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
|
||||
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
|
||||
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
|
||||
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
|
||||
|
||||
# Change logging
|
||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
||||
|
@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection
|
||||
from django_tables2 import RequestConfig
|
||||
@ -16,7 +17,7 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
|
||||
from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports, run_report
|
||||
from .scripts import get_scripts, run_script
|
||||
|
||||
@ -281,6 +282,120 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
|
||||
#
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
class JournalEntryListView(generic.ObjectListView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
filterset = filters.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
action_buttons = ('export',)
|
||||
|
||||
|
||||
class JournalEntryEditView(generic.ObjectEditView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
model_form = forms.JournalEntryForm
|
||||
|
||||
def alter_obj(self, obj, request, args, kwargs):
|
||||
if not obj.pk:
|
||||
obj.created_by = request.user
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, instance):
|
||||
if not instance.assigned_object:
|
||||
return reverse('extras:journalentry_list')
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
class JournalEntryDeleteView(generic.ObjectDeleteView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
|
||||
def get_return_url(self, request, instance):
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
class JournalEntryBulkEditView(generic.BulkEditView):
|
||||
queryset = JournalEntry.objects.prefetch_related('created_by')
|
||||
filterset = filters.JournalEntryFilterSet
|
||||
table = tables.JournalEntryTable
|
||||
form = forms.JournalEntryBulkEditForm
|
||||
|
||||
|
||||
class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JournalEntry.objects.prefetch_related('created_by')
|
||||
filterset = filters.JournalEntryFilterSet
|
||||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
class ObjectJournalView(View):
|
||||
"""
|
||||
Show all journal entries for an object.
|
||||
|
||||
base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
|
||||
"""
|
||||
base_template = None
|
||||
|
||||
def get(self, request, model, **kwargs):
|
||||
|
||||
# Handle QuerySet restriction of parent object if needed
|
||||
if hasattr(model.objects, 'restrict'):
|
||||
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
|
||||
else:
|
||||
obj = get_object_or_404(model, **kwargs)
|
||||
|
||||
# Gather all changes for this object (and its related objects)
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
|
||||
assigned_object_type=content_type,
|
||||
assigned_object_id=obj.pk
|
||||
)
|
||||
journalentry_table = tables.ObjectJournalTable(
|
||||
data=journalentries,
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Apply the request context
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(journalentry_table)
|
||||
|
||||
if request.user.has_perm('extras.add_journalentry'):
|
||||
form = forms.JournalEntryForm(
|
||||
initial={
|
||||
'assigned_object_type': ContentType.objects.get_for_model(obj),
|
||||
'assigned_object_id': obj.pk
|
||||
}
|
||||
)
|
||||
else:
|
||||
form = None
|
||||
|
||||
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
|
||||
# fall back to using base.html.
|
||||
if self.base_template is None:
|
||||
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
|
||||
# TODO: This can be removed once an object view has been established for every model.
|
||||
try:
|
||||
template.loader.get_template(self.base_template)
|
||||
except template.TemplateDoesNotExist:
|
||||
self.base_template = 'base.html'
|
||||
|
||||
return render(request, 'extras/object_journal.html', {
|
||||
'object': obj,
|
||||
'form': form,
|
||||
'table': journalentry_table,
|
||||
'base_template': self.base_template,
|
||||
'active_tab': 'journal',
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
|
||||
path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
|
||||
path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
|
||||
path('vrfs/<int:pk>/journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}),
|
||||
|
||||
# Route targets
|
||||
path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'),
|
||||
@ -28,6 +29,7 @@ urlpatterns = [
|
||||
path('route-targets/<int:pk>/edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'),
|
||||
path('route-targets/<int:pk>/delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'),
|
||||
path('route-targets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}),
|
||||
path('route-targets/<int:pk>/journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}),
|
||||
|
||||
# RIRs
|
||||
path('rirs/', views.RIRListView.as_view(), name='rir_list'),
|
||||
@ -49,6 +51,7 @@ urlpatterns = [
|
||||
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
|
||||
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
|
||||
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
|
||||
path('aggregates/<int:pk>/journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}),
|
||||
|
||||
# Roles
|
||||
path('roles/', views.RoleListView.as_view(), name='role_list'),
|
||||
@ -70,6 +73,7 @@ urlpatterns = [
|
||||
path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
|
||||
path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
|
||||
path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
|
||||
path('prefixes/<int:pk>/journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}),
|
||||
path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
|
||||
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
|
||||
|
||||
@ -81,6 +85,7 @@ urlpatterns = [
|
||||
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
|
||||
path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
|
||||
path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
|
||||
path('ip-addresses/<int:pk>/journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}),
|
||||
path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
|
||||
path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
|
||||
path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
|
||||
@ -109,6 +114,7 @@ urlpatterns = [
|
||||
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
|
||||
path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}),
|
||||
|
||||
# Services
|
||||
path('services/', views.ServiceListView.as_view(), name='service_list'),
|
||||
@ -119,5 +125,6 @@ urlpatterns = [
|
||||
path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
|
||||
path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
|
||||
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
|
||||
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
|
||||
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import Secret, SecretRole
|
||||
|
||||
@ -27,5 +27,6 @@ urlpatterns = [
|
||||
path('secrets/<int:pk>/edit/', views.SecretEditView.as_view(), name='secret_edit'),
|
||||
path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
|
||||
path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
|
||||
path('secrets/<int:pk>/journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': Secret}),
|
||||
|
||||
]
|
||||
|
@ -147,6 +147,11 @@
|
||||
<a href="{% url 'dcim:device_configcontext' pk=object.pk %}">Config Context</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.extras.view_journalentry %}
|
||||
<li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_journal' pk=object.pk %}">Journal</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_changelog' pk=object.pk %}">Change Log</a>
|
||||
|
32
netbox/templates/extras/object_journal.html
Normal file
32
netbox/templates/extras/object_journal.html
Normal 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 %}
|
@ -52,11 +52,23 @@
|
||||
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
|
||||
<a href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
|
||||
</li>
|
||||
{% if perms.extras.view_journalentry %}
|
||||
{% with journal_viewname=object|viewname:'journal' %}
|
||||
{% url journal_viewname pk=object.pk as journal_url %}
|
||||
{% if journal_url %}
|
||||
<li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
|
||||
<a href="{{ journal_url }}">Journal</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
{# TODO: Fix changelog URL resolution hack #}
|
||||
<a href="{{ object.get_absolute_url }}changelog/">Change Log</a>
|
||||
</li>
|
||||
{% with changelog_viewname=object|viewname:'changelog' %}
|
||||
{% url changelog_viewname pk=object.pk as changelog_url %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{{ changelog_url }}">Change Log</a>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
@ -520,6 +520,9 @@
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Other <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown-header">Logging</li>
|
||||
<li{% if not perms.extras.view_journalentry %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:journalentry_list' %}">Journal Entries</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:objectchange_list' %}">Change Log</a>
|
||||
</li>
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
@ -27,5 +27,6 @@ urlpatterns = [
|
||||
path('tenants/<int:pk>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
|
||||
path('tenants/<int:pk>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
|
||||
path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
|
||||
path('tenants/<int:pk>/journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
|
||||
|
||||
]
|
||||
|
@ -580,7 +580,7 @@ class ViewTestCases:
|
||||
if hasattr(self.model, 'name'):
|
||||
self.assertIn(instance1.name, content)
|
||||
self.assertNotIn(instance2.name, content)
|
||||
else:
|
||||
elif hasattr(self.model, 'get_absolute_url'):
|
||||
self.assertIn(instance1.get_absolute_url(), content)
|
||||
self.assertNotIn(instance2.get_absolute_url(), content)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from ipam.views import ServiceEditView
|
||||
from . import views
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
@ -38,6 +38,7 @@ urlpatterns = [
|
||||
path('clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
|
||||
path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
|
||||
path('clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
|
||||
path('clusters/<int:pk>/journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}),
|
||||
path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
|
||||
path('clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
|
||||
|
||||
@ -52,6 +53,7 @@ urlpatterns = [
|
||||
path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
|
||||
path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
|
||||
path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
|
||||
path('virtual-machines/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}),
|
||||
path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
|
||||
|
||||
# VM interfaces
|
||||
|
Loading…
Reference in New Issue
Block a user