Initial work on #151: Object journaling

This commit is contained in:
Jeremy Stretch
2021-03-16 15:00:08 -04:00
parent 1445efc638
commit 4ffd2ba841
23 changed files with 365 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

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

View File

@@ -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',

View File

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

View File

@@ -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')

View File

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

View File

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