diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index efa93c37c5..91c4dab43a 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -6,7 +6,7 @@ from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag -from utilities.forms import BootstrapMixin, CSVModelForm +from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField __all__ = ( @@ -17,7 +17,7 @@ ) -class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): +class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index 7cdb9731e8..2d6c20fcc6 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -1,9 +1,13 @@ +import time + from django import forms +from django.utils.translation import gettext_lazy as _ from .widgets import APISelect, APISelectMultiple, ClearableFileInput __all__ = ( 'BootstrapMixin', + 'CheckLastUpdatedMixin', ) @@ -11,7 +15,6 @@ class BootstrapMixin: """ Add the base Bootstrap CSS classes to form elements. """ - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -60,3 +63,41 @@ def is_valid(self): field.widget.attrs['class'] = f'{css} is-invalid' return is_valid + + +class CheckLastUpdatedMixin(forms.Form): + """ + Checks whether the object being saved has been updated since the form was initialized. If so, validation fails. + This prevents a user from inadvertently overwriting any changes made to the object between when the form was + initialized and when it was submitted. + + This validation does not apply to newly created objects, or if the `_init_time` field is not present in the form + data. + """ + _init_time = forms.DecimalField( + initial=time.time, + required=False, + widget=forms.HiddenInput() + ) + + def clean(self): + super().clean() + + # Skip for absent or newly created instances + if not self.instance or not self.instance.pk: + return + + # Skip if a form init time has not been specified + if not (form_init_time := self.cleaned_data.get('_init_time')): + return + + # Skip if the object does not have a last_updated value + if not (last_updated := getattr(self.instance, 'last_updated', None)): + return + + # Check that the submitted initialization time is not earlier than the object's modification time + if form_init_time < last_updated.timestamp(): + raise forms.ValidationError(_( + "This object has been modified since the form was rendered. Please consult the object's change " + "log for details." + ))