Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch
2024-03-15 12:32:54 -04:00
28 changed files with 152 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from extras.models import ObjectChange
__all__ = (
'ChangelogMixin',
'ConfigContextMixin',
'ContactsMixin',
'CustomFieldsMixin',
'ImageAttachmentsMixin',
'JournalEntriesMixin',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

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