mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 01:48:38 -06:00
Merge f600429b7e
into 6df0a02d8d
This commit is contained in:
commit
7c811d5eb2
@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
||||
model = ObjectChange
|
||||
fields = [
|
||||
'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))
|
||||
|
@ -186,7 +186,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(user_name__icontains=value) |
|
||||
Q(object_repr__icontains=value)
|
||||
Q(object_repr__icontains=value) |
|
||||
Q(message__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
|
16
netbox/core/migrations/0017_objectchange_message.py
Normal file
16
netbox/core/migrations/0017_objectchange_message.py
Normal 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),
|
||||
),
|
||||
]
|
@ -82,6 +82,12 @@ class ObjectChange(models.Model):
|
||||
max_length=200,
|
||||
editable=False
|
||||
)
|
||||
message = models.CharField(
|
||||
verbose_name=_('message'),
|
||||
max_length=200,
|
||||
editable=False,
|
||||
blank=True
|
||||
)
|
||||
prechange_data = models.JSONField(
|
||||
verbose_name=_('pre-change data'),
|
||||
editable=False,
|
||||
|
@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable):
|
||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||
verbose_name=_('Request ID')
|
||||
)
|
||||
message = tables.Column(
|
||||
verbose_name=_('Message'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=()
|
||||
)
|
||||
@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable):
|
||||
model = ObjectChange
|
||||
fields = (
|
||||
'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',
|
||||
)
|
||||
|
@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = ObjectChange.objects.all()
|
||||
filterset = ObjectChangeFilterSet
|
||||
ignore_fields = ('prechange_data', 'postchange_data')
|
||||
ignore_fields = ('message', 'prechange_data', 'postchange_data')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -11,7 +11,7 @@ from extras.models import CustomField, Tag
|
||||
from utilities.forms import BulkEditForm, CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import CheckLastUpdatedMixin
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
from .mixins import ChangeLoggingMixin, CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'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.
|
||||
|
||||
@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
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
|
||||
fields and adding/removing tags.
|
||||
|
@ -7,12 +7,23 @@ from extras.models import *
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggingMixin',
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'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:
|
||||
"""
|
||||
Extend a Form to include custom field support.
|
||||
|
@ -63,6 +63,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
|
||||
null=True
|
||||
)
|
||||
|
||||
_changelog_message = None
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@ -103,7 +105,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
|
||||
objectchange = ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self)[:200],
|
||||
action=action
|
||||
action=action,
|
||||
message=self._changelog_message or '',
|
||||
)
|
||||
if hasattr(self, '_prechange_snapshot'):
|
||||
objectchange.prechange_data = self._prechange_snapshot
|
||||
|
@ -21,6 +21,7 @@ from core.models import ObjectType
|
||||
from core.signals import clear_events
|
||||
from extras.choices import CustomFieldUIEditableChoices
|
||||
from extras.models import CustomField, ExportTemplate
|
||||
from netbox.forms.mixins import ChangeLoggingMixin
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
@ -423,7 +424,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
} if prefetch_ids else {}
|
||||
|
||||
for i, record in enumerate(records, start=1):
|
||||
instance = None
|
||||
object_id = int(record.pop('id')) if record.get('id') else None
|
||||
|
||||
# Determine whether this object is being created or updated
|
||||
@ -439,6 +439,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
instance.snapshot()
|
||||
|
||||
else:
|
||||
instance = self.queryset.model()
|
||||
|
||||
# For newly created objects, apply any default custom field values
|
||||
custom_fields = CustomField.objects.filter(
|
||||
object_types=ContentType.objects.get_for_model(self.queryset.model),
|
||||
@ -449,6 +451,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if field_name not in record:
|
||||
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
|
||||
model_form_kwargs = {
|
||||
'data': record,
|
||||
@ -622,6 +627,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if hasattr(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.
|
||||
for name, model_field in model_fields.items():
|
||||
# Handle nullification
|
||||
@ -892,7 +900,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
"""
|
||||
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)
|
||||
|
||||
return BulkDeleteForm
|
||||
@ -939,9 +947,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
try:
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
for obj in queryset:
|
||||
|
||||
# Take a snapshot of change-logged models
|
||||
if hasattr(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()
|
||||
|
||||
except (ProtectedError, RestrictedError) as e:
|
||||
|
@ -19,7 +19,7 @@ from netbox.object_actions import (
|
||||
)
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
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.permissions import get_permission_for_model
|
||||
from utilities.querydict import normalize_querydict, prepare_cloned_fields
|
||||
@ -288,6 +288,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
# Record changelog message (if any)
|
||||
obj._changelog_message = form.cleaned_data.pop('changelog_message', '')
|
||||
|
||||
try:
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
object_created = form.instance.pk is None
|
||||
@ -422,7 +425,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
||||
request: The current request
|
||||
"""
|
||||
obj = self.get_object(**kwargs)
|
||||
form = ConfirmationForm(initial=request.GET)
|
||||
form = DeleteForm(initial=request.GET)
|
||||
|
||||
try:
|
||||
dependent_objects = self._get_dependent_objects(obj)
|
||||
@ -461,23 +464,25 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
||||
"""
|
||||
logger = logging.getLogger('netbox.views.ObjectDeleteView')
|
||||
obj = self.get_object(**kwargs)
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
# Take a snapshot of change-logged models
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
form = DeleteForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
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:
|
||||
obj.delete()
|
||||
|
||||
except (ProtectedError, RestrictedError) as e:
|
||||
logger.info(f"Caught {type(e)} while attempting to delete objects")
|
||||
handle_protectederror([obj], request, e)
|
||||
return redirect(obj.get_absolute_url())
|
||||
|
||||
except AbortRequest as e:
|
||||
logger.debug(e.message)
|
||||
messages.error(request, mark_safe(e.message))
|
||||
|
@ -64,10 +64,16 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Message" %}</th>
|
||||
<td>
|
||||
{{ object.message|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Request ID" %}</th>
|
||||
<td>
|
||||
{{ object.request_id }}
|
||||
<a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}">{{ object.request_id }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
@ -66,7 +66,10 @@ Context:
|
||||
{% endfor %}
|
||||
|
||||
{# 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 %}
|
||||
</div>
|
||||
|
||||
|
@ -103,7 +103,10 @@ Context:
|
||||
{% endif %}
|
||||
|
||||
{# 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 %}
|
||||
</div>
|
||||
|
||||
|
@ -42,32 +42,31 @@ Context:
|
||||
|
||||
{# Data Import Form #}
|
||||
<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">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_method" value="direct" />
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_method" value="direct" />
|
||||
|
||||
{# Form fields #}
|
||||
{% render_field form.data %}
|
||||
{% render_field form.format %}
|
||||
{% render_field form.csv_delimiter %}
|
||||
{# Form fields #}
|
||||
{% render_field form.data %}
|
||||
{% render_field form.format %}
|
||||
{% render_field form.csv_delimiter %}
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% render_field form.background_job %}
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% 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 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -83,6 +82,11 @@ Context:
|
||||
{% render_field form.format %}
|
||||
{% 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="col col-md-12 text-end">
|
||||
{% if return_url %}
|
||||
@ -110,6 +114,7 @@ Context:
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% render_field form.changelog_message %}
|
||||
{% render_field form.background_job %}
|
||||
</div>
|
||||
|
||||
|
@ -20,7 +20,7 @@ Context:
|
||||
{% endblock %}
|
||||
|
||||
{% 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-content border-1 border-danger">
|
||||
{% include 'htmx/delete_form.html' %}
|
||||
|
@ -2,10 +2,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
<form action="{{ form_url }}" method="post">
|
||||
{# Render hidden fields #}
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% trans "Confirm Deletion" %}</h5>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
@ -16,10 +22,10 @@
|
||||
<p>
|
||||
{% trans "The following objects will be deleted as a result of this action." %}
|
||||
</p>
|
||||
<div class="accordion" id="deleteAccordion">
|
||||
<div class="accordion mb-3" id="deleteAccordion">
|
||||
{% for model, instances in dependent_objects.items %}
|
||||
<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 }}">
|
||||
{% with object_count=instances|length %}
|
||||
{{ object_count }}
|
||||
@ -46,8 +52,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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 class="modal-footer">
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||
|
@ -28,6 +28,13 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
{# Render all fields in a single group #}
|
||||
|
@ -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-content" id="htmx-modal-content">
|
||||
{# Dynamic content goes here #}
|
||||
|
@ -8,12 +8,13 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
|
||||
from netbox.forms.mixins import ChangeLoggingMixin
|
||||
from utilities.constants import CSV_DELIMITERS
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
from utilities.forms.utils import parse_csv
|
||||
|
||||
|
||||
class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form):
|
||||
class BulkImportForm(ChangeLoggingMixin, BackgroundJobMixin, SyncedDataMixin, forms.Form):
|
||||
import_method = forms.ChoiceField(
|
||||
choices=ImportMethodChoices,
|
||||
required=False
|
||||
|
@ -10,6 +10,7 @@ __all__ = (
|
||||
'BulkRenameForm',
|
||||
'ConfirmationForm',
|
||||
'CSVModelForm',
|
||||
'DeleteForm',
|
||||
'FilterForm',
|
||||
'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):
|
||||
"""
|
||||
Provides bulk edit support for objects.
|
||||
|
Loading…
Reference in New Issue
Block a user