Skip to content
Closed
16 changes: 13 additions & 3 deletions netbox/dcim/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
NestedVRFSerializer,
NestedVRFSerializer, NestedIPAddressFunctionAssignmentsSerializer
)
from ipam.models import ASN, VLAN
from ipam.models import ASN, VLAN, IPAddressFunctionAssignments
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
Expand Down Expand Up @@ -668,6 +668,7 @@ class DeviceSerializer(NetBoxModelSerializer):
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
ipaddressfunctions = serializers.SerializerMethodField()

class Meta:
model = Device
Expand All @@ -676,6 +677,7 @@ class Meta:
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'ipaddressfunctions',
]

@extend_schema_field(NestedDeviceSerializer)
Expand All @@ -689,6 +691,14 @@ def get_parent_device(self, obj):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data

@extend_schema_field(NestedDeviceSerializer)
def get_ipaddressfunctions(self, obj):
ct = ContentType.objects.get_for_model(obj)
ipaddrfuncs = IPAddressFunctionAssignments.objects.filter(assigned_object_type=ct, assigned_object_id=obj.id)
serializer = NestedIPAddressFunctionAssignmentsSerializer
context = {'request': self.context['request']}
return serializer(ipaddrfuncs, context=context, many=True).data


class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True)
Expand All @@ -699,7 +709,7 @@ class Meta(DeviceSerializer.Meta):
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
'created', 'last_updated',
'ipaddressfunctions', 'created', 'last_updated',
]

@extend_schema_field(serializers.JSONField(allow_null=True))
Expand Down
8 changes: 8 additions & 0 deletions netbox/dcim/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,9 @@ class Device(PrimaryModel, ConfigContextModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
ipaddressfunctions = GenericRelation(
to='ipam.IPAddressFunctionAssignments'
)

objects = ConfigContextModelQuerySet.as_manager()

Expand Down Expand Up @@ -1231,6 +1234,11 @@ class VirtualDeviceContext(PrimaryModel):
blank=True
)

# Generic relation
ipaddressfunctions = GenericRelation(
to='ipam.IPAddressFunctionAssignments'
)

class Meta:
ordering = ['name']
constraints = (
Expand Down
13 changes: 13 additions & 0 deletions netbox/ipam/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
'NestedFHRPGroupSerializer',
'NestedFHRPGroupAssignmentSerializer',
'NestedIPAddressSerializer',
'NestedIPAddressFunctionAssignmentsSerializer',
'NestedIPRangeSerializer',
'NestedL2VPNSerializer',
'NestedL2VPNTerminationSerializer',
Expand Down Expand Up @@ -205,6 +206,18 @@ class Meta:
fields = ['id', 'url', 'display', 'family', 'address']


class NestedIPAddressFunctionAssignmentsSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddressfunctionassignments-detail')
# assigned_object = serializers.SerializerMethodField(read_only=True)
assigned_ip = NestedIPAddressSerializer()

class Meta:
model = models.IPAddressFunctionAssignments
fields = [
'id', 'url', 'display', 'assigned_ip', 'function'
]


#
# Services
#
Expand Down
25 changes: 24 additions & 1 deletion netbox/ipam/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES, IPADDRESS_FUNCTION_ASSIGNMENT_MODELS
from ipam.models import *
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
Expand Down Expand Up @@ -446,6 +446,29 @@ def to_representation(self, instance):
}


class IPAddressFunctionAssignmentsSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddressfunctionassignments-detail')
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_FUNCTION_ASSIGNMENT_MODELS),
)
assigned_object = serializers.SerializerMethodField(read_only=True)
assigned_ip = NestedIPAddressSerializer()

class Meta:
model = IPAddressFunctionAssignments
fields = [
'id', 'url', 'display', 'assigned_ip', 'function',
'assigned_object_type', 'assigned_object_id', 'assigned_object',
'tags', 'custom_fields', 'created', 'last_updated',
]

@extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data


#
# Services
#
Expand Down
1 change: 1 addition & 0 deletions netbox/ipam/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
router.register('prefixes', views.PrefixViewSet)
router.register('ip-ranges', views.IPRangeViewSet)
router.register('ip-addresses', views.IPAddressViewSet)
router.register('ip-address-function-assignments', views.IPAddressFunctionAssignmentsViewSet)
router.register('fhrp-groups', views.FHRPGroupViewSet)
router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
router.register('vlan-groups', views.VLANGroupViewSet)
Expand Down
6 changes: 6 additions & 0 deletions netbox/ipam/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)


class IPAddressFunctionAssignmentsViewSet(NetBoxModelViewSet):
queryset = IPAddressFunctionAssignments.objects.prefetch_related('assigned_ip', 'assigned_object', 'tags')
serializer_class = serializers.IPAddressFunctionAssignmentsSerializer
# filterset_class = filtersets. # TODO add filterset


class FHRPGroupViewSet(NetBoxModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
serializer_class = serializers.FHRPGroupSerializer
Expand Down
13 changes: 13 additions & 0 deletions netbox/ipam/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ class IPAddressRoleChoices(ChoiceSet):
)


class IPAddressFunctionChoices(ChoiceSet):

FUNC_OOB = 'Out Of Band'
# Future planning, depreciate primary_ip
# FUNC_PRIMARY_IP = 'Primary IP'

CHOICES = (
(FUNC_OOB, 'Out Of Band', 'gray'),
# Future planning, depreciate primary_ip
# (FUNC_PRIMARY_IP, 'Primary IP', 'blue'),
)


