mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-17 06:29:53 +02:00
Merge branch 'develop' into feature
This commit is contained in:
@@ -309,6 +309,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
|
||||
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.Module
|
||||
fields = ['id', 'url', 'display', 'serial']
|
||||
|
||||
|
||||
class NestedModuleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
@@ -392,11 +400,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedModuleBaySerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
|
||||
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleBay
|
||||
fields = ['id', 'url', 'display', 'module', 'name']
|
||||
fields = ['id', 'url', 'display', 'installed_module', 'name']
|
||||
|
||||
|
||||
class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
|
||||
@@ -28,8 +28,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
front_image = serializers.URLField(allow_null=True, required=False)
|
||||
rear_image = serializers.URLField(allow_null=True, required=False)
|
||||
front_image = serializers.ImageField(required=False, allow_null=True)
|
||||
rear_image = serializers.ImageField(required=False, allow_null=True)
|
||||
|
||||
# Counter fields
|
||||
console_port_template_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
|
||||
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
|
||||
TYPE_32GFC_SFP28 = '32gfc-sfp28'
|
||||
TYPE_32GFC_SFP_PLUS = '32gfc-sfpp'
|
||||
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
|
||||
TYPE_64GFC_SFP_DD = '64gfc-sfpdd'
|
||||
TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
|
||||
TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
|
||||
|
||||
# InfiniBand
|
||||
@@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
|
||||
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
|
||||
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
|
||||
(TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'),
|
||||
(TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
|
||||
(TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'),
|
||||
(TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'),
|
||||
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
|
||||
)
|
||||
),
|
||||
|
||||
@@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'slug': self.slug,
|
||||
'description': self.description,
|
||||
'default_platform': self.default_platform.name if self.default_platform else None,
|
||||
'part_number': self.part_number,
|
||||
'u_height': float(self.u_height),
|
||||
'is_full_depth': self.is_full_depth,
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
'description': self.description,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
|
||||
@@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
platform = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Platform')
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
@@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
model = models.Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
|
||||
@@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventory_item_template_count,
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
permission='dcim.view_inventoryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from extras.models import ObjectChange
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
'ConfigContextMixin',
|
||||
'ContactsMixin',
|
||||
'CustomFieldsMixin',
|
||||
'ImageAttachmentsMixin',
|
||||
'JournalEntriesMixin',
|
||||
|
||||
@@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.jinja2 import ConfigTemplateLoader
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
@@ -26,7 +26,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -210,7 +210,7 @@ class ConfigContextModel(models.Model):
|
||||
# Config templates
|
||||
#
|
||||
|
||||
class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100
|
||||
|
||||
@@ -373,20 +373,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
|
||||
)
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if interface and (address := self.cleaned_data.get('address')):
|
||||
if address.ip == address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip)
|
||||
if address.version == 4 and address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import graphene
|
||||
|
||||
from ipam import filtersets, models
|
||||
from .mixins import IPAddressesMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
|
||||
@@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
class FHRPGroupType(NetBoxObjectType):
|
||||
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroup
|
||||
|
||||
@@ -844,6 +844,25 @@ class IPAddress(PrimaryModel):
|
||||
'address': _("Cannot create IP address with /0 mask.")
|
||||
})
|
||||
|
||||
# Do not allow assigning a network ID or broadcast address to an interface.
|
||||
if self.assigned_object:
|
||||
if self.address.ip == self.address.network:
|
||||
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
if self.address.version == 4 and self.address.prefixlen not in (31, 32):
|
||||
raise ValidationError(msg)
|
||||
if self.address.version == 6 and self.address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if (
|
||||
self.address.version == 4 and self.address.ip == self.address.broadcast and
|
||||
self.address.prefixlen not in (31, 32)
|
||||
):
|
||||
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
|
||||
ip=self.address.ip
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<td>
|
||||
{{ object.scheduled|annotated_date|placeholder }}
|
||||
{% if object.interval %}
|
||||
({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %})
|
||||
({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %})
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -51,36 +51,43 @@ def parse_alphanumeric_range(string):
|
||||
'0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
|
||||
"""
|
||||
values = []
|
||||
for dash_range in string.split(','):
|
||||
for value in string.split(','):
|
||||
if '-' not in value:
|
||||
# Item is not a range
|
||||
values.append(value)
|
||||
continue
|
||||
|
||||
# Find the range's beginning & end values
|
||||
try:
|
||||
begin, end = dash_range.split('-')
|
||||
begin, end = value.split('-')
|
||||
vals = begin + end
|
||||
# Break out of loop if there's an invalid pattern to return an error
|
||||
if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
|
||||
return []
|
||||
except ValueError:
|
||||
begin, end = dash_range, dash_range
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
|
||||
# Numeric range
|
||||
if begin.isdigit() and end.isdigit():
|
||||
if int(begin) >= int(end):
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
|
||||
|
||||
raise forms.ValidationError(
|
||||
_('Invalid range: Ending value ({end}) must be greater than beginning value ({begin}).').format(
|
||||
begin=begin, end=end
|
||||
)
|
||||
)
|
||||
for n in list(range(int(begin), int(end) + 1)):
|
||||
values.append(n)
|
||||
|
||||
# Alphanumeric range
|
||||
else:
|
||||
# Value-based
|
||||
if begin == end:
|
||||
values.append(begin)
|
||||
# Range-based
|
||||
else:
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
if ord(begin) >= ord(end):
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
|
||||
if ord(begin) >= ord(end):
|
||||
raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
|
||||
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
return values
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,16 @@ class ExpandAlphanumeric(TestCase):
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set(self):
|
||||
def test_set_numeric(self):
|
||||
input = 'r[1,2]a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
'r2a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set_alpha(self):
|
||||
input = '[r,t]1a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dcim.graphql.types import ComponentObjectType
|
||||
from extras.graphql.mixins import ConfigContextMixin
|
||||
from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
|
||||
from virtualization import filtersets, models
|
||||
@@ -38,7 +38,7 @@ class ClusterTypeType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.ClusterTypeFilterSet
|
||||
|
||||
|
||||
class VirtualMachineType(ConfigContextMixin, NetBoxObjectType):
|
||||
class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualMachine
|
||||
|
||||
@@ -33,6 +33,15 @@ VMINTERFACE_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunnel_add' %}?termination1_type=virtualization.virtualmachine&termination1_parent={{ record.virtual_machine.pk }}&termination1_termination={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunneltermination_delete' pk=record.tunnel_termination.pk %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Remove tunnel" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -64,6 +73,10 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
||||
role = columns.ColoredLabelColumn(
|
||||
verbose_name=_('Role'),
|
||||
)
|
||||
platform = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Platform')
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@@ -97,9 +110,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
|
||||
'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments',
|
||||
'config_template', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus',
|
||||
'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', 'config_template',
|
||||
'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -124,7 +124,7 @@ class EncryptionAlgorithmChoices(ChoiceSet):
|
||||
(ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
|
||||
(ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
|
||||
(ENCRYPTION_3DES, '3DES'),
|
||||
(ENCRYPTION_3DES, 'DES'),
|
||||
(ENCRYPTION_DES, 'DES'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user