diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 0e1fec6e5..43e3cad9a 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,6 +49,24 @@ class MyModel(NetBoxModel): ... ``` +### The `clone()` Method + +!!! info + This method was introduced in NetBox v3.3. + +The `NetBoxModel` class includes a `clone()` method to be used for gathering attriubtes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. + +Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: + +```python +class MyModel(NetBoxModel): + + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs +``` + ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f76cab4e2..e9125fea0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -30,6 +30,10 @@ * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location +### Plugins API + +* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes + ### Other Changes * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index b3bfe06c0..2fe3503b7 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,6 +2,7 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from extras.utils import is_taggable from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet from netbox.models.features import * @@ -52,6 +53,25 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True + def clone(self): + """ + Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. + """ + attrs = {} + + for field_name in getattr(self, 'clone_fields', []): + field = self._meta.get_field(field_name) + field_value = field.value_from_object(self) + if field_value not in (None, ''): + attrs[field_name] = field_value + + # Include tags (if applicable) + if is_taggable(self): + attrs['tags'] = [tag.pk for tag in self.tags.all()] + + return attrs + class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 4ebfe71cc..88abfa48f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -394,11 +394,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): if '_addanother' in request.POST: redirect_url = request.path - # If the object has clone_fields, pre-populate a new instance of the form + # If cloning is supported, pre-populate a new instance of the form params = prepare_cloned_fields(obj) - if 'return_url' in request.GET: - params['return_url'] = request.GET.get('return_url') if params: + if 'return_url' in request.GET: + params['return_url'] = request.GET.get('return_url') redirect_url += f"?{params.urlencode()}" return redirect(redirect_url) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 451c530e1..ef95ccdc0 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,7 +59,7 @@ Context: {# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if object.clone_fields and request.user|can_add:object %} + {% if request.user|can_add:object %} {% clone_button object %} {% endif %} {% if request.user|can_change:object %} diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 97ab165fe..731b67e43 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -282,26 +282,22 @@ def render_jinja2(template_code, context): def prepare_cloned_fields(instance): """ - Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where - applicable. + Generate a QueryDict comprising attributes from an object's clone() method. """ + # Generate the clone attributes from the instance + if not hasattr(instance, 'clone'): + return None + attrs = instance.clone() + + # Prepare querydict parameters params = [] - for field_name in getattr(instance, 'clone_fields', []): - field = instance._meta.get_field(field_name) - field_value = field.value_from_object(instance) - - # Pass False as null for boolean fields - if field_value is False: - params.append((field_name, '')) - - # Omit empty values - elif field_value not in (None, ''): - params.append((field_name, field_value)) - - # Copy tags - if is_taggable(instance): - for tag in instance.tags.all(): - params.append(('tags', tag.pk)) + for key, value in attrs.items(): + if type(value) in (list, tuple): + params.extend([(key, v) for v in value]) + elif value not in (False, None): + params.append((key, value)) + else: + params.append((key, '')) # Return a QueryDict with the parameters return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True)