This commit is contained in:
Jeremy Stretch 2025-07-22 14:58:03 -04:00 committed by GitHub
commit 7c811d5eb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 163 additions and 51 deletions

View File

@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer):
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', 'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
'postchange_data',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -186,7 +186,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(user_name__icontains=value) | Q(user_name__icontains=value) |
Q(object_repr__icontains=value) Q(object_repr__icontains=value) |
Q(message__icontains=value)
) )

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_job_log_entries'),
]
operations = [
migrations.AddField(
model_name='objectchange',
name='message',
field=models.CharField(blank=True, editable=False, max_length=200),
),
]

View File

@ -82,6 +82,12 @@ class ObjectChange(models.Model):
max_length=200, max_length=200,
editable=False editable=False
) )
message = models.CharField(
verbose_name=_('message'),
max_length=200,
editable=False,
blank=True
)
prechange_data = models.JSONField( prechange_data = models.JSONField(
verbose_name=_('pre-change data'), verbose_name=_('pre-change data'),
editable=False, editable=False,

View File

@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable):
template_code=OBJECTCHANGE_REQUEST_ID, template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name=_('Request ID') verbose_name=_('Request ID')
) )
message = tables.Column(
verbose_name=_('Message'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=() actions=()
) )
@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable):
model = ObjectChange model = ObjectChange
fields = ( fields = (
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id', 'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions', 'message', 'actions',
)
default_columns = (
'pk', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'message', 'actions',
) )

View File

@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
class ObjectChangeTestCase(TestCase, BaseFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet filterset = ObjectChangeFilterSet
ignore_fields = ('prechange_data', 'postchange_data') ignore_fields = ('message', 'prechange_data', 'postchange_data')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -11,7 +11,7 @@ from extras.models import CustomField, Tag
from utilities.forms import BulkEditForm, CSVModelForm from utilities.forms import BulkEditForm, CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import CheckLastUpdatedMixin from utilities.forms.mixins import CheckLastUpdatedMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from .mixins import ChangeLoggingMixin, CustomFieldsMixin, SavedFiltersMixin, TagsMixin
__all__ = ( __all__ = (
'NetBoxModelForm', 'NetBoxModelForm',
@ -21,7 +21,7 @@ __all__ = (
) )
class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): class NetBoxModelForm(ChangeLoggingMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm):
""" """
Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)
class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm): class NetBoxModelBulkEditForm(ChangeLoggingMixin, CustomFieldsMixin, BulkEditForm):
""" """
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
fields and adding/removing tags. fields and adding/removing tags.

View File

@ -7,12 +7,23 @@ from extras.models import *
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = ( __all__ = (
'ChangeLoggingMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
'SavedFiltersMixin', 'SavedFiltersMixin',
'TagsMixin', 'TagsMixin',
) )
class ChangeLoggingMixin(forms.Form):
"""
Adds an optional field for recording a message on the resulting changelog record(s).
"""
changelog_message = forms.CharField(
required=False,
max_length=200
)
class CustomFieldsMixin: class CustomFieldsMixin:
""" """
Extend a Form to include custom field support. Extend a Form to include custom field support.

View File

@ -63,6 +63,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
null=True null=True
) )
_changelog_message = None
class Meta: class Meta:
abstract = True abstract = True
@ -103,7 +105,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
objectchange = ObjectChange( objectchange = ObjectChange(
changed_object=self, changed_object=self,
object_repr=str(self)[:200], object_repr=str(self)[:200],
action=action action=action,
message=self._changelog_message or '',
) )
if hasattr(self, '_prechange_snapshot'): if hasattr(self, '_prechange_snapshot'):
objectchange.prechange_data = self._prechange_snapshot objectchange.prechange_data = self._prechange_snapshot

View File

