From e074570b8fd8eac213b49750360982199043153c Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 22 Sep 2022 10:01:19 -0700 Subject: [PATCH 01/37] 9071 add header to plugin menu --- netbox/extras/plugins/__init__.py | 14 ++++++++++--- netbox/netbox/navigation_menu.py | 34 ++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 0b57e6f05..95e88ca8c 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -58,6 +58,7 @@ class PluginConfig(AppConfig): # integrated components. graphql_schema = 'graphql.schema' menu_items = 'navigation.menu_items' + menu_header = 'navigation.menu_heading' template_extensions = 'template_content.template_extensions' user_preferences = 'preferences.preferences' @@ -70,9 +71,14 @@ class PluginConfig(AppConfig): register_template_extensions(template_extensions) # Register navigation menu items (if defined) + try: + menu_header = import_object(f"{self.__module__}.{self.menu_header}") + except AttributeError: + menu_header = None + menu_items = import_object(f"{self.__module__}.{self.menu_items}") if menu_items is not None: - register_menu_items(self.verbose_name, menu_items) + register_menu_items(self.verbose_name, menu_header, menu_items) # Register GraphQL schema (if defined) graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") @@ -246,7 +252,7 @@ class PluginMenuButton: self.color = color -def register_menu_items(section_name, class_list): +def register_menu_items(section_name, menu_header, class_list): """ Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) """ @@ -258,7 +264,9 @@ def register_menu_items(section_name, class_list): if not isinstance(button, PluginMenuButton): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - registry['plugins']['menu_items'][section_name] = class_list + registry['plugins']['menu_items'][section_name] = {} + registry['plugins']['menu_items'][section_name]['header'] = menu_header + registry['plugins']['menu_items'][section_name]['items'] = class_list # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a495f17c9..d4970aa35 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -408,18 +408,28 @@ MENUS = [ if registry['plugins']['menu_items']: plugin_menu_groups = [] - for plugin_name, items in registry['plugins']['menu_items'].items(): - plugin_menu_groups.append( - MenuGroup( - label=plugin_name, - items=items + for plugin_name, data in registry['plugins']['menu_items'].items(): + if data['header']: + menu_groups = [MenuGroup(label=plugin_name, items=data["items"])] + icon = data["header"]["icon"] + MENUS.append(Menu( + label=data["header"]["title"], + icon_class=f"mdi {icon}", + groups=menu_groups + )) + else: + plugin_menu_groups.append( + MenuGroup( + label=plugin_name, + items=data["items"] + ) ) + + if plugin_menu_groups: + PLUGIN_MENU = Menu( + label="Plugins", + icon_class="mdi mdi-puzzle", + groups=plugin_menu_groups ) - PLUGIN_MENU = Menu( - label="Plugins", - icon_class="mdi mdi-puzzle", - groups=plugin_menu_groups - ) - - MENUS.append(PLUGIN_MENU) + MENUS.append(PLUGIN_MENU) From a0b17887fdbcc23a4629cacd704e58834c01d3cc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 26 Sep 2022 15:45:58 -0400 Subject: [PATCH 02/37] Fixes #10445: Avoid rounding virtual machine memory values --- docs/release-notes/version-3.3.md | 1 + netbox/utilities/templatetags/helpers.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 2955e17d5..7a5553b41 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -7,6 +7,7 @@ * [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view * [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field +* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values --- diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 67ed553b2..462b37feb 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -73,9 +73,9 @@ def humanize_megabytes(mb): """ if not mb: return '' - if mb >= 1048576: + if not mb % 1048576: # 1024^2 return f'{int(mb / 1048576)} TB' - if mb >= 1024: + if not mb % 1024: return f'{int(mb / 1024)} GB' return f'{mb} MB' From 2463e4efd3f4c38ba4f3db0622f0c4c5576bf587 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 26 Sep 2022 16:42:11 -0400 Subject: [PATCH 03/37] Fixes #10461: Enable filtering by read-only custom fields in the UI --- docs/release-notes/version-3.3.md | 1 + netbox/extras/forms/customfields.py | 11 +++-------- netbox/extras/models/customfields.py | 9 ++++++++- netbox/netbox/forms/base.py | 6 +++--- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7a5553b41..d4d2d35f5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -8,6 +8,7 @@ * [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field * [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values +* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI --- diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 7574f4f2b..40d068450 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -34,7 +34,9 @@ class CustomFieldsMixin: return ContentType.objects.get_for_model(self.model) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type) + return CustomField.objects.filter(content_types=content_type).exclude( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ) def _get_form_field(self, customfield): return customfield.to_form_field() @@ -50,13 +52,6 @@ class CustomFieldsMixin: field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) - if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: - self.fields[field_name].disabled = True - if self.fields[field_name].help_text: - self.fields[field_name].help_text += '
' - self.fields[field_name].help_text += ' ' \ - 'Field is set to read-only.' - # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield if customfield.group_name not in self.custom_field_groups: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..d52d73848 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -297,12 +297,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge return model.objects.filter(pk__in=value) return value - def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): + def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. + enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None @@ -398,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge if self.description: field.help_text = escape(self.description) + # Annotate read-only fields + if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + field.disabled = True + prepend = '
' if field.help_text else '' + field.help_text += f'{prepend} Field is set to read-only.' + return field def to_filter(self, lookup_expr=None): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2676e4cde..fa741faf7 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices +from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.forms.customfields import CustomFieldsMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm @@ -125,10 +125,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): ) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).exclude( + return super()._get_custom_fields(content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(type=CustomFieldTypeChoices.TYPE_JSON) ) def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False) + return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False) From dda193247adf92df860ad799bb2127e34f2fe2b4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 26 Sep 2022 16:47:34 -0400 Subject: [PATCH 04/37] Fixes #10470: Omit read-only custom fields from CSV import forms --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/forms/base.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d4d2d35f5..60d8b5381 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field * [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values * [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI +* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms --- diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index fa741faf7..2cbc67971 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ tags = None # Temporary fix in lieu of tag import support (see #9158) + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type).filter( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ) + def _get_form_field(self, customfield): return customfield.to_form_field(for_csv_import=True) From b134d2a7b0daa61aa20769390cfc9f797a440108 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 14:23:53 -0700 Subject: [PATCH 05/37] 9071 fix test --- netbox/extras/tests/test_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 299cab9ef..733ae3a39 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -63,7 +63,7 @@ class PluginTest(TestCase): Check that plugin MenuItems and MenuButtons are registered. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) - menu_items = registry['plugins']['menu_items']['Dummy plugin'] + menu_items = registry['plugins']['menu_items']['Dummy plugin']['items'] self.assertEqual(len(menu_items), 2) self.assertEqual(len(menu_items[0].buttons), 2) From 7deb9fde9e3822c83076b08896de62b8ed578f7a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 14:41:46 -0700 Subject: [PATCH 06/37] 9071 add documentation --- docs/plugins/development/navigation.md | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 52ae953a7..b4a872ae2 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -32,6 +32,41 @@ A `PluginMenuItem` has the following attributes: | `permissions` | - | A list of permissions required to display this link | | `buttons` | - | An iterable of PluginMenuButton instances to include | +## Optional Header + +Plugin menus normally appear under the "Plugins" header. An optional menu_heading can be defined to make the plugin menu to appear as a top level menu header. An example is shown below: + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_heading = { + "title": "Animal Sound", + "icon": "mdi-puzzle" +} + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +The `menu_heading` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|------------------------------------------------------| +| `title` | Yes | The text that will show in the menu header | +| `icon` | Yes | The icon to use next to the headermdi | + +!!! tip + The icon names can be found at [Material Design Icons](https://materialdesignicons.com/) + ## Menu Buttons A `PluginMenuButton` has the following attributes: From 43b18c13e3406febf41f95812d1351ab74af5336 Mon Sep 17 00:00:00 2001 From: Patrick Hurrelmann Date: Tue, 27 Sep 2022 13:23:51 +0200 Subject: [PATCH 07/37] Fixes: #10480 Fix link-target on cable-trace svg to open link in the same window. --- netbox/dcim/svg/cables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 3872bc4fe..9a847acc9 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -35,7 +35,7 @@ class Node(Hyperlink): """ def __init__(self, position, width, url, color, labels, radius=10, **extra): - super(Node, self).__init__(href=url, target='_blank', **extra) + super(Node, self).__init__(href=url, target='_parent', **extra) x, y = position From 669e86f96e5866124db04e7efc1542b84966987a Mon Sep 17 00:00:00 2001 From: Patrick Hurrelmann Date: Tue, 27 Sep 2022 17:24:19 +0200 Subject: [PATCH 08/37] Fixes: #10465 Format all remaining displayed rackunits with floatformat (#10481) * Fixes: #10465 Try to finish #10268 and format all remaining displayed rackunits with floatformat * #10465: PEP8 fix Co-authored-by: Patrick Hurrelmann Co-authored-by: jeremystretch --- netbox/dcim/svg/racks.py | 3 ++- netbox/dcim/tables/devicetypes.py | 3 +++ netbox/templates/dcim/device.html | 4 ++-- netbox/templates/dcim/devicetype.html | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 573fc966c..6c57e6023 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -9,6 +9,7 @@ from svgwrite.text import Text from django.conf import settings from django.core.exceptions import FieldError from django.db.models import Q +from django.template.defaultfilters import floatformat from django.urls import reverse from django.utils.http import urlencode @@ -41,7 +42,7 @@ def get_device_description(device): device.device_role, device.device_type.manufacturer.name, device.device_type.model, - device.device_type.u_height, + floatformat(device.device_type.u_height), device.asset_tag or '', device.serial or '' ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c08..ec71245f7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -85,6 +85,9 @@ class DeviceTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:devicetype_list' ) + u_height = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) class Meta(NetBoxTable.Meta): model = DeviceType diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 6cc859749..253d905f2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -66,7 +66,7 @@ {% with object.parent_bay.device as parent %} {{ parent|linkify }} / {{ object.parent_bay }} {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) + (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) {% endif %} {% endwith %} {% elif object.rack and object.position %} @@ -90,7 +90,7 @@ Device Type - {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U) + {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb3ec9d2e..1fde72d27 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -29,7 +29,7 @@ Height (U) - {{ object.u_height }} + {{ object.u_height|floatformat }} Full Depth From 05542324fc57aee1a7939f7ea7e497a2490d0f9d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 11:53:11 -0400 Subject: [PATCH 09/37] Changelog for #10465, #10480 --- docs/release-notes/version-3.3.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 60d8b5381..4edda4e9b 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,10 @@ ## v3.3.5 (FUTURE) +### Enhancements + +* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions + ### Bug Fixes * [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view @@ -10,12 +14,14 @@ * [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values * [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI * [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms +* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window --- ## v3.3.4 (2022-09-16) ### Bug Fixes + * [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI * [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface From 2d9852d6f108b745181f23b39c4e72ee970bb7f8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 13:11:57 -0400 Subject: [PATCH 10/37] Fixes #10408: Fix validation when attempting to add redundant contact assignments --- docs/release-notes/version-3.3.md | 1 + netbox/templates/tenancy/contactassignment_edit.html | 3 +++ netbox/tenancy/forms/models.py | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4edda4e9b..e91e825f5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view +* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments * [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field * [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index 4d1747e72..d904deead 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -3,6 +3,9 @@ {% load form_helpers %} {% block form %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
Contact Assignment
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 021e36a5b..eabcb1d0f 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -119,8 +119,10 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): class Meta: model = ContactAssignment fields = ( - 'group', 'contact', 'role', 'priority', + 'content_type', 'object_id', 'group', 'contact', 'role', 'priority', ) widgets = { + 'content_type': forms.HiddenInput(), + 'object_id': forms.HiddenInput(), 'priority': StaticSelect(), } From 1d4f828b93550a09b4842c7a985f5394df6ef2a9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:19:39 -0400 Subject: [PATCH 11/37] Device/VM unique constraints ignore case for name field --- .../migrations/0162_unique_constraints.py | 5 ++-- netbox/dcim/models/devices.py | 5 ++-- netbox/dcim/tests/test_models.py | 21 +++++++++++++++ .../migrations/0033_unique_constraints.py | 5 ++-- netbox/virtualization/models.py | 7 ++--- netbox/virtualization/tests/test_models.py | 26 ++++++++++++++++--- 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index 5dac7039c..d52dbb6c9 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -1,4 +1,5 @@ from django.db import migrations, models +import django.db.models.functions.text class Migration(migrations.Migration): @@ -170,11 +171,11 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='device', - constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'), ), migrations.AddConstraint( model_name='device', - constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'site'), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), condition=models.Q(('tenant__isnull', True)), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), ), migrations.AddConstraint( model_name='device', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 79cc8c86b..d0d9001ad 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError +from django.db.models.functions import Lower from django.urls import reverse from django.utils.safestring import mark_safe @@ -662,11 +663,11 @@ class Device(NetBoxModel, ConfigContextModel): ordering = ('_name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( - fields=('name', 'site', 'tenant'), + Lower('name'), 'site', 'tenant', name='%(app_label)s_%(class)s_unique_name_site_tenant' ), models.UniqueConstraint( - fields=('name', 'site'), + Lower('name'), 'site', name='%(app_label)s_%(class)s_unique_name_site', condition=Q(tenant__isnull=True), violation_error_message="Device name must be unique per site." diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index acde02ecd..460a5e252 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -399,6 +399,27 @@ class DeviceTestCase(TestCase): self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) + def test_device_name_case_sensitivity(self): + + device1 = Device( + site=self.site, + device_type=self.device_type, + device_role=self.device_role, + name='device 1' + ) + device1.save() + + device2 = Device( + site=device1.site, + device_type=device1.device_type, + device_role=device1.device_role, + name='DEVICE 1' + ) + + # Uniqueness validation for name should ignore case + with self.assertRaises(ValidationError): + device2.full_clean() + def test_device_duplicate_names(self): device1 = Device( diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py index 4667dcbd3..0624d3607 100644 --- a/netbox/virtualization/migrations/0033_unique_constraints.py +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -1,4 +1,5 @@ from django.db import migrations, models +import django.db.models.functions.text class Migration(migrations.Migration): @@ -30,11 +31,11 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='virtualmachine', - constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), models.F('tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), ), migrations.AddConstraint( model_name='virtualmachine', - constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), condition=models.Q(('tenant__isnull', True)), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per cluster.'), ), migrations.AddConstraint( model_name='vminterface', diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5a1c361c2..37fcd68ae 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q +from django.db.models.functions import Lower from django.urls import reverse from dcim.models import BaseInterface, Device @@ -318,14 +319,14 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): ordering = ('_name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( - fields=('name', 'cluster', 'tenant'), + Lower('name'), 'cluster', 'tenant', name='%(app_label)s_%(class)s_unique_name_cluster_tenant' ), models.UniqueConstraint( - fields=('name', 'cluster'), + Lower('name'), 'cluster', name='%(app_label)s_%(class)s_unique_name_cluster', condition=Q(tenant__isnull=True), - violation_error_message="Virtual machine name must be unique per site." + violation_error_message="Virtual machine name must be unique per cluster." ), ) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index df5816efa..bf0571d3d 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -8,12 +8,14 @@ from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def test_vm_duplicate_name_per_cluster(self): + @classmethod + def setUpTestData(cls): cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') - cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) + Cluster.objects.create(name='Cluster 1', type=cluster_type) + def test_vm_duplicate_name_per_cluster(self): vm1 = VirtualMachine( - cluster=cluster, + cluster=Cluster.objects.first(), name='Test VM 1' ) vm1.save() @@ -43,7 +45,7 @@ class VirtualMachineTestCase(TestCase): vm2.save() def test_vm_mismatched_site_cluster(self): - cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster_type = ClusterType.objects.first() sites = ( Site(name='Site 1', slug='site-1'), @@ -71,3 +73,19 @@ class VirtualMachineTestCase(TestCase): # VM with cluster site but no direct site should fail with self.assertRaises(ValidationError): VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() + + def test_vm_name_case_sensitivity(self): + vm1 = VirtualMachine( + cluster=Cluster.objects.first(), + name='virtual machine 1' + ) + vm1.save() + + vm2 = VirtualMachine( + cluster=vm1.cluster, + name='VIRTUAL MACHINE 1' + ) + + # Uniqueness validation for name should ignore case + with self.assertRaises(ValidationError): + vm2.full_clean() From e977333177798cdf09a0ee4d8285908fb596d5e9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:48:39 -0400 Subject: [PATCH 12/37] Update device/VM name filters to be case-insensitive --- netbox/dcim/filtersets.py | 5 ++++- netbox/dcim/tests/test_filtersets.py | 3 +++ netbox/virtualization/filtersets.py | 7 +++++-- netbox/virtualization/tests/test_filtersets.py | 3 +++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0a4439173..3a66e6c30 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -887,6 +887,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter to_field_name='slug', label='Device model (slug)', ) + name = MultiValueCharFilter( + lookup_expr='iexact' + ) status = django_filters.MultipleChoiceFilter( choices=DeviceStatusChoices, null_value=None @@ -950,7 +953,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter class Meta: model = Device - fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] + fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index feef4e90c..7a745721b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1611,6 +1611,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): def test_name(self): params = {'name': ['Device 1', 'Device 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Test case insensitivity + params = {'name': ['DEVICE 1', 'DEVICE 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asset_tag(self): params = {'asset_tag': ['1001', '1002']} diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 00d3e2313..1b9c5bc78 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -6,7 +6,7 @@ from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet -from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -196,6 +196,9 @@ class VirtualMachineFilterSet( to_field_name='slug', label='Site (slug)', ) + name = MultiValueCharFilter( + lookup_expr='iexact' + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceRole.objects.all(), label='Role (ID)', @@ -227,7 +230,7 @@ class VirtualMachineFilterSet( class Meta: model = VirtualMachine - fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] + fields = ['id', 'cluster', 'vcpus', 'memory', 'disk'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d3ff12887..d474af21a 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -299,6 +299,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Test case insensitivity + params = {'name': ['VIRTUAL MACHINE 1', 'VIRTUAL MACHINE 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_vcpus(self): params = {'vcpus': [1, 2]} From ad6a7086c42779dcc91bde801126caac1a4afa1a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:52:14 -0400 Subject: [PATCH 13/37] Changelog for #9249 --- docs/release-notes/version-3.4.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 257ffd625..98a576c70 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -3,8 +3,13 @@ !!! warning "PostgreSQL 11 Required" NetBox v3.4 requires PostgreSQL 11 or later. +### Breaking Changes + +* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. + ### Enhancements +* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups ### Plugins API From 3c32c09a5a1a1156340193c2d6eda98c8a4876eb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 09:30:38 -0400 Subject: [PATCH 14/37] Fixes #10496: Use page.canonical_url to identify ReadTheDocs builds --- docs/_theme/main.html | 4 ++-- mkdocs.yml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 4dfc4e14e..3ff44b9cb 100644 --- a/docs/_theme/main.html +++ b/docs/_theme/main.html @@ -2,8 +2,8 @@ {% block site_meta %} {{ super() }} - {# Disable search indexing unless we're building for ReadTheDocs #} - {% if not config.extra.readthedocs %} + {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} + {% if page.canonical_url != 'https://docs.netbox.dev/' %} {% endif %} {% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index 530c6d52e..a10fd6e67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,7 +38,6 @@ plugins: show_root_toc_entry: false show_source: false extra: - readthedocs: !ENV READTHEDOCS social: - icon: fontawesome/brands/github link: https://github.com/netbox-community/netbox From 20e3fdc7828946e0119a3f373bb5fbe6adea176c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 28 Sep 2022 12:22:19 -0700 Subject: [PATCH 15/37] #9045 #9046 - remove legacy fields from Provider (#10377) * #9045 - remove legacy fields from Provider * Add safegaurd for legacy data to migration * 9045 remove fields from forms and tables * Update unrelated tests to use ASN model instead of Provider * Fix migrations collision Co-authored-by: jeremystretch --- netbox/circuits/api/serializers.py | 2 +- netbox/circuits/filtersets.py | 4 +- netbox/circuits/forms/bulk_edit.py | 22 +------ netbox/circuits/forms/bulk_import.py | 2 +- netbox/circuits/forms/models.py | 18 +----- .../0040_provider_remove_deprecated_fields.py | 59 +++++++++++++++++++ netbox/circuits/models/providers.py | 20 +------ netbox/circuits/tables/providers.py | 4 +- netbox/circuits/tests/test_api.py | 2 +- netbox/circuits/tests/test_filtersets.py | 14 ++--- netbox/circuits/tests/test_views.py | 18 ++---- netbox/extras/tests/test_customvalidator.py | 20 ++++--- netbox/templates/circuits/provider.html | 29 --------- netbox/utilities/tests/test_filters.py | 31 ++++------ 14 files changed, 104 insertions(+), 141 deletions(-) create mode 100644 netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index c1d856f39..4a8e2bd28 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,7 +31,7 @@ class ProviderSerializer(NetBoxModelSerializer): class Meta: model = Provider fields = [ - 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'id', 'url', 'display', 'name', 'slug', 'account', 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index cee38fb18..cf250584f 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -65,7 +65,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account'] + fields = ['id', 'name', 'slug', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -73,8 +73,6 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | - Q(noc_contact__icontains=value) | - Q(admin_contact__icontains=value) | Q(comments__icontains=value) ) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index b6ba42afb..12975b5d6 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -20,10 +20,6 @@ __all__ = ( class ProviderBulkEditForm(NetBoxModelBulkEditForm): - asn = forms.IntegerField( - required=False, - label='ASN (legacy)' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -34,20 +30,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Account number' ) - portal_url = forms.URLField( - required=False, - label='Portal' - ) - noc_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='NOC contact' - ) - admin_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='Admin contact' - ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -55,10 +37,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): model = Provider fieldsets = ( - (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), + (None, ('asns', 'account', )), ) nullable_fields = ( - 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'asns', 'account', 'comments', ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index cc2d0409a..77ebb3de9 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'name', 'slug', 'account', 'comments', ) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 7bd7abbbf..17c2e7480 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -30,29 +30,17 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ('Provider', ('name', 'slug', 'asns', 'tags')), + ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'comments', 'tags', ] - widgets = { - 'noc_contact': SmallTextarea( - attrs={'rows': 5} - ), - 'admin_contact': SmallTextarea( - attrs={'rows': 5} - ), - } help_texts = { 'name': "Full name of the provider", - 'asn': "BGP autonomous system number (if applicable)", - 'portal_url': "URL of the provider's customer support portal", - 'noc_contact': "NOC email address and phone number", - 'admin_contact': "Administrative contact email address and phone number", } diff --git a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py new file mode 100644 index 000000000..98c82204d --- /dev/null +++ b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py @@ -0,0 +1,59 @@ +import os + +from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy provider fields still contain data. + """ + Provider = apps.get_model('circuits', 'Provider') + + provider_count = Provider.objects.exclude(asn__isnull=True).count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} " + f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been " + f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider ASN data." + ) + + provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} " + f"providers with legacy contact data. Please ensure all legacy provider contact data has been " + f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider contact data." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0039_unique_constraints'), + ] + + operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='provider', + name='admin_contact', + ), + migrations.RemoveField( + model_name='provider', + name='asn', + ), + migrations.RemoveField( + model_name='provider', + name='noc_contact', + ), + migrations.RemoveField( + model_name='provider', + name='portal_url', + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 2a1e01626..bd63ff0c6 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -24,12 +24,6 @@ class Provider(NetBoxModel): max_length=100, unique=True ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='providers', @@ -40,18 +34,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - portal_url = models.URLField( - blank=True, - verbose_name='Portal URL' - ) - noc_contact = models.TextField( - blank=True, - verbose_name='NOC contact' - ) - admin_contact = models.TextField( - blank=True, - verbose_name='Admin contact' - ) comments = models.TextField( blank=True ) @@ -62,7 +44,7 @@ class Provider(NetBoxModel): ) clone_fields = ( - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'account', ) class Meta: diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index 0ec6d439d..3e2fd1193 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -41,10 +41,10 @@ class ProviderTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', + 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') + default_columns = ('pk', 'name', 'account', 'circuit_count') class ProviderNetworkTable(NetBoxTable): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 02b489ac4..c9d2cfc40 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] bulk_update_data = { - 'asn': 1234, + 'account': '1234', } @classmethod diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 2646de3c2..897c87c05 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), - Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), - Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'), - Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'), - Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), + Provider(name='Provider 1', slug='provider-1', account='1234'), + Provider(name='Provider 2', slug='provider-2', account='2345'), + Provider(name='Provider 3', slug='provider-3', account='3456'), + Provider(name='Provider 4', slug='provider-4', account='4567'), + Provider(name='Provider 5', slug='provider-5', account='5678'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0]]) @@ -82,10 +82,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): # Legacy field - params = {'asn': ['65001', '65002']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn_id(self): # ASN object assignment asns = ASN.objects.all()[:2] params = {'asn_id': [asns[0].pk, asns[1].pk]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index fa6146b93..9644c0b02 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -23,9 +23,9 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), - Provider(name='Provider 3', slug='provider-3', asn=65003), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0], asns[1]]) @@ -37,12 +37,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'Provider X', 'slug': 'provider-x', - 'asn': 65123, 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', - 'portal_url': 'http://example.com/portal', - 'noc_contact': 'noc@example.com', - 'admin_contact': 'admin@example.com', 'comments': 'Another provider', 'tags': [t.pk for t in tags], } @@ -55,11 +51,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { - 'asn': 65009, 'account': '5678', - 'portal_url': 'http://example.com/portal2', - 'noc_contact': 'noc2@example.com', - 'admin_contact': 'admin2@example.com', 'comments': 'New comments', } @@ -104,8 +96,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), ) Provider.objects.bulk_create(providers) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index ce3b572d1..0fe507b67 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase, override_settings -from circuits.models import Provider +from ipam.models import ASN, RIR from dcim.models import Site from extras.validators import CustomValidator @@ -67,21 +67,25 @@ custom_validator = MyValidator() class CustomValidatorTest(TestCase): - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @classmethod + def setUpTestData(cls): + RIR.objects.create(name='RIR 1', slug='rir-1') + + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_configuration(self): - self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) - validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] + self.assertIn('ipam.asn', settings.CUSTOM_VALIDATORS) + validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] self.assertIsInstance(validator, CustomValidator) - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=1).clean() + ASN(asn=1, rir=RIR.objects.first()).clean() - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [max_validator]}) def test_max(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=65535).clean() + ASN(asn=65535, rir=RIR.objects.first()).clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_min_length(self): diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 60bf8cfbc..0fc18a368 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -19,17 +19,6 @@
Provider
- - - - - - - - - - - - - - - - + + + + + + + + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51e873ffa..e30ce7a62 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -104,9 +104,7 @@
-
- Dimensions -
+
Dimensions
ASN - {% if object.asn %} -
- -
- {% endif %} - {{ object.asn|placeholder }} -
ASNs @@ -44,24 +33,6 @@ Account {{ object.account|placeholder }}
Customer Portal - {% if object.portal_url %} - {{ object.portal_url }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
NOC Contact{{ object.noc_contact|markdown|placeholder }}
Admin Contact{{ object.admin_contact|markdown|placeholder }}
Circuits diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 5182722d1..334f270dc 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,8 +5,6 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, Provider from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -15,6 +13,7 @@ from dcim.models import ( ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.filtersets import ASNFilterSet from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( @@ -338,13 +337,14 @@ class DynamicFilterLookupExpressionTest(TestCase): """ @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RIR 1', slug='rir-1') - providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65101), - Provider(name='Provider 3', slug='provider-3', asn=65201), + asns = ( + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir), ) - Provider.objects.bulk_create(providers) + ASN.objects.bulk_create(asns) manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -389,15 +389,6 @@ class DynamicFilterLookupExpressionTest(TestCase): ) Site.objects.bulk_create(sites) - rir = RIR.objects.create(name='RFC 6996', is_private=True) - - asns = [ - ASN(asn=65001, rir=rir), - ASN(asn=65101, rir=rir), - ASN(asn=65201, rir=rir) - ] - ASN.objects.bulk_create(asns) - asns[0].sites.add(sites[0]) asns[1].sites.add(sites[1]) asns[2].sites.add(sites[2]) @@ -456,19 +447,19 @@ class DynamicFilterLookupExpressionTest(TestCase): def test_provider_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1) def test_provider_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2) def test_provider_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1) def test_provider_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 00d2dcda68be0935962c8e5d11a66d784638fb1e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 15:56:09 -0400 Subject: [PATCH 16/37] Refactor navigation resources and menu --- docs/development/adding-models.md | 2 +- netbox/netbox/navigation/__init__.py | 92 +++++++++++++++++++ .../menu.py} | 83 +---------------- netbox/utilities/templatetags/navigation.py | 2 +- 4 files changed, 95 insertions(+), 84 deletions(-) create mode 100644 netbox/netbox/navigation/__init__.py rename netbox/netbox/{navigation_menu.py => navigation/menu.py} (86%) diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index f4d171f48..aef11d666 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em ## 10. Add the model to the navigation menu -Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. +Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`. ## 11. REST API components diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py new file mode 100644 index 000000000..7b5729843 --- /dev/null +++ b/netbox/netbox/navigation/__init__.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Sequence, Optional + +from utilities.choices import ButtonColorChoices + + +__all__ = ( + 'get_model_item', + 'get_model_buttons', + 'Menu', + 'MenuGroup', + 'MenuItem', + 'MenuItemButton', +) + + +# +# Navigation menu data classes +# + +@dataclass +class MenuItemButton: + + link: str + title: str + icon_class: str + permissions: Optional[Sequence[str]] = () + color: Optional[str] = None + + +@dataclass +class MenuItem: + + link: str + link_text: str + permissions: Optional[Sequence[str]] = () + buttons: Optional[Sequence[MenuItemButton]] = () + + +@dataclass +class MenuGroup: + + label: str + items: Sequence[MenuItem] + + +@dataclass +class Menu: + + label: str + icon_class: str + groups: Sequence[MenuGroup] + + +# +# Utility functions +# + +def get_model_item(app_label, model_name, label, actions=('add', 'import')): + return MenuItem( + link=f'{app_label}:{model_name}_list', + link_text=label, + permissions=[f'{app_label}.view_{model_name}'], + buttons=get_model_buttons(app_label, model_name, actions) + ) + + +def get_model_buttons(app_label, model_name, actions=('add', 'import')): + buttons = [] + + if 'add' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.GREEN + ) + ) + if 'import' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.CYAN + ) + ) + + return buttons diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation/menu.py similarity index 86% rename from netbox/netbox/navigation_menu.py rename to netbox/netbox/navigation/menu.py index d4970aa35..9eb762c23 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,86 +1,5 @@ -from dataclasses import dataclass -from typing import Sequence, Optional - from extras.registry import registry -from utilities.choices import ButtonColorChoices - - -# -# Nav menu data classes -# - -@dataclass -class MenuItemButton: - - link: str - title: str - icon_class: str - permissions: Optional[Sequence[str]] = () - color: Optional[str] = None - - -@dataclass -class MenuItem: - - link: str - link_text: str - permissions: Optional[Sequence[str]] = () - buttons: Optional[Sequence[MenuItemButton]] = () - - -@dataclass -class MenuGroup: - - label: str - items: Sequence[MenuItem] - - -@dataclass -class Menu: - - label: str - icon_class: str - groups: Sequence[MenuGroup] - - -# -# Utility functions -# - -def get_model_item(app_label, model_name, label, actions=('add', 'import')): - return MenuItem( - link=f'{app_label}:{model_name}_list', - link_text=label, - permissions=[f'{app_label}.view_{model_name}'], - buttons=get_model_buttons(app_label, model_name, actions) - ) - - -def get_model_buttons(app_label, model_name, actions=('add', 'import')): - buttons = [] - - if 'add' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_add', - title='Add', - icon_class='mdi mdi-plus-thick', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.GREEN - ) - ) - if 'import' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_import', - title='Import', - icon_class='mdi mdi-upload', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.CYAN - ) - ) - - return buttons +from .navigation import * # diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ef0657446..a34ef9816 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -2,7 +2,7 @@ from typing import Dict from django import template from django.template import Context -from netbox.navigation_menu import MENUS +from netbox.navigation.menu import MENUS register = template.Library() From db90b084cf779c109100a6688d0e16ec07cb1978 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:08:03 -0400 Subject: [PATCH 17/37] Enable plugins to create root-level navigation menus --- netbox/extras/plugins/__init__.py | 51 +++++++++++++++++++---------- netbox/extras/tests/test_plugins.py | 2 +- netbox/netbox/navigation/menu.py | 42 +++++++++--------------- 3 files changed, 50 insertions(+), 45 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 95e88ca8c..a5fdbea10 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -6,15 +6,16 @@ from django.apps import AppConfig from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template -from extras.registry import registry -from utilities.choices import ButtonColorChoices - from extras.plugins.utils import import_object +from extras.registry import registry +from netbox.navigation import MenuGroup +from utilities.choices import ButtonColorChoices # Initialize plugin registry registry['plugins'] = { 'graphql_schemas': [], + 'menus': [], 'menu_items': {}, 'preferences': {}, 'template_extensions': collections.defaultdict(list), @@ -57,8 +58,8 @@ class PluginConfig(AppConfig): # Default integration paths. Plugin authors can override these to customize the paths to # integrated components. graphql_schema = 'graphql.schema' + menu = 'navigation.menu' menu_items = 'navigation.menu_items' - menu_header = 'navigation.menu_heading' template_extensions = 'template_content.template_extensions' user_preferences = 'preferences.preferences' @@ -70,15 +71,11 @@ class PluginConfig(AppConfig): if template_extensions is not None: register_template_extensions(template_extensions) - # Register navigation menu items (if defined) - try: - menu_header = import_object(f"{self.__module__}.{self.menu_header}") - except AttributeError: - menu_header = None - - menu_items = import_object(f"{self.__module__}.{self.menu_items}") - if menu_items is not None: - register_menu_items(self.verbose_name, menu_header, menu_items) + # Register navigation menu or menu items (if defined) + if menu := import_object(f"{self.__module__}.{self.menu}"): + register_menu(menu) + if menu_items := import_object(f"{self.__module__}.{self.menu_items}"): + register_menu_items(self.verbose_name, menu_items) # Register GraphQL schema (if defined) graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") @@ -206,6 +203,22 @@ def register_template_extensions(class_list): # Navigation menu links # +class PluginMenu: + icon = 'mdi-puzzle' + + def __init__(self, label, groups, icon=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon is not None: + self.icon = icon + + @property + def icon_class(self): + return f'mdi {self.icon}' + + class PluginMenuItem: """ This class represents a navigation menu item. This constitutes primary link and its text, but also allows for @@ -252,7 +265,13 @@ class PluginMenuButton: self.color = color -def register_menu_items(section_name, menu_header, class_list): +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): """ Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) """ @@ -264,9 +283,7 @@ def register_menu_items(section_name, menu_header, class_list): if not isinstance(button, PluginMenuButton): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - registry['plugins']['menu_items'][section_name] = {} - registry['plugins']['menu_items'][section_name]['header'] = menu_header - registry['plugins']['menu_items'][section_name]['items'] = class_list + registry['plugins']['menu_items'][section_name] = class_list # diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 733ae3a39..299cab9ef 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -63,7 +63,7 @@ class PluginTest(TestCase): Check that plugin MenuItems and MenuButtons are registered. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) - menu_items = registry['plugins']['menu_items']['Dummy plugin']['items'] + menu_items = registry['plugins']['menu_items']['Dummy plugin'] self.assertEqual(len(menu_items), 2) self.assertEqual(len(menu_items[0].buttons), 2) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9eb762c23..400a7bf5a 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,5 +1,5 @@ from extras.registry import registry -from .navigation import * +from . import * # @@ -324,31 +324,19 @@ MENUS = [ # Add plugin menus # +for menu in registry['plugins']['menus']: + MENUS.append(menu) + if registry['plugins']['menu_items']: - plugin_menu_groups = [] - for plugin_name, data in registry['plugins']['menu_items'].items(): - if data['header']: - menu_groups = [MenuGroup(label=plugin_name, items=data["items"])] - icon = data["header"]["icon"] - MENUS.append(Menu( - label=data["header"]["title"], - icon_class=f"mdi {icon}", - groups=menu_groups - )) - else: - plugin_menu_groups.append( - MenuGroup( - label=plugin_name, - items=data["items"] - ) - ) - - if plugin_menu_groups: - PLUGIN_MENU = Menu( - label="Plugins", - icon_class="mdi mdi-puzzle", - groups=plugin_menu_groups - ) - - MENUS.append(PLUGIN_MENU) + # Build the default plugins menu + groups = [ + MenuGroup(label=label, items=items) + for label, items in registry['plugins']['menu_items'].items() + ] + plugins_menu = Menu( + label="Plugins", + icon_class="mdi mdi-puzzle", + groups=groups + ) + MENUS.append(plugins_menu) From d0465242a336061b5894299037a10125f0f8c438 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:44:16 -0400 Subject: [PATCH 18/37] Add documentation for PluginMenu --- docs/plugins/development/navigation.md | 115 +++++++++++++------------ netbox/extras/plugins/__init__.py | 12 +-- 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index b4a872ae2..a52a9803a 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -1,25 +1,67 @@ # Navigation +## Menus + +!!! note + This feature was introduced in NetBox v3.4. + +A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below. + +```python title="navigation.py" +from extras.plugins import PluginMenu + +menu = PluginMenu( + label='My Plugin', + groups=( + ('Foo', (item1, item2, item3)), + ('Bar', (item4, item5)), + ), + icon='mdi mdi-router' +) +``` + +Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items. + +!!! tip + The path to the menu class can be modified by setting `menu` in the PluginConfig instance. + +A `PluginMenu` has the following attributes: + +| Attribute | Required | Description | +|--------------|----------|---------------------------------------------------| +| `label` | Yes | The text displayed as the menu heading | +| `groups` | Yes | An iterable of named groups containing menu items | +| `icon_class` | - | The CSS name of the icon to use for the heading | + +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) + +### The Default Menu + +If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu. + +```python title="navigation.py" +menu_items = (item1, item2, item3) +``` + +!!! tip + The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance. + ## Menu Items -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. +Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. -!!! tip - The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance. - -```python +```python filename="navigation.py" from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), +item1 = PluginMenuItem( + link='plugins:myplugin:myview', + link_text='Some text', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) ) ``` @@ -32,54 +74,21 @@ A `PluginMenuItem` has the following attributes: | `permissions` | - | A list of permissions required to display this link | | `buttons` | - | An iterable of PluginMenuButton instances to include | -## Optional Header - -Plugin menus normally appear under the "Plugins" header. An optional menu_heading can be defined to make the plugin menu to appear as a top level menu header. An example is shown below: - -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices - -menu_heading = { - "title": "Animal Sound", - "icon": "mdi-puzzle" -} - -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), -) -``` - -The `menu_heading` has the following attributes: - -| Attribute | Required | Description | -|---------------|----------|------------------------------------------------------| -| `title` | Yes | The text that will show in the menu header | -| `icon` | Yes | The icon to use next to the headermdi | - -!!! tip - The icon names can be found at [Material Design Icons](https://materialdesignicons.com/) - ## Menu Buttons +Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects. + A `PluginMenuButton` has the following attributes: | Attribute | Required | Description | |---------------|----------|--------------------------------------------------------------------| | `link` | Yes | Name of the URL path to which this button links | | `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | -| `icon_class` | Yes | Button icon CSS class* | +| `icon_class` | Yes | Button icon CSS class | | `color` | - | One of the choices provided by `ButtonColorChoices` | | `permissions` | - | A list of permissions required to display this button | -*NetBox supports [Material Design Icons](https://materialdesignicons.com/). +Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index a5fdbea10..9fdf172e3 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -204,19 +204,15 @@ def register_template_extensions(class_list): # class PluginMenu: - icon = 'mdi-puzzle' + icon_class = 'mdi-puzzle' - def __init__(self, label, groups, icon=None): + def __init__(self, label, groups, icon_class=None): self.label = label self.groups = [ MenuGroup(label, items) for label, items in groups ] - if icon is not None: - self.icon = icon - - @property - def icon_class(self): - return f'mdi {self.icon}' + if icon_class is not None: + self.icon_class = icon_class class PluginMenuItem: From 3fbd514417755f886d09a352b3fc1a4f7240430c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:57:40 -0400 Subject: [PATCH 19/37] Add test for plugin menu registration --- netbox/extras/plugins/__init__.py | 2 +- netbox/extras/tests/dummy_plugin/navigation.py | 10 ++++++++-- netbox/extras/tests/test_plugins.py | 11 ++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 9fdf172e3..ef1106aea 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -204,7 +204,7 @@ def register_template_extensions(class_list): # class PluginMenu: - icon_class = 'mdi-puzzle' + icon_class = 'mdi mdi-puzzle' def __init__(self, label, groups, icon_class=None): self.label = label diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py index 88ac3f7c9..a475b1cde 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -1,7 +1,7 @@ -from extras.plugins import PluginMenuButton, PluginMenuItem +from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem -menu_items = ( +items = ( PluginMenuItem( link='plugins:dummy_plugin:dummy_models', link_text='Item 1', @@ -23,3 +23,9 @@ menu_items = ( link_text='Item 2', ), ) + +menu = PluginMenu( + label='Dummy', + groups=(('Group 1', items),), +) +menu_items = items diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 299cab9ef..e0ff67a2b 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse +from extras.plugins import PluginMenu from extras.registry import registry from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query @@ -58,9 +59,17 @@ class PluginTest(TestCase): response = client.get(url) self.assertEqual(response.status_code, 200) + def test_menu(self): + """ + Check menu registration. + """ + menu = registry['plugins']['menus'][0] + self.assertIsInstance(menu, PluginMenu) + self.assertEqual(menu.label, 'Dummy') + def test_menu_items(self): """ - Check that plugin MenuItems and MenuButtons are registered. + Check menu_items registration. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) menu_items = registry['plugins']['menu_items']['Dummy plugin'] From d486fa8452b8516ffc33556c183f3f312c701a14 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 17:18:31 -0400 Subject: [PATCH 20/37] Changelog for #9045, #9046, #9071 --- docs/release-notes/version-3.4.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 98a576c70..24e5a0ea9 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -6,6 +6,14 @@ ### Breaking Changes * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. +* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. +* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. + +### New Features + +#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071)) + +A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. ### Enhancements @@ -14,13 +22,18 @@ ### Plugins API +* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin ### Other Changes +* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model +* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 ### REST API Changes +* circuits.provider + * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields * ipam.FHRPGroup * Added optional `name` field From 4cb6984a6591b63d3870e3a7e8b7351794f0166e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 29 Sep 2022 18:41:33 +0300 Subject: [PATCH 21/37] GitHub Workflows security hardening (#10456) * build: harden lock.yml permissions Signed-off-by: Alex * build: harden stale.yml permissions Signed-off-by: Alex * build: harden ci.yml permissions Signed-off-by: Alex Signed-off-by: Alex --- .github/workflows/ci.yml | 2 ++ .github/workflows/lock.yml | 5 +++++ .github/workflows/stale.yml | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f5028cd..9431863b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: CI on: [push, pull_request] +permissions: + contents: read # to fetch code (actions/checkout) jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 9df4bc441..b928fc128 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -5,8 +5,13 @@ on: schedule: - cron: '0 3 * * *' +permissions: {} jobs: lock: + permissions: + issues: write # to lock issues (dessant/lock-threads) + pull-requests: write # to lock PRs (dessant/lock-threads) + runs-on: ubuntu-latest steps: - uses: dessant/lock-threads@v3 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 57666417a..1df1c7044 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,8 +4,13 @@ on: schedule: - cron: '0 4 * * *' +permissions: {} jobs: stale: + permissions: + issues: write # to close stale issues (actions/stale) + pull-requests: write # to close stale PRs (actions/stale) + runs-on: ubuntu-latest steps: - uses: actions/stale@v5 From 309a70df8908b354cb6634071913878dd1aea4e1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 29 Sep 2022 11:59:15 -0400 Subject: [PATCH 22/37] Tweak workflow permissions --- .github/workflows/lock.yml | 5 ++--- .github/workflows/stale.yml | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index b928fc128..4f73f66f0 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -5,12 +5,11 @@ on: schedule: - cron: '0 3 * * *' -permissions: {} jobs: lock: permissions: - issues: write # to lock issues (dessant/lock-threads) - pull-requests: write # to lock PRs (dessant/lock-threads) + issues: write + pull-requests: write runs-on: ubuntu-latest steps: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1df1c7044..70a2511c8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,12 +4,11 @@ on: schedule: - cron: '0 4 * * *' -permissions: {} jobs: stale: permissions: - issues: write # to close stale issues (actions/stale) - pull-requests: write # to close stale PRs (actions/stale) + issues: write + pull-requests: write runs-on: ubuntu-latest steps: From cbbfcd0e7b9acba46dbaa725fd7bd02366aa1303 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 29 Sep 2022 12:00:44 -0400 Subject: [PATCH 23/37] Bump stale to v6 --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 70a2511c8..cbc8d8b87 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v5 + - uses: actions/stale@v6 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an From 04738587e80ca0cc9ecdf3a833c9af555877e902 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 29 Sep 2022 12:17:10 -0400 Subject: [PATCH 24/37] Move permissions block to root --- .github/workflows/lock.yml | 9 ++++----- .github/workflows/stale.yml | 8 +++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 4f73f66f0..a53cf728c 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -5,17 +5,16 @@ on: schedule: - cron: '0 3 * * *' +permissions: + issues: write + pull-requests: write + jobs: lock: - permissions: - issues: write - pull-requests: write - runs-on: ubuntu-latest steps: - uses: dessant/lock-threads@v3 with: - github-token: ${{ github.token }} issue-inactive-days: 90 pr-inactive-days: 30 issue-lock-reason: 'resolved' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index cbc8d8b87..68e475f24 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,14 +1,16 @@ # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) name: 'Close stale issues/PRs' + on: schedule: - cron: '0 4 * * *' +permissions: + issues: write + pull-requests: write + jobs: stale: - permissions: - issues: write - pull-requests: write runs-on: ubuntu-latest steps: From 62820ea2b8fc4d61f2e68390520d72bccdfff53e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 29 Sep 2022 12:36:10 -0400 Subject: [PATCH 25/37] Add workflow_dispatch event --- .github/workflows/ci.yml | 2 +- .github/workflows/lock.yml | 1 + .github/workflows/stale.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9431863b7..d75f98fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: [push, pull_request] permissions: - contents: read # to fetch code (actions/checkout) + contents: read jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index a53cf728c..6019cef5d 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -4,6 +4,7 @@ name: 'Lock threads' on: schedule: - cron: '0 3 * * *' + workflow_dispatch: permissions: issues: write diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 68e475f24..ab259af2a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,6 +4,7 @@ name: 'Close stale issues/PRs' on: schedule: - cron: '0 4 * * *' + workflow_dispatch: permissions: issues: write From ada5c58acffa2528bee1f82b4daabb77ae147d9f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 15:05:13 -0400 Subject: [PATCH 26/37] Closes #10529: Run validation on each value of a multi-value filter --- netbox/utilities/filters.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 543449b73..3d7f7d7ad 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -23,6 +23,14 @@ def multivalue_field_factory(field_class): field.to_python(v) for v in value if v ] + def run_validators(self, value): + for v in value: + super().run_validators(v) + + def validate(self, value): + for v in value: + super().validate(v) + return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) From af8bb0c4b9c0b8d6599f110f522bd1e598c40b88 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 30 Sep 2022 13:03:24 -0700 Subject: [PATCH 27/37] 10348 add decimal custom field (#10422) * 10348 add decimal custom field * 10348 fix tests * 10348 add documentation * Rearrange custom fields to be ordered consistently * Rename number_field to integer_field for clarity * Clean up validation logic * Apply suggested changes from PR * Store decimal custom field values natively * Fix filter test * Update custom field model migrations to use new encoder Co-authored-by: jeremystretch --- docs/customization/custom-fields.md | 1 + netbox/circuits/migrations/0001_squashed.py | 10 +- ...uit_termination_date_tags_custom_fields.py | 4 +- netbox/dcim/migrations/0001_squashed.py | 52 +-- netbox/dcim/migrations/0146_modules.py | 8 +- .../dcim/migrations/0147_inventoryitemrole.py | 4 +- netbox/extras/api/serializers.py | 2 + netbox/extras/choices.py | 2 + .../0073_journalentry_tags_custom_fields.py | 4 +- netbox/extras/models/customfields.py | 50 ++- netbox/extras/tests/test_customfields.py | 300 +++++++++++------- netbox/extras/tests/test_forms.py | 3 + netbox/ipam/migrations/0001_squashed.py | 22 +- netbox/ipam/migrations/0050_iprange.py | 4 +- netbox/ipam/migrations/0052_fhrpgroup.py | 4 +- netbox/ipam/migrations/0053_asn_model.py | 4 +- .../ipam/migrations/0055_servicetemplate.py | 4 +- netbox/ipam/migrations/0059_l2vpn.py | 6 +- netbox/netbox/filtersets.py | 3 +- netbox/netbox/models/features.py | 4 +- .../tenancy/migrations/0001_squashed_0012.py | 6 +- netbox/tenancy/migrations/0003_contacts.py | 8 +- netbox/utilities/filters.py | 8 +- netbox/utilities/json.py | 17 + .../migrations/0001_squashed_0022.py | 12 +- netbox/wireless/migrations/0001_wireless.py | 8 +- 26 files changed, 343 insertions(+), 207 deletions(-) create mode 100644 netbox/utilities/json.py diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index c443fa9f6..81aaa5247 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * Text: Free-form text (intended for single-line use) * Long text: Free-form of any length; supports Markdown rendering * Integer: A whole number (positive or negative) +* Decimal: A fixed-precision decimal number (4 decimal places) * Boolean: True or false * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 851f40a22..971233162 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('cid', models.CharField(max_length=100)), ('status', models.CharField(default='active', max_length=50)), @@ -58,7 +58,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -73,7 +73,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -93,7 +93,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py index c686bf042..96b2a9d97 100644 --- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='circuittermination', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='circuittermination', diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index 374d3bf45..fca7d8eb9 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -1,6 +1,6 @@ import dcim.fields import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('termination_a_id', models.PositiveIntegerField()), ('termination_b_id', models.PositiveIntegerField()), @@ -60,7 +60,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -96,7 +96,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -132,7 +132,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('name', models.CharField(blank=True, max_length=64, null=True)), @@ -155,7 +155,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -186,7 +186,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -203,7 +203,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -224,7 +224,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -261,7 +261,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('label', models.CharField(blank=True, max_length=64)), @@ -302,7 +302,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -326,7 +326,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -345,7 +345,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -360,7 +360,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -377,7 +377,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)), ('mark_connected', models.BooleanField(default=False)), @@ -401,7 +401,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -438,7 +438,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ], @@ -451,7 +451,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -490,7 +490,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -516,7 +516,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), ('description', models.CharField(max_length=200)), @@ -530,7 +530,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -546,7 +546,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -583,7 +583,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -602,7 +602,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -630,7 +630,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -649,7 +649,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('domain', models.CharField(blank=True, max_length=30)), diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py index 11324fc58..821cf6119 100644 --- a/netbox/dcim/migrations/0146_modules.py +++ b/netbox/dcim/migrations/0146_modules.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -107,7 +107,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('part_number', models.CharField(blank=True, max_length=50)), @@ -125,7 +125,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -145,7 +145,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('serial', models.CharField(blank=True, max_length=50)), diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index f5e1f23f5..cbdd36c08 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 764c7750a..fd774f8ff 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -99,6 +99,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): types = CustomFieldTypeChoices if obj.type == types.TYPE_INTEGER: return 'integer' + if obj.type == types.TYPE_DECIMAL: + return 'decimal' if obj.type == types.TYPE_BOOLEAN: return 'boolean' if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 123fd2cd4..5afe9f33f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_TEXT = 'text' TYPE_LONGTEXT = 'longtext' TYPE_INTEGER = 'integer' + TYPE_DECIMAL = 'decimal' TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' TYPE_URL = 'url' @@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_TEXT, 'Text'), (TYPE_LONGTEXT, 'Text (long)'), (TYPE_INTEGER, 'Integer'), + (TYPE_DECIMAL, 'Decimal'), (TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), diff --git a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py index 73a3e466c..5f2d7f7f3 100644 --- a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py +++ b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='journalentry', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='journalentry', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..3cb5b506c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,5 +1,6 @@ import re from datetime import datetime, date +import decimal import django_filters from django import forms @@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge }) # Minimum/maximum values can be set only for numeric fields - if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: - raise ValidationError({ - 'validation_minimum': "A minimum value may be set only for numeric fields" - }) - if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: - raise ValidationError({ - 'validation_maximum': "A maximum value may be set only for numeric fields" - }) + if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): + if self.validation_minimum: + raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"}) + if self.validation_maximum: + raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"}) # Regex validation can be set only for text fields regex_types = ( @@ -317,6 +315,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge max_value=self.validation_maximum ) + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + field = forms.DecimalField( + required=required, + initial=initial, + max_digits=12, + decimal_places=4, + min_value=self.validation_minimum, + max_value=self.validation_maximum + ) + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( @@ -426,6 +435,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: filter_class = filters.MultiValueNumberFilter + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + filter_class = filters.MultiValueDecimalFilter + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: filter_class = django_filters.BooleanFilter @@ -475,7 +488,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError(f"Value must match regex '{self.validation_regex}'") # Validate integer - if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: if type(value) is not int: raise ValidationError("Value must be an integer.") if self.validation_minimum is not None and value < self.validation_minimum: @@ -483,12 +496,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + try: + decimal.Decimal(value) + except decimal.InvalidOperation: + raise ValidationError("Value must be a decimal.") + if self.validation_minimum is not None and value < self.validation_minimum: + raise ValidationError(f"Value must be at least {self.validation_minimum}") + if self.validation_maximum is not None and value > self.validation_maximum: + raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate boolean - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError("Value must be true or false.") # Validate date - if self.type == CustomFieldTypeChoices.TYPE_DATE: + elif self.type == CustomFieldTypeChoices.TYPE_DATE: if type(value) is not date: try: datetime.strptime(value, '%Y-%m-%d') @@ -496,14 +520,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError("Date values must be in the format YYYY-MM-DD.") # Validate selected choice - if self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: if value not in self.choices: raise ValidationError( f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) # Validate all selected choices - if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if not set(value).issubset(self.choices): raise ValidationError( f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 946999bc2..6080ce2e5 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse @@ -102,6 +104,32 @@ class CustomFieldTest(TestCase): instance.refresh_from_db() self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_decimal_field(self): + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='decimal_field', + type=CustomFieldTypeChoices.TYPE_DECIMAL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (123456.54, 0, -123456.78): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_boolean_field(self): # Create a custom field & check that initial value is null @@ -373,7 +401,8 @@ class CustomFieldAPITest(APITestCase): custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), - CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), @@ -424,14 +453,15 @@ class CustomFieldAPITest(APITestCase): custom_fields[0].name: 'bar', custom_fields[1].name: 'DEF', custom_fields[2].name: 456, - custom_fields[3].name: True, - custom_fields[4].name: '2020-01-02', - custom_fields[5].name: 'http://example.com/2', - custom_fields[6].name: '{"foo": 1, "bar": 2}', - custom_fields[7].name: 'Bar', - custom_fields[8].name: ['Bar', 'Baz'], - custom_fields[9].name: vlans[1].pk, - custom_fields[10].name: [vlans[2].pk, vlans[3].pk], + custom_fields[3].name: Decimal('456.78'), + custom_fields[4].name: True, + custom_fields[5].name: '2020-01-02', + custom_fields[6].name: 'http://example.com/2', + custom_fields[7].name: '{"foo": 1, "bar": 2}', + custom_fields[8].name: 'Bar', + custom_fields[9].name: ['Bar', 'Baz'], + custom_fields[10].name: vlans[1].pk, + custom_fields[11].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() @@ -440,6 +470,7 @@ class CustomFieldAPITest(APITestCase): CustomFieldTypeChoices.TYPE_TEXT: 'string', CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', CustomFieldTypeChoices.TYPE_DATE: 'string', CustomFieldTypeChoices.TYPE_URL: 'string', @@ -473,7 +504,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, - 'number_field': None, + 'integer_field': None, + 'decimal_field': None, 'boolean_field': None, 'date_field': None, 'url_field': None, @@ -497,7 +529,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) - self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) + self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field']) + self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) @@ -531,7 +564,8 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) @@ -548,7 +582,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -568,7 +603,8 @@ class CustomFieldAPITest(APITestCase): 'custom_fields': { 'text_field': 'bar', 'longtext_field': 'blah blah blah', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -590,7 +626,8 @@ class CustomFieldAPITest(APITestCase): data_cf = data['custom_fields'] self.assertEqual(response_cf['text_field'], data_cf['text_field']) self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) - self.assertEqual(response_cf['number_field'], data_cf['number_field']) + self.assertEqual(response_cf['integer_field'], data_cf['integer_field']) + self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) @@ -607,7 +644,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) @@ -652,7 +690,8 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) @@ -669,7 +708,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -686,7 +726,8 @@ class CustomFieldAPITest(APITestCase): custom_field_data = { 'text_field': 'bar', 'longtext_field': 'abcdefghij', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -726,7 +767,8 @@ class CustomFieldAPITest(APITestCase): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) + self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field']) + self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) @@ -743,7 +785,8 @@ class CustomFieldAPITest(APITestCase): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) @@ -763,7 +806,7 @@ class CustomFieldAPITest(APITestCase): data = { 'custom_fields': { 'text_field': 'ABCD', - 'number_field': 1234, + 'integer_field': 1234, }, } url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) @@ -775,8 +818,9 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) - self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) @@ -792,8 +836,9 @@ class CustomFieldAPITest(APITestCase): # Validate database data site2.refresh_from_db() self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) @@ -808,20 +853,20 @@ class CustomFieldAPITest(APITestCase): url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - cf_integer = CustomField.objects.get(name='number_field') + cf_integer = CustomField.objects.get(name='integer_field') cf_integer.validation_minimum = 10 cf_integer.validation_maximum = 20 cf_integer.save() - data = {'custom_fields': {'number_field': 9}} + data = {'custom_fields': {'integer_field': 9}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 21}} + data = {'custom_fields': {'integer_field': 21}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 15}} + data = {'custom_fields': {'integer_field': 15}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -860,6 +905,7 @@ class CustomFieldImportTest(TestCase): CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), + CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), @@ -880,10 +926,10 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -893,10 +939,11 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 9) + self.assertEqual(len(site1.custom_field_data), 10) self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) + self.assertEqual(site1.custom_field_data['decimal'], 123.45) self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') @@ -906,10 +953,11 @@ class CustomFieldImportTest(TestCase): # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 9) + self.assertEqual(len(site2.custom_field_data), 10) self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) + self.assertEqual(site2.custom_field_data['decimal'], 456.78) self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') @@ -1034,53 +1082,78 @@ class CustomFieldModelFilterTest(TestCase): cf.save() cf.content_types.set([obj_type]) + # Decimal filtering + cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf.save() + cf.content_types.set([obj_type]) + # Boolean filtering - cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) + cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf.save() cf.content_types.set([obj_type]) # Exact text filtering - cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf4', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose text filtering - cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf5', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Date filtering - cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) + cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE) cf.save() cf.content_types.set([obj_type]) # Exact URL filtering - cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf7', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose URL filtering - cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf8', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField( + name='cf9', + type=CustomFieldTypeChoices.TYPE_SELECT, + choices=['Foo', 'Bar', 'Baz'] + ) cf.save() cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) + cf = CustomField( + name='cf10', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=['A', 'B', 'C', 'X'] + ) cf.save() cf.content_types.set([obj_type]) # Object filtering cf = CustomField( - name='cf10', + name='cf11', type=CustomFieldTypeChoices.TYPE_OBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1089,7 +1162,7 @@ class CustomFieldModelFilterTest(TestCase): # Multi-object filtering cf = CustomField( - name='cf11', + name='cf12', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1099,42 +1172,45 @@ class CustomFieldModelFilterTest(TestCase): Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, - 'cf2': True, - 'cf3': 'foo', + 'cf2': 100.1, + 'cf3': True, 'cf4': 'foo', - 'cf5': '2016-06-26', - 'cf6': 'http://a.example.com', + 'cf5': 'foo', + 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', - 'cf8': 'Foo', - 'cf9': ['A', 'X'], - 'cf10': manufacturers[0].pk, - 'cf11': [manufacturers[0].pk, manufacturers[3].pk], + 'cf8': 'http://a.example.com', + 'cf9': 'Foo', + 'cf10': ['A', 'X'], + 'cf11': manufacturers[0].pk, + 'cf12': [manufacturers[0].pk, manufacturers[3].pk], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': True, - 'cf3': 'foobar', + 'cf2': 200.2, + 'cf3': True, 'cf4': 'foobar', - 'cf5': '2016-06-27', - 'cf6': 'http://b.example.com', + 'cf5': 'foobar', + 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', - 'cf8': 'Bar', - 'cf9': ['B', 'X'], - 'cf10': manufacturers[1].pk, - 'cf11': [manufacturers[1].pk, manufacturers[3].pk], + 'cf8': 'http://b.example.com', + 'cf9': 'Bar', + 'cf10': ['B', 'X'], + 'cf11': manufacturers[1].pk, + 'cf12': [manufacturers[1].pk, manufacturers[3].pk], }), Site(name='Site 3', slug='site-3', custom_field_data={ 'cf1': 300, - 'cf2': False, - 'cf3': 'bar', + 'cf2': 300.3, + 'cf3': False, 'cf4': 'bar', - 'cf5': '2016-06-28', - 'cf6': 'http://c.example.com', + 'cf5': 'bar', + 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', - 'cf8': 'Baz', - 'cf9': ['C', 'X'], - 'cf10': manufacturers[2].pk, - 'cf11': [manufacturers[2].pk, manufacturers[3].pk], + 'cf8': 'http://c.example.com', + 'cf9': 'Baz', + 'cf10': ['C', 'X'], + 'cf11': manufacturers[2].pk, + 'cf12': [manufacturers[2].pk, manufacturers[3].pk], }), ]) @@ -1146,60 +1222,68 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) + def test_filter_decimal(self): + self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2) + def test_filter_boolean(self): - self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1) def test_filter_text_strict(self): - self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2) def test_filter_text_loose(self): - self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2) def test_filter_date(self): - self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) def test_filter_url_strict(self): - self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) def test_filter_url_loose(self): - self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3) def test_filter_object(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) def test_filter_multiobject(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ec50b7dd..35402bda3 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase): cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer.content_types.set([obj_type]) + cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf_integer.content_types.set([obj_type]) + cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf_boolean.content_types.set([obj_type]) diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 545fd46c6..b5d68439a 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -29,7 +29,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('date_added', models.DateField(blank=True, null=True)), @@ -44,7 +44,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('address', ipam.fields.IPAddressField()), ('status', models.CharField(default='active', max_length=50)), @@ -64,7 +64,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('status', models.CharField(default='active', max_length=50)), @@ -81,7 +81,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -99,7 +99,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -115,7 +115,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=21, unique=True)), ('description', models.CharField(blank=True, max_length=200)), @@ -129,7 +129,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)), @@ -151,7 +151,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -170,7 +170,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), ('name', models.CharField(max_length=64)), @@ -193,7 +193,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0050_iprange.py b/netbox/ipam/migrations/0050_iprange.py index 5b8861f29..374b2547c 100644 --- a/netbox/ipam/migrations/0050_iprange.py +++ b/netbox/ipam/migrations/0050_iprange.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.5 on 2021-07-16 14:15 -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import django.db.models.expressions @@ -22,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('start_address', ipam.fields.IPAddressField()), ('end_address', ipam.fields.IPAddressField()), diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 70219543f..e69e49d48 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('group_id', models.PositiveSmallIntegerField()), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py index 1c7ee8e23..3b074634c 100644 --- a/netbox/ipam/migrations/0053_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.8 on 2021-11-02 16:16 import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('asn', dcim.fields.ASNField(unique=True)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py index 738317907..c8ba6645c 100644 --- a/netbox/ipam/migrations/0055_servicetemplate.py +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('protocol', models.CharField(max_length=50)), ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)), diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index bd4761593..59dbab632 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField()), ('type', models.CharField(max_length=50)), @@ -42,7 +42,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('assigned_object_id', models.PositiveBigIntegerField()), ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b6776e3c1..6a8f5d0d3 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -46,7 +46,7 @@ class BaseFilterSet(django_filters.FilterSet): 'filter_class': filters.MultiValueDateTimeFilter }, models.DecimalField: { - 'filter_class': filters.MultiValueNumberFilter + 'filter_class': filters.MultiValueDecimalFilter }, models.EmailField: { 'filter_class': filters.MultiValueCharFilter @@ -95,6 +95,7 @@ class BaseFilterSet(django_filters.FilterSet): filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, + filters.MultiValueDecimalFilter, filters.MultiValueTimeFilter )): return FILTER_NUMERIC_BASED_LOOKUP_MAP diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 9fa1c5cef..ce80cec3e 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -4,7 +4,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager @@ -12,6 +11,7 @@ from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import is_taggable, register_features from netbox.signals import post_clean +from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object __all__ = ( @@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model): Enables support for custom fields. """ custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, + encoder=CustomFieldJSONEncoder, blank=True, default=dict ) diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 77297b982..e8a028a92 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -54,7 +54,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index 35e568ab1..ba9bef50f 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -55,7 +55,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('title', models.CharField(blank=True, max_length=100)), diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 3d7f7d7ad..d41eff498 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,8 +3,6 @@ from django import forms from django.conf import settings from django_filters.constants import EMPTY_VALUES -from utilities.forms import MACAddressField - def multivalue_field_factory(field_class): """ @@ -31,7 +29,7 @@ def multivalue_field_factory(field_class): for v in value: super().validate(v) - return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + return type(f'MultiValue{field_class.__name__}', (NewField,), dict()) # @@ -54,6 +52,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.IntegerField) +class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DecimalField) + + class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField) diff --git a/netbox/utilities/json.py b/netbox/utilities/json.py new file mode 100644 index 000000000..5574ff36f --- /dev/null +++ b/netbox/utilities/json.py @@ -0,0 +1,17 @@ +import decimal + +from django.core.serializers.json import DjangoJSONEncoder + +__all__ = ( + 'CustomFieldJSONEncoder', +) + + +class CustomFieldJSONEncoder(DjangoJSONEncoder): + """ + Override Django's built-in JSON encoder to save decimal values as JSON numbers. + """ + def default(self, o): + if isinstance(o, decimal.Decimal): + return float(o) + return super().default(o) diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index d00bae2e2..29eda8a50 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -1,5 +1,5 @@ import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -51,7 +51,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('comments', models.TextField(blank=True)), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -80,7 +80,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -95,7 +95,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('name', models.CharField(max_length=64)), @@ -147,7 +147,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('enabled', models.BooleanField(default=True)), ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 10b6e585b..9369df8a5 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -44,7 +44,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(max_length=32)), ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(blank=True, max_length=32)), ('status', models.CharField(default='connected', max_length=50)), From 204c10c053fddc26ad23ec15a3c60eee38bfc081 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 30 Sep 2022 13:31:04 -0700 Subject: [PATCH 28/37] 9654 device weight (#10448) * 9654 add weight fields to devices * 9654 changes from code review * 9654 change _abs_weight to grams * Resolve migrations conflict * 9654 code-review changes * 9654 total weight on devices * Misc cleanup Co-authored-by: Jeremy Stretch --- docs/models/dcim/devicetype.md | 4 ++ docs/models/dcim/moduletype.md | 4 ++ docs/models/dcim/rack.md | 4 ++ netbox/dcim/api/serializers.py | 16 +++-- netbox/dcim/choices.py | 18 ++++++ netbox/dcim/filtersets.py | 6 +- netbox/dcim/forms/bulk_edit.py | 54 ++++++++++++----- netbox/dcim/forms/filtersets.py | 24 ++++++++ netbox/dcim/forms/models.py | 17 ++++-- netbox/dcim/graphql/types.py | 9 +++ ...0163_rack_devicetype_moduletype_weights.py | 58 +++++++++++++++++++ netbox/dcim/models/devices.py | 24 ++++++-- netbox/dcim/models/mixins.py | 45 ++++++++++++++ netbox/dcim/models/racks.py | 24 +++++++- netbox/dcim/tables/devicetypes.py | 8 ++- netbox/dcim/tables/modules.py | 7 ++- netbox/dcim/tables/racks.py | 11 +++- netbox/dcim/tables/template_code.py | 5 ++ netbox/dcim/tests/test_filtersets.py | 42 +++++++++++--- netbox/templates/dcim/devicetype.html | 10 ++++ netbox/templates/dcim/moduletype.html | 10 ++++ netbox/templates/dcim/rack.html | 19 +++++- netbox/templates/dcim/rack_edit.html | 8 +++ netbox/utilities/utils.py | 27 ++++++++- 24 files changed, 397 insertions(+), 57 deletions(-) create mode 100644 netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py create mode 100644 netbox/dcim/models/mixins.py diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 050f93244..6dc4aa13e 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). +### Weight + +The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds). + ### Front & Rear Images Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index b8ec0ac6e..3122d2e00 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu ### Part Number An alternative part number to uniquely identify the module type. + +### Weight + +The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound). diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 57e7bec98..e88c36fad 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -65,6 +65,10 @@ The height of the rack, measured in units. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +### Weight + +The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). + ### Descending Units If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca3..22d56565e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer): default=None) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -208,8 +209,9 @@ class RackSerializer(NetBoxModelSerializer): model = Rack fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', + 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'powerfeed_count', ] @@ -315,27 +317,29 @@ class DeviceTypeSerializer(NetBoxModelSerializer): ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', + 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', ] class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) # module_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 7d35a40f9..8466d4861 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1314,6 +1314,24 @@ class CableLengthUnitChoices(ChoiceSet): ) +class WeightUnitChoices(ChoiceSet): + + # Metric + UNIT_KILOGRAM = 'kg' + UNIT_GRAM = 'g' + + # Imperial + UNIT_POUND = 'lb' + UNIT_OUNCE = 'oz' + + CHOICES = ( + (UNIT_KILOGRAM, 'Kilograms'), + (UNIT_GRAM, 'Grams'), + (UNIT_POUND, 'Pounds'), + (UNIT_OUNCE, 'Ounces'), + ) + + # # CableTerminations # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3a66e6c30..a0c5e545c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = [ 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', + 'outer_unit', 'weight', 'weight_unit' ] def search(self, queryset, name, value): @@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ] def search(self, queryset, name, value): @@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class Meta: model = ModuleType - fields = ['id', 'model', 'part_number'] + fields = ['id', 'model', 'part_number', 'weight', 'weight_unit'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 396f7e59b..d033d3a67 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): widget=SmallTextarea, label='Comments' ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = Rack fieldsets = ( ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Location', ('region', 'site_group', 'site', 'location')), ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), + ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit' ) @@ -355,12 +366,23 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = DeviceType fieldsets = ( - (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'airflow') + nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): part_number = forms.CharField( required=False ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = ModuleType fieldsets = ( - (None, ('manufacturer', 'part_number')), + ('Module Type', ('manufacturer', 'part_number')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number',) + nullable_fields = ('part_number', 'weight', 'weight_unit') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): 'type', 'status', 'tenant', 'label', 'color', 'length', ) - def clean(self): - super().clean() - - # Validate length/unit - length = self.cleaned_data.get('length') - length_unit = self.cleaned_data.get('length_unit') - if length and not length_unit: - raise forms.ValidationError({ - 'length_unit': "Must specify a unit when setting length" - }) - class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): domain = forms.CharField( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 96b0d1319..818da83e1 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -228,6 +228,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), + ('Weight', ('weight', 'weight_unit')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte required=False ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class RackElevationFilterForm(RackFilterForm): @@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class ModuleTypeFilterForm(NetBoxModelFilterSetForm): @@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class DeviceRoleFilterForm(NetBoxModelFilterSetForm): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b33023ece..4faefb623 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -260,7 +260,7 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'comments', 'tags', + 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm): 'type': StaticSelect(), 'width': StaticSelect(), 'outer_unit': StaticSelect(), + 'weight_unit': StaticSelect(), } @@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm): ('Chassis', ( 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', )), + ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), ) @@ -370,7 +372,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'front_image', 'rear_image', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -380,7 +382,8 @@ class DeviceTypeForm(NetBoxModelForm): }), 'rear_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS - }) + }), + 'weight_unit': StaticSelect(), } @@ -392,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm): fieldsets = ( ('Module Type', ( - 'manufacturer', 'model', 'part_number', 'tags', + 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit' )), ) class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'comments', 'tags', + 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', ] + widgets = { + 'weight_unit': StaticSelect(), + } + class DeviceRoleForm(NetBoxModelForm): slug = SlugField() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 52a98278a..78cabbcd1 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -211,6 +211,9 @@ class DeviceTypeType(NetBoxObjectType): def resolve_airflow(self, info): return self.airflow or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class FrontPortType(ComponentObjectType, CabledObjectMixin): @@ -328,6 +331,9 @@ class ModuleTypeType(NetBoxObjectType): fields = '__all__' filterset_class = filtersets.ModuleTypeFilterSet + def resolve_weight_unit(self, info): + return self.weight_unit or None + class PlatformType(OrganizationalObjectType): @@ -416,6 +422,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): def resolve_outer_unit(self, info): return self.outer_unit or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class RackReservationType(NetBoxObjectType): diff --git a/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py new file mode 100644 index 000000000..09bef5736 --- /dev/null +++ b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py @@ -0,0 +1,58 @@ +# Generated by Django 4.0.7 on 2022-09-23 01:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0162_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='moduletype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='rack', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d0d9001ad..b7c4abd32 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,8 @@ import decimal - import yaml +from functools import cached_property + from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError @@ -21,6 +22,7 @@ from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * +from .mixins import WeightMixin __all__ = ( @@ -71,7 +73,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(NetBoxModel): +class DeviceType(NetBoxModel, WeightMixin): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -139,7 +141,7 @@ class DeviceType(NetBoxModel): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', ) class Meta: @@ -315,7 +317,7 @@ class DeviceType(NetBoxModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(NetBoxModel): +class ModuleType(NetBoxModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -344,7 +346,7 @@ class ModuleType(NetBoxModel): to='extras.ImageAttachment' ) - clone_fields = ('manufacturer',) + clone_fields = ('manufacturer', 'weight', 'weight_unit',) class Meta: ordering = ('manufacturer', 'model') @@ -946,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel): def get_status_color(self): return DeviceStatusChoices.colors.get(self.status) + @cached_property + def total_weight(self): + total_weight = sum( + module.module_type._abs_weight + for module in Module.objects.filter(device=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self.device_type._abs_weight: + total_weight += self.device_type._abs_weight + return round(total_weight / 1000, 2) + class Module(NetBoxModel, ConfigContextModel): """ diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py new file mode 100644 index 000000000..b5449332b --- /dev/null +++ b/netbox/dcim/models/mixins.py @@ -0,0 +1,45 @@ +from django.core.exceptions import ValidationError +from django.db import models +from dcim.choices import * +from utilities.utils import to_grams + + +class WeightMixin(models.Model): + weight = models.DecimalField( + max_digits=8, + decimal_places=2, + blank=True, + null=True + ) + weight_unit = models.CharField( + max_length=50, + choices=WeightUnitChoices, + blank=True, + ) + # Stores the normalized weight (in grams) for database ordering + _abs_weight = models.PositiveBigIntegerField( + blank=True, + null=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + + # Store the given weight (if any) in grams for use in database ordering + if self.weight and self.weight_unit: + self._abs_weight = to_grams(self.weight, self.weight_unit) + else: + self._abs_weight = None + + super().save(*args, **kwargs) + + def clean(self): + super().clean() + + # Validate weight and weight_unit + if self.weight is not None and not self.weight_unit: + raise ValidationError("Must specify a unit when setting a weight") + elif self.weight is None: + self.weight_unit = '' diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 10550e906..6da48b65c 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,5 @@ import decimal +from functools import cached_property from django.apps import apps from django.contrib.auth.models import User @@ -18,7 +19,8 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange from .device_components import PowerPort -from .devices import Device +from .devices import Device, Module +from .mixins import WeightMixin from .power import PowerFeed __all__ = ( @@ -62,7 +64,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(NetBoxModel): +class Rack(NetBoxModel, WeightMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -185,7 +187,7 @@ class Rack(NetBoxModel): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', + 'outer_depth', 'outer_unit', 'weight', 'weight_unit', ) class Meta: @@ -454,6 +456,22 @@ class Rack(NetBoxModel): return int(allocated_draw / available_power_total * 100) + @cached_property + def total_weight(self): + total_weight = sum( + device.device_type._abs_weight + for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type') + ) + total_weight += sum( + module.module_type._abs_weight + for module in Module.objects.filter(device__rack=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self._abs_weight: + total_weight += self._abs_weight + return round(total_weight / 1000, 2) + class RackReservation(NetBoxModel): """ diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c08..8f371ef1a 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,7 +5,7 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from netbox.tables import NetBoxTable, columns -from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT __all__ = ( 'ConsolePortTemplateTable', @@ -85,12 +85,16 @@ class DeviceTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:devicetype_list' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index e40d7bd80..b644e6ba6 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -2,6 +2,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType from netbox.tables import NetBoxTable, columns +from .template_code import DEVICE_WEIGHT __all__ = ( 'ModuleTable', @@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 39553bac0..ffca07145 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,6 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from netbox.tables import NetBoxTable, columns from tenancy.tables import TenancyColumnsMixin +from .template_code import DEVICE_WEIGHT __all__ = ( 'RackTable', @@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable): template_code="{{ record.outer_depth }} {{ record.outer_unit }}", verbose_name='Outer Depth' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', - 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', - 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', + 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments', + 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index dfc77b854..9b8fb8fd6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -15,6 +15,11 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ +DEVICE_WEIGHT = """ +{% load helpers %} +{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %} +""" + DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7a745721b..d4922fb1d 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) racks = ( - Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), + Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) Rack.objects.bulk_create(racks) @@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() @@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), - DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) DeviceType.objects.bulk_create(device_types) @@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'inventory_items': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() @@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) module_types = ( - ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), - ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), - ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), + ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) ModuleType.objects.bulk_create(module_types) @@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb3ec9d2e..6a37a8d06 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -35,6 +35,16 @@ Full Depth {% checkmark object.is_full_depth %}
Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Parent/Child diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 2c8e77be3..8128e64be 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,16 @@ Part Number {{ object.part_number|placeholder }}
Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Instances {{ instance_count }}
@@ -147,6 +145,20 @@ {% endif %} + + + + + + + +
Rack Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Total Weight{{ object.total_weight|floatformat }} Kilograms
@@ -186,6 +198,7 @@
{% endif %} + {% include 'inc/panels/image_attachments.html' %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index ca97be34d..4a340c147 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -57,6 +57,14 @@
{% render_field form.desc_units %} +
+
+
Weight
+
+ {% render_field form.weight %} + {% render_field form.weight_unit %} +
+ {% if form.custom_fields %}
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 69ab615fc..9f587e88d 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -12,7 +12,7 @@ from django.http import QueryDict from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel -from dcim.choices import CableLengthUnitChoices +from dcim.choices import CableLengthUnitChoices, WeightUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable from netbox.config import get_config @@ -270,6 +270,31 @@ def to_meters(length, unit): raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") +def to_grams(weight, unit): + """ + Convert the given weight to kilograms. + """ + try: + if weight < 0: + raise ValueError("Weight must be a positive number") + except TypeError: + raise TypeError(f"Invalid value '{weight}' for weight (must be a number)") + + valid_units = WeightUnitChoices.values() + if unit not in valid_units: + raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}") + + if unit == WeightUnitChoices.UNIT_KILOGRAM: + return weight * 1000 + if unit == WeightUnitChoices.UNIT_GRAM: + return weight + if unit == WeightUnitChoices.UNIT_POUND: + return weight * Decimal(453.592) + if unit == WeightUnitChoices.UNIT_OUNCE: + return weight * Decimal(28.3495) + raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.") + + def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. From 97d561ac330db53c902717fddb52b9209db598bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 16:37:07 -0400 Subject: [PATCH 29/37] Changelog for #9654, #10348 --- docs/release-notes/version-3.4.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 24e5a0ea9..4019ef474 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -18,7 +18,9 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive +* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups +* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type ### Plugins API @@ -35,5 +37,11 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * circuits.provider * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields +* dcim.DeviceType + * Added optional `weight` and `weight_unit` fields +* dcim.ModuleType + * Added optional `weight` and `weight_unit` fields +* dcim.Rack + * Added optional `weight` and `weight_unit` fields * ipam.FHRPGroup * Added optional `name` field From 5cc55d1e993d4d21655a7f536dd1ce299755784b Mon Sep 17 00:00:00 2001 From: Patrick Hurrelmann Date: Tue, 27 Sep 2022 17:24:19 +0200 Subject: [PATCH 30/37] Fixes: #10465 Format all remaining displayed rackunits with floatformat (#10481) * Fixes: #10465 Try to finish #10268 and format all remaining displayed rackunits with floatformat * #10465: PEP8 fix Co-authored-by: Patrick Hurrelmann Co-authored-by: jeremystretch --- netbox/dcim/tables/devicetypes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index bc596a297..c48e93ca7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -92,6 +92,9 @@ class DeviceTypeTable(NetBoxTable): template_code=DEVICE_WEIGHT, order_by=('_abs_weight', 'weight_unit') ) + u_height = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) class Meta(NetBoxTable.Meta): model = DeviceType From ac7db3cc88dcb8159df3256c64b5f8dda642050e Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Tue, 20 Sep 2022 15:50:33 -0400 Subject: [PATCH 31/37] Tidy-up imports and typing (cherry picked from commit adee5cf6a8856ceda0170a4382cec8fd784be93b) --- netbox/netbox/settings.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cfd4d231c..a0e8f5ffa 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,18 +1,17 @@ import hashlib import importlib -import logging import os import platform -import re -import socket import sys import warnings from urllib.parse import urlsplit +import django import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from django.utils.encoding import force_str from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -20,9 +19,7 @@ from netbox.config import PARAMS # Monkey patch to fix Django 4.0 support for graphene-django (see # https://github.com/graphql-python/graphene-django/issues/1284) # TODO: Remove this when graphene-django 2.16 becomes available -import django -from django.utils.encoding import force_str -django.utils.encoding.force_text = force_str +django.utils.encoding.force_text = force_str # type: ignore # @@ -186,7 +183,7 @@ if STORAGE_BACKEND is not None: if STORAGE_BACKEND.startswith('storages.'): try: - import storages.utils + import storages.utils # type: ignore except ModuleNotFoundError as e: if getattr(e, 'name') == 'storages': raise ImproperlyConfigured( From dc522a0135df41f0905c114ad40ffc5026287f47 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Tue, 20 Sep 2022 17:55:44 -0400 Subject: [PATCH 32/37] Initial implementation - Allows to specify a list of django-apps to be "installed" alongside the plugin. (cherry picked from commit 6c7296200d756d2acbba3a589a7759f3a690cc48) --- netbox/extras/plugins/__init__.py | 3 +++ netbox/netbox/settings.py | 34 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ef1106aea..3efa9aaa7 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -55,6 +55,9 @@ class PluginConfig(AppConfig): # Django-rq queues dedicated to the plugin queues = [] + # Django apps to append to INSTALLED_APPS when plugin requires them. + django_apps = [] + # Default integration paths. Plugin authors can override these to customize the paths to # integrated components. graphql_schema = 'graphql.schema' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a0e8f5ffa..ed225da52 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,5 +1,6 @@ import hashlib import importlib +import importlib.util import os import platform import sys @@ -12,6 +13,7 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_str +from extras.plugins import PluginConfig from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -660,14 +662,42 @@ for plugin_name in PLUGINS: # Determine plugin config and add to INSTALLED_APPS. try: - plugin_config = plugin.config - INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__)) + plugin_config: PluginConfig = plugin.config except AttributeError: raise ImproperlyConfigured( "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " "and point to the PluginConfig subclass.".format(plugin_name) ) + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + # Gather additionnal apps to load alongside this plugin + plugin_apps = plugin_config.django_apps + if plugin_name in plugin_apps: + plugin_apps.pop(plugin_name) + if plugin_module not in plugin_apps: + plugin_apps.append(plugin_module) + + # Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs) + for app in plugin_apps: + if "." in app: + parts = app.split(".") + spec = importlib.util.find_spec(".".join(parts[:-1])) + else: + spec = importlib.util.find_spec(app) + if spec is None: + raise ImproperlyConfigured( + f"Plugin {plugin_name} provides a 'config' variable which contains invalid 'plugin_apps'. " + f"The module {app}, from this list, cannot be imported. Check that the additionnal app has been " + "installed within the correct Python environment." + ) + + + INSTALLED_APPS.extend(plugin_apps) + + # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence + sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) + INSTALLED_APPS = list(sorted_apps) + # Validate user-provided configuration settings and assign defaults if plugin_name not in PLUGINS_CONFIG: PLUGINS_CONFIG[plugin_name] = {} From 5c1417c4c76cd09942695fcf71abc37d06689962 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 28 Sep 2022 18:11:10 -0400 Subject: [PATCH 33/37] PEP8 fixes --- netbox/netbox/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ed225da52..0af5eaa1b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -21,7 +21,7 @@ from netbox.config import PARAMS # Monkey patch to fix Django 4.0 support for graphene-django (see # https://github.com/graphql-python/graphene-django/issues/1284) # TODO: Remove this when graphene-django 2.16 becomes available -django.utils.encoding.force_text = force_str # type: ignore +django.utils.encoding.force_text = force_str # type: ignore # @@ -669,7 +669,8 @@ for plugin_name in PLUGINS: "and point to the PluginConfig subclass.".format(plugin_name) ) - plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + # Gather additionnal apps to load alongside this plugin plugin_apps = plugin_config.django_apps if plugin_name in plugin_apps: @@ -691,7 +692,6 @@ for plugin_name in PLUGINS: "installed within the correct Python environment." ) - INSTALLED_APPS.extend(plugin_apps) # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence From d4a7af8a896f9962a252af43863f559c5340c1d2 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 29 Sep 2022 17:12:18 -0400 Subject: [PATCH 34/37] Update plugins development docs --- docs/plugins/development/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 98db9e0bb..7c2334713 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -14,6 +14,7 @@ Plugins can do a lot, including: * Provide their own "pages" (views) in the web user interface * Inject template content and navigation links * Extend NetBox's REST and GraphQL APIs +* Load additionnal Django Apps * Add custom request/response middleware However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. @@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig): default_settings = { 'baz': True } + django_apps = ["foo", "bar", "baz"] config = FooBarConfig ``` @@ -101,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `default_settings` | A dictionary of configuration parameters and their default values | +| `django_apps` | A list of additionnal apps to load alongside the plugin | | `min_version` | Minimum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | @@ -112,6 +115,15 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. +#### Important notes about `django_apps` + +Loading additional apps may cause more harm than good and could lead to make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intented to be used only for advanced use-cases that require a deeper Django integration. + +Apps from this list are inserted *before* the plugin's `PluginConfig` in the same order. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. + +Any additionnal app must be installed within the the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. + + ## Create setup.py `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: From 0607295081b0f56bcfb3b6ab60925446b95baeaf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 16:49:49 -0400 Subject: [PATCH 35/37] Docs cleanup --- docs/plugins/development/index.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 7c2334713..d5aea0591 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -14,7 +14,7 @@ Plugins can do a lot, including: * Provide their own "pages" (views) in the web user interface * Inject template content and navigation links * Extend NetBox's REST and GraphQL APIs -* Load additionnal Django Apps +* Load additional Django apps * Add custom request/response middleware However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. @@ -103,7 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `default_settings` | A dictionary of configuration parameters and their default values | -| `django_apps` | A list of additionnal apps to load alongside the plugin | +| `django_apps` | A list of additional Django apps to load alongside the plugin | | `min_version` | Minimum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | @@ -115,14 +115,13 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. -#### Important notes about `django_apps` +#### Important Notes About `django_apps` -Loading additional apps may cause more harm than good and could lead to make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intented to be used only for advanced use-cases that require a deeper Django integration. +Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration. -Apps from this list are inserted *before* the plugin's `PluginConfig` in the same order. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. - -Any additionnal app must be installed within the the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. +Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. +Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. ## Create setup.py From f7860138c79402c30364254ff747c87d743a311b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 17:01:37 -0400 Subject: [PATCH 36/37] Rename plugin_apps to django_apps for clarity --- netbox/netbox/settings.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0af5eaa1b..a2a6f57a6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -671,15 +671,15 @@ for plugin_name in PLUGINS: plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore - # Gather additionnal apps to load alongside this plugin - plugin_apps = plugin_config.django_apps - if plugin_name in plugin_apps: - plugin_apps.pop(plugin_name) - if plugin_module not in plugin_apps: - plugin_apps.append(plugin_module) + # Gather additional apps to load alongside this plugin + django_apps = plugin_config.django_apps + if plugin_name in django_apps: + django_apps.pop(plugin_name) + if plugin_module not in django_apps: + django_apps.append(plugin_module) # Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs) - for app in plugin_apps: + for app in django_apps: if "." in app: parts = app.split(".") spec = importlib.util.find_spec(".".join(parts[:-1])) @@ -687,12 +687,12 @@ for plugin_name in PLUGINS: spec = importlib.util.find_spec(app) if spec is None: raise ImproperlyConfigured( - f"Plugin {plugin_name} provides a 'config' variable which contains invalid 'plugin_apps'. " - f"The module {app}, from this list, cannot be imported. Check that the additionnal app has been " + f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} " + f"The module {app} cannot be imported. Check that the necessary package has been " "installed within the correct Python environment." ) - INSTALLED_APPS.extend(plugin_apps) + INSTALLED_APPS.extend(django_apps) # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) From 568e0c7ff66b149583e96947bc500374579c6fee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 17:30:18 -0400 Subject: [PATCH 37/37] Changelog for #9880 --- docs/release-notes/version-3.4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 4019ef474..537a4968d 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -25,6 +25,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Plugins API * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus +* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin ### Other Changes