mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Initial work on #151: Object journaling
This commit is contained in:
parent
e97adcb614
commit
1f1a62da67
@ -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,46 @@ 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)
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = [
|
||||
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
|
||||
'created_by', 'comments',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate that the parent object exists
|
||||
try:
|
||||
data['content_type'].get_object_for_this_type(id=data['object_id'])
|
||||
except ObjectDoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
"Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
|
||||
)
|
||||
|
||||
# Enforce model validation
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_assigned_object(self, instance):
|
||||
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(instance.assigned_object, context=context).data
|
||||
|
||||
|
||||
#
|
||||
# Config contexts
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -21,6 +21,7 @@ __all__ = (
|
||||
'CustomFieldModelFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'TagFilterSet',
|
||||
@ -117,6 +118,24 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
fields = ['id', 'content_type_id', 'object_id', 'name']
|
||||
|
||||
|
||||
class JournalEntryFilterSet(BaseFilterSet):
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
# created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||
# queryset=User.objects.all(),
|
||||
# label='User (ID)',
|
||||
# )
|
||||
# created_by = django_filters.ModelMultipleChoiceFilter(
|
||||
# field_name='user__username',
|
||||
# queryset=User.objects.all(),
|
||||
# to_field_name='username',
|
||||
# label='User (name)',
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created']
|
||||
|
||||
|
||||
class TagFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -13,7 +13,7 @@ from utilities.forms import (
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
|
||||
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
|
||||
|
||||
|
||||
#
|
||||
@ -371,6 +371,21 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = ['assigned_object_type', 'assigned_object_id', 'comments']
|
||||
widgets = {
|
||||
'assigned_object_type': forms.HiddenInput,
|
||||
'assigned_object_id': forms.HiddenInput,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
29
netbox/extras/migrations/0058_journalentry.py
Normal file
29
netbox/extras/migrations/0058_journalentry.py
Normal file
@ -0,0 +1,29 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0057_customlink_rename_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JournalEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('assigned_object_id', models.PositiveIntegerField()),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('comments', models.TextField()),
|
||||
('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created',),
|
||||
},
|
||||
),
|
||||
]
|
@ -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,45 @@ 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
|
||||
)
|
||||
comments = models.TextField()
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created',)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.created}"
|
||||
|
||||
|
||||
#
|
||||
# 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,17 @@ class ObjectChangeTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ObjectChange
|
||||
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
|
||||
|
||||
class ObjectJournalTable(BaseTable):
|
||||
created = tables.DateTimeColumn(
|
||||
format=settings.SHORT_DATETIME_FORMAT
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=JournalEntry,
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = JournalEntry
|
||||
fields = ('created', 'created_by', 'comments', 'actions')
|
||||
|
@ -31,6 +31,11 @@ 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/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
|
||||
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,97 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
|
||||
#
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
class JournalEntryEditView(generic.ObjectEditView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
model_form = forms.JournalEntryForm
|
||||
|
||||
def alter_obj(self, obj, request, args, kwargs):
|
||||
if not obj.pk:
|
||||
obj.created_by = request.user
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, instance):
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
class JournalEntryDeleteView(generic.ObjectDeleteView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
|
||||
def get_return_url(self, request, instance):
|
||||
obj = instance.assigned_object
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
|
||||
return reverse(viewname, kwargs={'pk': obj.pk})
|
||||
|
||||
|
||||
class ObjectJournalView(View):
|
||||
"""
|
||||
Show all journal entries for an object.
|
||||
|
||||
base_template: The name of the template to extend. If not provided, "<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>
|
||||
|
30
netbox/templates/extras/object_journal.html
Normal file
30
netbox/templates/extras/object_journal.html
Normal file
@ -0,0 +1,30 @@
|
||||
{% 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.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' %}
|
||||
{% endblock %}
|
@ -52,6 +52,12 @@
|
||||
<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 %}
|
||||
<li role="presentation"{% if active_tab == 'journal' %} class="active"{% endif %}>
|
||||
{# TODO: Fix journal URL resolution hack #}
|
||||
<a href="{{ object.get_absolute_url }}journal/">Journal</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
{# TODO: Fix changelog URL resolution hack #}
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from . import views
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
@ -27,5 +27,6 @@ urlpatterns = [
|
||||
path('tenants/<int:pk>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
|
||||
path('tenants/<int:pk>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
|
||||
path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
|
||||
path('tenants/<int:pk>/journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
|
||||
|
||||
]
|
||||
|
@ -1,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