@ -21,6 +21,7 @@ from core.models import ObjectType
from core.signals import clear_events from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices from extras.choices import CustomFieldUIEditableChoices
from extras.models import CustomField, ExportTemplate from extras.models import CustomField, ExportTemplate
from netbox.forms.mixins import ChangeLoggingMixin
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
@ -423,7 +424,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
} if prefetch_ids else {} } if prefetch_ids else {}
for i, record in enumerate(records, start=1): for i, record in enumerate(records, start=1):
instance = None
object_id = int(record.pop('id')) if record.get('id') else None object_id = int(record.pop('id')) if record.get('id') else None
# Determine whether this object is being created or updated # Determine whether this object is being created or updated
@ -439,6 +439,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
instance.snapshot() instance.snapshot()
else: else:
instance = self.queryset.model()
# For newly created objects, apply any default custom field values # For newly created objects, apply any default custom field values
custom_fields = CustomField.objects.filter( custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self.queryset.model), object_types=ContentType.objects.get_for_model(self.queryset.model),
@ -449,6 +451,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if field_name not in record: if field_name not in record:
record[field_name] = cf.default record[field_name] = cf.default
# Record changelog message (if any)
instance._changelog_message = form.cleaned_data.pop('changelog_message', '')
# Instantiate the model form for the object # Instantiate the model form for the object
model_form_kwargs = { model_form_kwargs = {
'data': record, 'data': record,
@ -622,6 +627,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if hasattr(obj, 'snapshot'): if hasattr(obj, 'snapshot'):
obj.snapshot() obj.snapshot()
# Attach the changelog message (if any) to the object
obj._changelog_message = form.cleaned_data.get('changelog_message')
# Update standard fields. If a field is listed in _nullify, delete its value. # Update standard fields. If a field is listed in _nullify, delete its value.
for name, model_field in model_fields.items(): for name, model_field in model_fields.items():
# Handle nullification # Handle nullification
@ -892,7 +900,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
""" """
Provide a standard bulk delete form if none has been specified for the view Provide a standard bulk delete form if none has been specified for the view
""" """
class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm): class BulkDeleteForm(BackgroundJobMixin, ChangeLoggingMixin, ConfirmationForm):
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
return BulkDeleteForm return BulkDeleteForm
@ -939,9 +947,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
try: try:
with transaction.atomic(using=router.db_for_write(model)): with transaction.atomic(using=router.db_for_write(model)):
for obj in queryset: for obj in queryset:
# Take a snapshot of change-logged models # Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'): if hasattr(obj, 'snapshot'):
obj.snapshot() obj.snapshot()
# Attach the changelog message (if any) to the object
obj._changelog_message = form.cleaned_data.get('changelog_message')
# Delete the object
obj.delete() obj.delete()
except (ProtectedError, RestrictedError) as e: except (ProtectedError, RestrictedError) as e:

View File

@ -19,7 +19,7 @@ from netbox.object_actions import (
) )
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import DeleteForm, restrict_form_fields
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields from utilities.querydict import normalize_querydict, prepare_cloned_fields
@ -288,6 +288,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
# Record changelog message (if any)
obj._changelog_message = form.cleaned_data.pop('changelog_message', '')
try: try:
with transaction.atomic(using=router.db_for_write(model)): with transaction.atomic(using=router.db_for_write(model)):
object_created = form.instance.pk is None object_created = form.instance.pk is None
@ -422,7 +425,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
request: The current request request: The current request
""" """
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
form = ConfirmationForm(initial=request.GET) form = DeleteForm(initial=request.GET)
try: try:
dependent_objects = self._get_dependent_objects(obj) dependent_objects = self._get_dependent_objects(obj)
@ -461,23 +464,25 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
""" """
logger = logging.getLogger('netbox.views.ObjectDeleteView') logger = logging.getLogger('netbox.views.ObjectDeleteView')
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
form = ConfirmationForm(request.POST) form = DeleteForm(request.POST)
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
# Record changelog message (if any)
obj._changelog_message = form.cleaned_data.pop('changelog_message', '')
# Delete the object
try: try:
obj.delete() obj.delete()
except (ProtectedError, RestrictedError) as e: except (ProtectedError, RestrictedError) as e:
logger.info(f"Caught {type(e)} while attempting to delete objects") logger.info(f"Caught {type(e)} while attempting to delete objects")
handle_protectederror([obj], request, e) handle_protectederror([obj], request, e)
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
except AbortRequest as e: except AbortRequest as e:
logger.debug(e.message) logger.debug(e.message)
messages.error(request, mark_safe(e.message)) messages.error(request, mark_safe(e.message))

View File

