mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-23 13:52:17 -06:00
Initial work on #151: Object journaling
This commit is contained in:
@@ -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
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user