Merge branch 'feature' into 9654-device-weight

This commit is contained in:
Jeremy Stretch 2022-09-29 10:38:16 -04:00 committed by GitHub
commit 6eafdab281
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1197 additions and 512 deletions

View File

@ -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

View File

@ -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',
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),
)
),
)
```
@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes:
## 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/).
!!! 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/)

View File

@ -3,19 +3,37 @@
!!! 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.
* 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
* [#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
* [#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

View File

@ -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',
]

View File

@ -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)
)

View File

@ -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',
)

View File

@ -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',
)

View File

@ -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",
}

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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}'

View File

@ -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:
@ -106,10 +88,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

View File

@ -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):

View File

@ -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

View File

@ -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]}

View File

@ -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)

View File

@ -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():

View File

@ -0,0 +1,332 @@
from django.db import migrations, models
import django.db.models.functions.text
class Migration(migrations.Migration):
dependencies = [
('dcim', '0161_cabling_cleanup'),
]
operations = [
migrations.RemoveConstraint(
model_name='cabletermination',
name='dcim_cable_termination_unique_termination',
),
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='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='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'),
),
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(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(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',
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='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.'),
),
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='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.'),
),
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.'),
),
]

View File

@ -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'
),
)

View File

@ -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

View File

@ -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})

View File

@ -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
@ -144,10 +145,16 @@ class DeviceType(NetBoxModel, WeightMixin):
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
@ -342,8 +349,11 @@ class ModuleType(NetBoxModel, WeightMixin):
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):
@ -652,10 +662,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(
Lower('name'), 'site', 'tenant',
name='%(app_label)s_%(class)s_unique_name_site_tenant'
),
models.UniqueConstraint(
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."
),
models.UniqueConstraint(
fields=('rack', 'position', 'face'),
name='%(app_label)s_%(class)s_unique_rack_position_face'
),
models.UniqueConstraint(
fields=('virtual_chassis', 'vc_position'),
name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
),
)
def __str__(self):
@ -680,23 +705,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()

View File

@ -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

View File

@ -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, Module
from .mixins import WeightMixin
from .power import PowerFeed
@ -192,10 +191,16 @@ class Rack(NetBoxModel, WeightMixin):
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):

View File

@ -62,38 +62,26 @@ 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',
condition=Q(parent=None)
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',
condition=Q(parent=None)
name='%(app_label)s_%(class)s_slug',
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])
@ -148,38 +136,26 @@ 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',
condition=Q(parent=None)
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',
condition=Q(parent=None)
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."
),
)
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])
@ -379,38 +355,26 @@ 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',
condition=Q(parent=None)
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',
condition=Q(parent=None)
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."
),
)
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, ]

View File

@ -1635,6 +1635,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']}

View File

@ -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,33 @@ 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_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):

View File

@ -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'),
),
]

View File

@ -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}"

View File

@ -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,6 +58,7 @@ 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'
template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences'
@ -69,9 +71,10 @@ class PluginConfig(AppConfig):
if template_extensions is not None:
register_template_extensions(template_extensions)
# Register navigation menu items (if defined)
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
if menu_items is not None:
# 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)
@ -200,6 +203,18 @@ def register_template_extensions(class_list):
# Navigation menu links
#
class PluginMenu:
icon_class = 'mdi mdi-puzzle'
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class
class PluginMenuItem:
"""
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
@ -246,6 +261,12 @@ class PluginMenuButton:
self.color = color
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)

View File

@ -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

View File

@ -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):

View File

@ -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']

View File

@ -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'),
),
]

View File

@ -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):

View File

@ -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'

View File

@ -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

View File

@ -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 . import *
#
@ -405,21 +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, items in registry['plugins']['menu_items'].items():
plugin_menu_groups.append(
MenuGroup(
label=plugin_name,
items=items
)
)
PLUGIN_MENU = 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=plugin_menu_groups
groups=groups
)
MENUS.append(PLUGIN_MENU)
MENUS.append(plugins_menu)

View File

@ -19,17 +19,6 @@
<h5 class="card-header">Provider</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">ASN</th>
<td>
{% if object.asn %}
<div class="float-end text-warning">
<i class="mdi mdi-alert" title="This field will be removed in a future release. Please migrate this data to ASN objects."></i>
</div>
{% endif %}
{{ object.asn|placeholder }}
</td>
</tr>
<tr>
<th scope="row">ASNs</th>
<td>
@ -44,24 +33,6 @@
<th scope="row">Account</th>
<td>{{ object.account|placeholder }}</td>
</tr>
<tr>
<th scope="row">Customer Portal</th>
<td>
{% if object.portal_url %}
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">NOC Contact</th>
<td>{{ object.noc_contact|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Admin Contact</th>
<td>{{ object.admin_contact|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Circuits</th>
<td>

View File

@ -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'),
),
]

View File

@ -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:

View File

@ -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()

View File

@ -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']}

View File

@ -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():

View File

@ -0,0 +1,44 @@
from django.db import migrations, models
import django.db.models.functions.text
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0032_virtualmachine_update_sites'),
]
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(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(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',
constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'),
),
]

View File

@ -2,6 +2,8 @@ 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.db.models.functions import Lower
from django.urls import reverse
from dcim.models import BaseInterface, Device
@ -159,9 +161,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):
@ -309,9 +317,18 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
class Meta:
ordering = ('_name', 'pk') # Name may be non-unique
unique_together = [
['cluster', 'tenant', 'name']
]
constraints = (
models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant',
name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
),
models.UniqueConstraint(
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 cluster."
),
)
def __str__(self):
return self.name
@ -323,20 +340,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()
@ -465,9 +468,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

View File

@ -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]}

View File

@ -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()

View File

@ -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'),
),
]

View File

@ -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}'