@ -64,10 +64,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Message" %}</th>
<td>
{{ object.message|placeholder }}
</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Request ID" %}</th> <th scope="row">{% trans "Request ID" %}</th>
<td> <td>
{{ object.request_id }} <a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}">{{ object.request_id }}</a>
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -66,7 +66,10 @@ Context:
{% endfor %} {% endfor %}
{# Meta fields #} {# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3"> <div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% if form.changelog_message %}
{% render_field form.changelog_message %}
{% endif %}
{% render_field form.background_job %} {% render_field form.background_job %}
</div> </div>

View File

@ -103,7 +103,10 @@ Context:
{% endif %} {% endif %}
{# Meta fields #} {# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3"> <div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% if form.changelog_message %}
{% render_field form.changelog_message %}
{% endif %}
{% render_field form.background_job %} {% render_field form.background_job %}
</div> </div>

View File

@ -42,32 +42,31 @@ Context:
{# Data Import Form #} {# Data Import Form #}
<div class="tab-pane show active" id="import-form" role="tabpanel" aria-labelledby="import-form-tab"> <div class="tab-pane show active" id="import-form" role="tabpanel" aria-labelledby="import-form-tab">
<div class="row"> <div class="col col-md-12 col-lg-10 offset-lg-1">
<div class="col col-md-12 col-lg-10 offset-lg-1"> <form action="" method="post" enctype="multipart/form-data" class="form">
<form action="" method="post" enctype="multipart/form-data" class="form"> {% csrf_token %}
{% csrf_token %} <input type="hidden" name="import_method" value="direct" />
<input type="hidden" name="import_method" value="direct" />
{# Form fields #} {# Form fields #}
{% render_field form.data %} {% render_field form.data %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{# Meta fields #} {# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3"> <div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %} {% render_field form.changelog_message %}
{% render_field form.background_job %}
</div>
<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
{% endif %}
<button type="submit" name="data_submit" class="btn btn-primary">{% trans "Submit" %}</button>
</div> </div>
</div>
<div class="form-group"> </form>
<div class="col col-md-12 text-end">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
{% endif %}
<button type="submit" name="data_submit" class="btn btn-primary">{% trans "Submit" %}</button>
</div>
</div>
</form>
</div>
</div> </div>
</div> </div>
@ -83,6 +82,11 @@ Context:
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.changelog_message %}
</div>
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}
@ -110,6 +114,7 @@ Context:
{# Meta fields #} {# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3"> <div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.changelog_message %}
{% render_field form.background_job %} {% render_field form.background_job %}
</div> </div>

View File

@ -20,7 +20,7 @@ Context:
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="modal" tabindex="-1" style="display: block; position: static"> <div class="modal modal-lg" tabindex="-1" style="display: block; position: static">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content border-1 border-danger"> <div class="modal-content border-1 border-danger">
{% include 'htmx/delete_form.html' %} {% include 'htmx/delete_form.html' %}

View File

@ -2,10 +2,16 @@
{% load i18n %} {% load i18n %}
<form action="{{ form_url }}" method="post"> <form action="{{ form_url }}" method="post">
{# Render hidden fields #}
{% csrf_token %} {% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">{% trans "Confirm Deletion" %}</h5> <h5 class="modal-title">{% trans "Confirm Deletion" %}</h5>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
@ -16,10 +22,10 @@
<p> <p>
{% trans "The following objects will be deleted as a result of this action." %} {% trans "The following objects will be deleted as a result of this action." %}
</p> </p>
<div class="accordion" id="deleteAccordion"> <div class="accordion mb-3" id="deleteAccordion">
{% for model, instances in dependent_objects.items %} {% for model, instances in dependent_objects.items %}
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="deleteheading{{ forloop.counter }}"> <h2 class="accordion-header h4" id="deleteheading{{ forloop.counter }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}">
{% with object_count=instances|length %} {% with object_count=instances|length %}
{{ object_count }} {{ object_count }}
@ -46,8 +52,15 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% render_form form %}
{# Meta fields #}
{% if form.changelog_message %}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3">
{% render_field form.changelog_message %}
</div>
{% endif %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
{% if return_url %} {% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a> <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>

View File

@ -28,6 +28,13 @@
</div> </div>
{% endif %} {% endif %}
{# Meta fields #}
{% if form.changelog_message %}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% render_field form.changelog_message %}
</div>
{% endif %}
{% else %} {% else %}
{# Render all fields in a single group #} {# Render all fields in a single group #}

View File

@ -1,4 +1,4 @@
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true"> <div class="modal modal-lg fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog{% if size %} modal-{{ size }}{% endif %}"> <div class="modal-dialog{% if size %} modal-{{ size }}{% endif %}">
<div class="modal-content" id="htmx-modal-content"> <div class="modal-content" id="htmx-modal-content">
{# Dynamic content goes here #} {# Dynamic content goes here #}

View File

@ -8,12 +8,13 @@ from django.utils.translation import gettext as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
from netbox.forms.mixins import ChangeLoggingMixin
from utilities.constants import CSV_DELIMITERS from utilities.constants import CSV_DELIMITERS
from utilities.forms.mixins import BackgroundJobMixin from utilities.forms.mixins import BackgroundJobMixin
from utilities.forms.utils import parse_csv from utilities.forms.utils import parse_csv
class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form): class BulkImportForm(ChangeLoggingMixin, BackgroundJobMixin, SyncedDataMixin, forms.Form):
import_method = forms.ChoiceField( import_method = forms.ChoiceField(
choices=ImportMethodChoices, choices=ImportMethodChoices,
required=False required=False

View File

@ -10,6 +10,7 @@ __all__ = (
'BulkRenameForm', 'BulkRenameForm',
'ConfirmationForm', 'ConfirmationForm',
'CSVModelForm', 'CSVModelForm',
'DeleteForm',
'FilterForm', 'FilterForm',
'TableConfigForm', 'TableConfigForm',
) )
@ -30,6 +31,16 @@ class ConfirmationForm(forms.Form):
) )
class DeleteForm(ConfirmationForm):
"""
Confirm the deletion of an object, optionally providing a changelog message.
"""
changelog_message = forms.CharField(
required=False,
max_length=200
)
class BulkEditForm(BackgroundJobMixin, forms.Form): class BulkEditForm(BackgroundJobMixin, forms.Form):
""" """
Provides bulk edit support for objects. Provides bulk edit support for objects.