#
# FHRP
#
Expand Down
6 changes: 6 additions & 0 deletions netbox/ipam/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@
)


IPADDRESS_FUNCTION_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='device') |
Q(app_label='dcim', model='virtualdevicecontext') |
Q(app_label='virtualization', model='VirtualMachine')
)

#
# FHRP groups
#
Expand Down
46 changes: 46 additions & 0 deletions netbox/ipam/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
'IPAddressBulkAddForm',
'IPAddressForm',
'IPRangeForm',
'IPAddressFunctionAssignmentsForm',
'L2VPNForm',
'L2VPNTerminationForm',
'PrefixForm',
Expand Down Expand Up @@ -422,6 +423,51 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
)


class IPAddressFunctionAssignmentsForm(NetBoxModelForm):
device_object = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
selector=True
)
vm_object = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
selector=True
)

class Meta:
model = IPAddressFunctionAssignments
fields = [
'assigned_ip', 'function', 'tags',
]

def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()

if instance:
if type(instance.assigned_object) is Device:
initial['device_object'] = instance.assigned_object
elif type(instance.assigned_object) is VirtualMachine:
initial['vm_object'] = instance.assigned_object
kwargs['initial'] = initial

super().__init__(*args, **kwargs)

def clean(self):
super().clean()

device_object = self.cleaned_data.get('device_object')
vm_object = self.cleaned_data.get('vm_object')

if not (device_object or vm_object):
raise ValidationError('An ip address function assignment must specify an device or virtualmachine.')
if len([x for x in (device_object, vm_object) if x]) > 1:
raise ValidationError('An ip address function assignment can only have one terminating object (a device or virtualmachine).')

self.instance.assigned_object = device_object or vm_object


class FHRPGroupForm(NetBoxModelForm):

# Optionally create a new IPAddress along with the FHRPGroup
Expand Down
44 changes: 44 additions & 0 deletions netbox/ipam/migrations/0067_ipaddressfunction_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 4.1.9 on 2023-07-06 20:08

from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.json


class Migration(migrations.Migration):

dependencies = [
('extras', '0092_delete_jobresult'),
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0066_iprange_mark_utilized'),
]

operations = [
migrations.CreateModel(
name='IPAddressFunction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('assigned_object_id', models.PositiveBigIntegerField()),
('function', models.CharField(max_length=50)),
('assigned_ip', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ipam.ipaddress')),
('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'device')), models.Q(('app_label', 'dcim'), ('model', 'virtualdevicecontext')), models.Q(('app_label', 'virtualization'), ('model', 'VirtualMachine')), _connector='OR')), on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'IP Address Function',
'ordering': ('function',),
},
),
migrations.AddConstraint(
model_name='ipaddressfunction',
constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id', 'function'), name='ipam_ipfunction_assigned_object'),
),
migrations.AddConstraint(
model_name='ipaddressfunction',
constraint=models.UniqueConstraint(fields=('assigned_ip',), name='ipam_ipfunction_ip_single_use'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.1.9 on 2023-07-06 20:51

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('extras', '0092_delete_jobresult'),
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0067_ipaddressfunction_and_more'),
]

operations = [
migrations.RenameModel(
old_name='IPAddressFunction',
new_name='IPAddressFunctionAssignments',
),
migrations.AlterModelOptions(
name='ipaddressfunctionassignments',
options={'ordering': ('function',), 'verbose_name': 'IP Address Function Assignments'},
),
]
1 change: 1 addition & 0 deletions netbox/ipam/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
'ASNRange',
'Aggregate',
'IPAddress',
'IPAddressFunctionAssignments',
'IPRange',
'FHRPGroup',
'FHRPGroupAssignment',
Expand Down
50 changes: 49 additions & 1 deletion netbox/ipam/models/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel

__all__ = (
'Aggregate',
'IPAddress',
'IPAddressFunctionAssignments',
'IPRange',
'Prefix',
'RIR',
Expand Down Expand Up @@ -667,6 +668,53 @@ def utilization(self):
return int(float(child_count) / self.size * 100)


class IPAddressFunctionAssignments(NetBoxModel):
assigned_object_type = models.ForeignKey(
to=ContentType,
limit_choices_to=IPADDRESS_FUNCTION_ASSIGNMENT_MODELS,
on_delete=models.CASCADE,
related_name='+'
)
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
assigned_ip = models.ForeignKey(
to='ipam.IPAddress',
on_delete=models.CASCADE,
related_name='+',
verbose_name='Assigned IP'
)
function = models.CharField(
max_length=50,
choices=IPAddressFunctionChoices,
help_text=_('Function to assign to ip')
)

class Meta:
ordering = ('function',)
verbose_name = 'IP Address Function Assignments'
constraints = (
models.UniqueConstraint(
fields=('assigned_object_type', 'assigned_object_id', 'function'),
name='ipam_ipfunction_assigned_object'
),
models.UniqueConstraint(
fields=('assigned_ip',),
name='ipam_ipfunction_ip_single_use'
),
)

def __str__(self):
if self.pk is not None:
return f'{self.assigned_object} - {self.function} - {self.assigned_ip}'
return super().__str__()

def get_absolute_url(self):
return reverse('ipam:ipaddressfunctionassignments', args=[self.pk])


class IPAddress(PrimaryModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
Expand Down
Loading