From e074570b8fd8eac213b49750360982199043153c Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 22 Sep 2022 10:01:19 -0700 Subject: [PATCH 01/15] 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 b134d2a7b0daa61aa20769390cfc9f797a440108 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 14:23:53 -0700 Subject: [PATCH 02/15] 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 03/15] 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 ec6457bcd386dd08061203959fe0c27d79bb9ca7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 14 Sep 2022 09:16:25 -0400 Subject: [PATCH 04/15] Remove custom validate_unique() methods --- .../migrations/0162_unique_constraints.py | 81 +++++++++++++++++++ netbox/dcim/models/devices.py | 40 +++++---- netbox/dcim/models/sites.py | 60 +++----------- netbox/dcim/tests/test_models.py | 6 +- .../migrations/0033_unique_constraints.py | 25 ++++++ netbox/virtualization/models.py | 30 +++---- 6 files changed, 153 insertions(+), 89 deletions(-) create mode 100644 netbox/dcim/migrations/0162_unique_constraints.py create mode 100644 netbox/virtualization/migrations/0033_unique_constraints.py diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py new file mode 100644 index 000000000..08c113f50 --- /dev/null +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -0,0 +1,81 @@ +# Generated by Django 4.1.1 on 2022-09-14 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0161_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_name', + ), + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_slug', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_name', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_slug', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_name', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_slug', + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('name', 'site', '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.'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('rack', 'position', 'face'), name='dcim_device_unique_rack_position_face'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_region_slug', violation_error_message='A top-level region with this slug already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_sitegroup_name', violation_error_message='A top-level site group with this name already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 7858960a1..eb21e532b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -651,10 +651,25 @@ class Device(NetBoxModel, ConfigContextModel): class Meta: ordering = ('_name', 'pk') # Name may be null - unique_together = ( - ('site', 'tenant', 'name'), # See validate_unique below - ('rack', 'position', 'face'), - ('virtual_chassis', 'vc_position'), + constraints = ( + models.UniqueConstraint( + name='dcim_device_unique_name_site_tenant', + fields=('name', 'site', 'tenant') + ), + models.UniqueConstraint( + name='dcim_device_unique_name_site', + fields=('name', 'site'), + condition=Q(tenant__isnull=True), + violation_error_message="Device name must be unique per site." + ), + models.UniqueConstraint( + name='dcim_device_unique_rack_position_face', + fields=('rack', 'position', 'face') + ), + models.UniqueConstraint( + name='dcim_device_unique_virtual_chassis_vc_position', + fields=('virtual_chassis', 'vc_position') + ), ) def __str__(self): @@ -679,23 +694,6 @@ class Device(NetBoxModel, ConfigContextModel): def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) - def validate_unique(self, exclude=None): - - # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary - # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation - # of the uniqueness constraint without manual intervention. - if self.name and hasattr(self, 'site') and self.tenant is None: - if Device.objects.exclude(pk=self.pk).filter( - name=self.name, - site=self.site, - tenant__isnull=True - ): - raise ValidationError({ - 'name': 'A device with this name already exists.' - }) - - super().validate_unique(exclude) - def clean(self): super().clean() diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index f5c8e6d9d..90f855741 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -67,7 +67,8 @@ class Region(NestedGroupModel): models.UniqueConstraint( fields=('name',), name='dcim_region_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), @@ -76,24 +77,11 @@ class Region(NestedGroupModel): models.UniqueConstraint( fields=('slug',), name='dcim_region_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - regions = Region.objects.exclude(pk=self.pk) - if regions.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this name already exists.' - }) - if regions.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -153,7 +141,8 @@ class SiteGroup(NestedGroupModel): models.UniqueConstraint( fields=('name',), name='dcim_sitegroup_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), @@ -162,24 +151,11 @@ class SiteGroup(NestedGroupModel): models.UniqueConstraint( fields=('slug',), name='dcim_sitegroup_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - site_groups = SiteGroup.objects.exclude(pk=self.pk) - if site_groups.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this name already exists.' - }) - if site_groups.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -384,7 +360,8 @@ class Location(NestedGroupModel): models.UniqueConstraint( fields=('site', 'name'), name='dcim_location_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A location with this name already exists within the specified site." ), models.UniqueConstraint( fields=('site', 'parent', 'slug'), @@ -393,24 +370,11 @@ class Location(NestedGroupModel): models.UniqueConstraint( fields=('site', 'slug'), name='dcim_location_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A location with this slug already exists within the specified site." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - locations = Location.objects.exclude(pk=self.pk) - if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this name in site {self.site} already exists." - }) - if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this slug in site {self.site} already exists." - }) - - super().validate_unique(exclude=exclude) - @classmethod def get_prerequisite_models(cls): return [Site, ] diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 0e02b0de5..acde02ecd 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -384,7 +384,7 @@ class DeviceTestCase(TestCase): site=self.site, device_type=self.device_type, device_role=self.device_role, - name='' + name=None ) device1.save() @@ -392,12 +392,12 @@ class DeviceTestCase(TestCase): site=device1.site, device_type=device1.device_type, device_role=device1.device_role, - name='' + name=None ) device2.full_clean() device2.save() - self.assertEqual(Device.objects.filter(name='').count(), 2) + self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) def test_device_duplicate_names(self): diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py new file mode 100644 index 000000000..fe02881b0 --- /dev/null +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.1 on 2022-09-14 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0032_virtualmachine_update_sites'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='virtualmachine', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='virtualmachine', + constraint=models.UniqueConstraint(fields=('name', 'cluster', '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.'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4acbe6daf..1b0a6ba06 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation 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.urls import reverse from dcim.models import BaseInterface, Device @@ -309,9 +310,18 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): class Meta: ordering = ('_name', 'pk') # Name may be non-unique - unique_together = [ - ['cluster', 'tenant', 'name'] - ] + constraints = ( + models.UniqueConstraint( + name='virtualization_virtualmachine_unique_name_cluster_tenant', + fields=('name', 'cluster', 'tenant') + ), + models.UniqueConstraint( + name='virtualization_virtualmachine_unique_name_cluster', + fields=('name', 'cluster'), + condition=Q(tenant__isnull=True), + violation_error_message="Virtual machine name must be unique per site." + ), + ) def __str__(self): return self.name @@ -323,20 +333,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) - def validate_unique(self, exclude=None): - - # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary - # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation - # of the uniqueness constraint without manual intervention. - if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( - name=self.name, cluster=self.cluster, tenant__isnull=True - ): - raise ValidationError({ - 'name': 'A virtual machine with this name already exists in the assigned cluster.' - }) - - super().validate_unique(exclude) - def clean(self): super().clean() From f51415cf2c581b63bad0807b5d32f4d5c0231af9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 15:35:24 -0400 Subject: [PATCH 05/15] Replace unique_together with UniqueConstraints --- .../migrations/0039_unique_constraints.py | 39 +++ netbox/circuits/models/circuits.py | 14 +- netbox/circuits/models/providers.py | 3 +- .../migrations/0162_unique_constraints.py | 246 +++++++++++++++++- .../dcim/models/device_component_templates.py | 95 +++---- netbox/dcim/models/device_components.py | 62 ++--- netbox/dcim/models/devices.py | 21 +- netbox/dcim/models/power.py | 14 +- netbox/dcim/models/racks.py | 17 +- .../migrations/0078_unique_constraints.py | 27 ++ netbox/extras/models/models.py | 16 +- .../migrations/0062_unique_constraints.py | 43 +++ netbox/ipam/models/fhrp.py | 7 +- netbox/ipam/models/vlans.py | 28 +- .../migrations/0008_unique_constraints.py | 35 +++ netbox/tenancy/models/contacts.py | 21 +- .../migrations/0033_unique_constraints.py | 22 +- netbox/virtualization/models.py | 21 +- .../migrations/0006_unique_constraints.py | 27 ++ netbox/wireless/models.py | 14 +- 20 files changed, 630 insertions(+), 142 deletions(-) create mode 100644 netbox/circuits/migrations/0039_unique_constraints.py create mode 100644 netbox/extras/migrations/0078_unique_constraints.py create mode 100644 netbox/ipam/migrations/0062_unique_constraints.py create mode 100644 netbox/tenancy/migrations/0008_unique_constraints.py create mode 100644 netbox/wireless/migrations/0006_unique_constraints.py diff --git a/netbox/circuits/migrations/0039_unique_constraints.py b/netbox/circuits/migrations/0039_unique_constraints.py new file mode 100644 index 000000000..1d5b62499 --- /dev/null +++ b/netbox/circuits/migrations/0039_unique_constraints.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0038_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='providernetwork', + name='circuits_providernetwork_provider_name', + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='circuit', + constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'), + ), + migrations.AddConstraint( + model_name='circuittermination', + constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'), + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index c08b5473a..ea74eeb40 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -132,7 +132,12 @@ class Circuit(NetBoxModel): class Meta: ordering = ['provider', 'cid'] - unique_together = ['provider', 'cid'] + constraints = ( + models.UniqueConstraint( + fields=('provider', 'cid'), + name='%(app_label)s_%(class)s_unique_provider_cid' + ), + ) def __str__(self): return self.cid @@ -208,7 +213,12 @@ class CircuitTermination( class Meta: ordering = ['circuit', 'term_side'] - unique_together = ['circuit', 'term_side'] + constraints = ( + models.UniqueConstraint( + fields=('circuit', 'term_side'), + name='%(app_label)s_%(class)s_unique_circuit_term_side' + ), + ) def __str__(self): return f'Termination {self.term_side}: {self.site or self.provider_network}' diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index e136e13ea..2a1e01626 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -106,10 +106,9 @@ class ProviderNetwork(NetBoxModel): constraints = ( models.UniqueConstraint( fields=('provider', 'name'), - name='circuits_providernetwork_provider_name' + name='%(app_label)s_%(class)s_unique_provider_name' ), ) - unique_together = ('provider', 'name') def __str__(self): return self.name diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index 08c113f50..a2f471632 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.1 on 2022-09-14 20:57 - from django.db import migrations, models @@ -34,10 +32,134 @@ class Migration(migrations.Migration): model_name='sitegroup', name='dcim_sitegroup_slug', ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set(), + ), migrations.AlterUniqueTogether( name='device', unique_together=set(), ), + migrations.AlterUniqueTogether( + name='devicebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitem', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitemtemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='moduletype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerfeed', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerpanel', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='consoleport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleserverport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleserverporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleserverporttemplate_unique_module_type_name'), + ), migrations.AddConstraint( model_name='device', constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), @@ -54,6 +176,62 @@ class Migration(migrations.Migration): model_name='device', constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'), ), + migrations.AddConstraint( + model_name='devicebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_devicebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='devicebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_devicebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_devicetype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'slug'), name='dcim_devicetype_unique_manufacturer_slug'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_frontport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontport_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_frontporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_frontporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontporttemplate_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='interface', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_interface_unique_device_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_interfacetemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_interfacetemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='inventoryitem', + constraint=models.UniqueConstraint(fields=('device', 'parent', 'name'), name='dcim_inventoryitem_unique_device_parent_name'), + ), + migrations.AddConstraint( + model_name='inventoryitemtemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'parent', 'name'), name='dcim_inventoryitemtemplate_unique_device_type_parent_name'), + ), migrations.AddConstraint( model_name='location', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'), @@ -62,6 +240,70 @@ class Migration(migrations.Migration): model_name='location', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'), ), + migrations.AddConstraint( + model_name='modulebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_modulebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='modulebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_modulebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='moduletype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_moduletype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='powerfeed', + constraint=models.UniqueConstraint(fields=('power_panel', 'name'), name='dcim_powerfeed_unique_power_panel_name'), + ), + migrations.AddConstraint( + model_name='poweroutlet', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_poweroutlet_unique_device_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_poweroutlettemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_poweroutlettemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='powerpanel', + constraint=models.UniqueConstraint(fields=('site', 'name'), name='dcim_powerpanel_unique_site_name'), + ), + migrations.AddConstraint( + model_name='powerport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_powerport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_powerporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_powerporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'name'), name='dcim_rack_unique_location_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'facility_id'), name='dcim_rack_unique_location_facility_id'), + ), + migrations.AddConstraint( + model_name='rearport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_rearport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_rearporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_rearporttemplate_unique_module_type_name'), + ), migrations.AddConstraint( model_name='region', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'), diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b7079d375..15389a2c0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -61,6 +61,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): class Meta: abstract = True + ordering = ('device_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + ) def __str__(self): if self.label: @@ -100,6 +107,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True + ordering = ('device_type', 'module_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + ) def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -145,13 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): component_model = ConsolePort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -181,13 +192,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): component_model = ConsoleServerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -229,13 +233,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): component_model = PowerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -291,13 +288,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): component_model = PowerOutlet - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def clean(self): super().clean() @@ -372,13 +362,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): component_model = Interface - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -428,12 +411,20 @@ class FrontPortTemplate(ModularComponentTemplateModel): component_model = FrontPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentTemplateModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def clean(self): @@ -507,13 +498,6 @@ class RearPortTemplate(ModularComponentTemplateModel): component_model = RearPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -547,10 +531,6 @@ class ModuleBayTemplate(ComponentTemplateModel): component_model = ModuleBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -574,10 +554,6 @@ class DeviceBayTemplate(ComponentTemplateModel): """ component_model = DeviceBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -653,7 +629,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): class Meta: ordering = ('device_type__id', 'parent__id', '_name') - unique_together = ('device_type', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_parent_name' + ), + ) def instantiate(self, **kwargs): parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c521ee095..59d63ef7b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -69,6 +69,13 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True + ordering = ('device', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + ) def __str__(self): if self.label: @@ -99,7 +106,7 @@ class ModularComponentModel(ComponentModel): object_id_field='component_id' ) - class Meta: + class Meta(ComponentModel.Meta): abstract = True @@ -265,10 +272,6 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) @@ -292,10 +295,6 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) @@ -329,10 +328,6 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:powerport', kwargs={'pk': self.pk}) @@ -443,10 +438,6 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) @@ -677,9 +668,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', ) - class Meta: + class Meta(ModularComponentModel.Meta): ordering = ('device', CollateAsChar('_name')) - unique_together = ('device', 'name') def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) @@ -895,11 +885,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel): clone_fields = ('device', 'type', 'color') - class Meta: - ordering = ('device', '_name') - unique_together = ( - ('device', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def get_absolute_url(self): @@ -944,10 +939,6 @@ class RearPort(ModularComponentModel, CabledObjectModel): ) clone_fields = ('device', 'type', 'color', 'positions') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:rearport', kwargs={'pk': self.pk}) @@ -980,10 +971,6 @@ class ModuleBay(ComponentModel): clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) @@ -1002,10 +989,6 @@ class DeviceBay(ComponentModel): clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:devicebay', kwargs={'pk': self.pk}) @@ -1141,7 +1124,12 @@ class InventoryItem(MPTTModel, ComponentModel): class Meta: ordering = ('device__id', 'parent__id', '_name') - unique_together = ('device', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_parent_name' + ), + ) def get_absolute_url(self): return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index eb21e532b..491846c39 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -143,10 +143,16 @@ class DeviceType(NetBoxModel): class Meta: ordering = ['manufacturer', 'model'] - unique_together = [ - ['manufacturer', 'model'], - ['manufacturer', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + models.UniqueConstraint( + fields=('manufacturer', 'slug'), + name='%(app_label)s_%(class)s_unique_manufacturer_slug' + ), + ) def __str__(self): return self.model @@ -341,8 +347,11 @@ class ModuleType(NetBoxModel): class Meta: ordering = ('manufacturer', 'model') - unique_together = ( - ('manufacturer', 'model'), + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), ) def __str__(self): diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 83eead67f..39f0f37ef 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -50,7 +50,12 @@ class PowerPanel(NetBoxModel): class Meta: ordering = ['site', 'name'] - unique_together = ['site', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), + ) def __str__(self): return self.name @@ -138,7 +143,12 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): class Meta: ordering = ['power_panel', 'name'] - unique_together = ['power_panel', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('power_panel', 'name'), + name='%(app_label)s_%(class)s_unique_power_panel_name' + ), + ) def __str__(self): return self.name diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 20027675a..10550e906 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -3,12 +3,11 @@ import decimal from django.apps import apps from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Count, Sum +from django.db.models import Count from django.urls import reverse from dcim.choices import * @@ -18,7 +17,7 @@ from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange -from .device_components import PowerOutlet, PowerPort +from .device_components import PowerPort from .devices import Device from .power import PowerFeed @@ -191,10 +190,16 @@ class Rack(NetBoxModel): class Meta: ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique - unique_together = ( + constraints = ( # Name and facility_id must be unique *only* within a Location - ('location', 'name'), - ('location', 'facility_id'), + models.UniqueConstraint( + fields=('location', 'name'), + name='%(app_label)s_%(class)s_unique_location_name' + ), + models.UniqueConstraint( + fields=('location', 'facility_id'), + name='%(app_label)s_%(class)s_unique_location_facility_id' + ), ) def __str__(self): diff --git a/netbox/extras/migrations/0078_unique_constraints.py b/netbox/extras/migrations/0078_unique_constraints.py new file mode 100644 index 000000000..4a56831a7 --- /dev/null +++ b/netbox/extras/migrations/0078_unique_constraints.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0077_customlink_extend_text_and_url'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='webhook', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='exporttemplate', + constraint=models.UniqueConstraint(fields=('content_type', 'name'), name='extras_exporttemplate_unique_content_type_name'), + ), + migrations.AddConstraint( + model_name='webhook', + constraint=models.UniqueConstraint(fields=('payload_url', 'type_create', 'type_update', 'type_delete'), name='extras_webhook_unique_payload_url_types'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0df34c146..266953f61 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -131,7 +131,12 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('name',) - unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) + constraints = ( + models.UniqueConstraint( + fields=('payload_url', 'type_create', 'type_update', 'type_delete'), + name='%(app_label)s_%(class)s_unique_payload_url_types' + ), + ) def __str__(self): return self.name @@ -297,9 +302,12 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ['content_type', 'name'] - unique_together = [ - ['content_type', 'name'] - ] + constraints = ( + models.UniqueConstraint( + fields=('content_type', 'name'), + name='%(app_label)s_%(class)s_unique_content_type_name' + ), + ) def __str__(self): return f"{self.content_type}: {self.name}" diff --git a/netbox/ipam/migrations/0062_unique_constraints.py b/netbox/ipam/migrations/0062_unique_constraints.py new file mode 100644 index 000000000..47c1a1214 --- /dev/null +++ b/netbox/ipam/migrations/0062_unique_constraints.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0061_fhrpgroup_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='fhrpgroupassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlan', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='fhrpgroupassignment', + constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id', 'group'), name='ipam_fhrpgroupassignment_unique_interface_group'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'vid'), name='ipam_vlan_unique_group_vid'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='ipam_vlan_unique_group_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'name'), name='ipam_vlangroup_unique_scope_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'slug'), name='ipam_vlangroup_unique_scope_slug'), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 88e6e19d9..633affa41 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -102,7 +102,12 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('-priority', 'pk') - unique_together = ('interface_type', 'interface_id', 'group') + constraints = ( + models.UniqueConstraint( + fields=('interface_type', 'interface_id', 'group'), + name='%(app_label)s_%(class)s_unique_interface_group' + ), + ) verbose_name = 'FHRP group assignment' def __str__(self): diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index f0e062721..c8c401e1c 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -70,10 +70,16 @@ class VLANGroup(OrganizationalModel): class Meta: ordering = ('name', 'pk') # Name may be non-unique - unique_together = [ - ['scope_type', 'scope_id', 'name'], - ['scope_type', 'scope_id', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'name'), + name='%(app_label)s_%(class)s_unique_scope_name' + ), + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'slug'), + name='%(app_label)s_%(class)s_unique_scope_slug' + ), + ) verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' @@ -189,10 +195,16 @@ class VLAN(NetBoxModel): class Meta: ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] + constraints = ( + models.UniqueConstraint( + fields=('group', 'vid'), + name='%(app_label)s_%(class)s_unique_group_vid' + ), + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + ) verbose_name = 'VLAN' verbose_name_plural = 'VLANs' diff --git a/netbox/tenancy/migrations/0008_unique_constraints.py b/netbox/tenancy/migrations/0008_unique_constraints.py new file mode 100644 index 000000000..092878524 --- /dev/null +++ b/netbox/tenancy/migrations/0008_unique_constraints.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_contact_link'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='contact', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactgroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='contact', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_contact_unique_group_name'), + ), + migrations.AddConstraint( + model_name='contactassignment', + constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'contact', 'role'), name='tenancy_contactassignment_unique_object_contact_role'), + ), + migrations.AddConstraint( + model_name='contactgroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 41881f853..79c0a2db3 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -41,8 +41,11 @@ class ContactGroup(NestedGroupModel): class Meta: ordering = ['name'] - unique_together = ( - ('parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_unique_parent_name' + ), ) def get_absolute_url(self): @@ -118,8 +121,11 @@ class Contact(NetBoxModel): class Meta: ordering = ['name'] - unique_together = ( - ('group', 'name') + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), ) def __str__(self): @@ -159,7 +165,12 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('priority', 'contact') - unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') + constraints = ( + models.UniqueConstraint( + fields=('content_type', 'object_id', 'contact', 'role'), + name='%(app_label)s_%(class)s_unique_object_contact_role' + ), + ) def __str__(self): if self.priority: diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py index fe02881b0..4667dcbd3 100644 --- a/netbox/virtualization/migrations/0033_unique_constraints.py +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.1 on 2022-09-14 20:57 - from django.db import migrations, models @@ -10,10 +8,26 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterUniqueTogether( + name='cluster', + unique_together=set(), + ), migrations.AlterUniqueTogether( name='virtualmachine', unique_together=set(), ), + migrations.AlterUniqueTogether( + name='vminterface', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='virtualization_cluster_unique_group_name'), + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint(fields=('site', 'name'), name='virtualization_cluster_unique_site_name'), + ), migrations.AddConstraint( model_name='virtualmachine', constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), @@ -22,4 +36,8 @@ class Migration(migrations.Migration): 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.'), ), + migrations.AddConstraint( + model_name='vminterface', + constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'), + ), ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 1b0a6ba06..b0e732188 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -160,9 +160,15 @@ class Cluster(NetBoxModel): class Meta: ordering = ['name'] - unique_together = ( - ('group', 'name'), - ('site', 'name'), + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), ) def __str__(self): @@ -461,9 +467,14 @@ class VMInterface(NetBoxModel, BaseInterface): ) class Meta: - verbose_name = 'interface' ordering = ('virtual_machine', CollateAsChar('_name')) - unique_together = ('virtual_machine', 'name') + constraints = ( + models.UniqueConstraint( + fields=('virtual_machine', 'name'), + name='%(app_label)s_%(class)s_unique_virtual_machine_name' + ), + ) + verbose_name = 'interface' def __str__(self): return self.name diff --git a/netbox/wireless/migrations/0006_unique_constraints.py b/netbox/wireless/migrations/0006_unique_constraints.py new file mode 100644 index 000000000..f638ae1ab --- /dev/null +++ b/netbox/wireless/migrations/0006_unique_constraints.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0005_wirelesslink_interface_types'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='wirelesslangroup', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='wirelesslink', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='wirelesslangroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='wireless_wirelesslangroup_unique_parent_name'), + ), + migrations.AddConstraint( + model_name='wirelesslink', + constraint=models.UniqueConstraint(fields=('interface_a', 'interface_b'), name='wireless_wirelesslink_unique_interfaces'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index c383ad642..29fe33f4b 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -69,8 +69,11 @@ class WirelessLANGroup(NestedGroupModel): class Meta: ordering = ('name', 'pk') - unique_together = ( - ('parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_unique_parent_name' + ), ) verbose_name = 'Wireless LAN Group' @@ -195,7 +198,12 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): class Meta: ordering = ['pk'] - unique_together = ('interface_a', 'interface_b') + constraints = ( + models.UniqueConstraint( + fields=('interface_a', 'interface_b'), + name='%(app_label)s_%(class)s_unique_interfaces' + ), + ) def __str__(self): return f'#{self.pk}' From 7ff2cb75a8fe40a464c180547a495c887a3fdab1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 15:44:38 -0400 Subject: [PATCH 06/15] Use templated app & model names for all unique constraints --- .../migrations/0162_unique_constraints.py | 8 +++++++ netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/devices.py | 14 +++++------ netbox/dcim/models/sites.py | 24 +++++++++---------- netbox/virtualization/models.py | 6 ++--- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index a2f471632..5dac7039c 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -8,6 +8,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveConstraint( + model_name='cabletermination', + name='dcim_cable_termination_unique_termination', + ), migrations.RemoveConstraint( model_name='location', name='dcim_location_name', @@ -136,6 +140,10 @@ class Migration(migrations.Migration): name='rearporttemplate', unique_together=set(), ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cabletermination_unique_termination'), + ), migrations.AddConstraint( model_name='consoleport', constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'), diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e05eb6d51..fad3e8bd6 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -269,7 +269,7 @@ class CableTermination(models.Model): constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), - name='dcim_cable_termination_unique_termination' + name='%(app_label)s_%(class)s_unique_termination' ), ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 491846c39..79cc8c86b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -662,22 +662,22 @@ class Device(NetBoxModel, ConfigContextModel): ordering = ('_name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( - name='dcim_device_unique_name_site_tenant', - fields=('name', 'site', 'tenant') + fields=('name', 'site', 'tenant'), + name='%(app_label)s_%(class)s_unique_name_site_tenant' ), models.UniqueConstraint( - name='dcim_device_unique_name_site', fields=('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." ), models.UniqueConstraint( - name='dcim_device_unique_rack_position_face', - fields=('rack', 'position', 'face') + fields=('rack', 'position', 'face'), + name='%(app_label)s_%(class)s_unique_rack_position_face' ), models.UniqueConstraint( - name='dcim_device_unique_virtual_chassis_vc_position', - fields=('virtual_chassis', 'vc_position') + fields=('virtual_chassis', 'vc_position'), + name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position' ), ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 90f855741..9ddadace2 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -62,21 +62,21 @@ class Region(NestedGroupModel): constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_region_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_region_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A top-level region with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_region_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_region_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A top-level region with this slug already exists." ), @@ -136,21 +136,21 @@ class SiteGroup(NestedGroupModel): constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_sitegroup_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_sitegroup_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A top-level site group with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_sitegroup_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_sitegroup_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A top-level site group with this slug already exists." ), @@ -355,21 +355,21 @@ class Location(NestedGroupModel): constraints = ( models.UniqueConstraint( fields=('site', 'parent', 'name'), - name='dcim_location_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('site', 'name'), - name='dcim_location_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A location with this name already exists within the specified site." ), models.UniqueConstraint( fields=('site', 'parent', 'slug'), - name='dcim_location_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('site', 'slug'), - name='dcim_location_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A location with this slug already exists within the specified site." ), diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b0e732188..5a1c361c2 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -318,12 +318,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): ordering = ('_name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( - name='virtualization_virtualmachine_unique_name_cluster_tenant', - fields=('name', 'cluster', 'tenant') + fields=('name', 'cluster', 'tenant'), + name='%(app_label)s_%(class)s_unique_name_cluster_tenant' ), models.UniqueConstraint( - name='virtualization_virtualmachine_unique_name_cluster', fields=('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." ), From 1d4f828b93550a09b4842c7a985f5394df6ef2a9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:19:39 -0400 Subject: [PATCH 07/15] 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 08/15] 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 09/15] 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 20e3fdc7828946e0119a3f373bb5fbe6adea176c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 28 Sep 2022 12:22:19 -0700 Subject: [PATCH 10/15] #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
- - - - - - - - - - - - - - - -
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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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