From b821cd2b1f213394090289f115f83f8caaf43521 Mon Sep 17 00:00:00 2001 From: Iva Kaneva Date: Thu, 15 Dec 2016 18:07:10 +0200 Subject: [PATCH] Service Port functionality #539 (#590) * Add ServicePort model * Add ServicePort panel in Device page * Add "Add ServicePort" functionality * Add "Edit" and "Delete" Service port functionality * Add ServicePort API serializers * Rename ServicePort.type to ServicePort.protocol * Add IPv6 support for assigning a ServicePort to all device's IPs * Make ServicePort bound to Device and make IPAddres optional * Move ServicePort related urls and views from `ipam` to `dcim` * Move serviceport templates to `dcim` folder * Move ServicePort api serialization from `ipam` to `dcim` * Rename `serviceport_assign` to `serviceport_add` ...to keep consistency with the naming convention along the rest of device related components * Move ServicePort mode definition to `dcim` * add service port specific permissions --- netbox/dcim/api/serializers.py | 31 +++++++- netbox/dcim/api/urls.py | 4 + netbox/dcim/api/views.py | 22 +++++- netbox/dcim/forms.py | 53 ++++++++++++- .../dcim/migrations/0022_add_service_port.py | 40 ++++++++++ netbox/dcim/models.py | 70 ++++++++++++++++ netbox/dcim/urls.py | 6 ++ netbox/dcim/views.py | 79 ++++++++++++++++++- netbox/ipam/forms.py | 2 +- netbox/ipam/models.py | 2 - netbox/ipam/views.py | 3 +- netbox/templates/dcim/device.html | 24 ++++++ netbox/templates/dcim/inc/_port.html | 33 ++++++++ netbox/templates/dcim/serviceport.html | 76 ++++++++++++++++++ netbox/templates/dcim/serviceport_add.html | 43 ++++++++++ netbox/templates/dcim/serviceport_edit.html | 26 ++++++ 16 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 netbox/dcim/migrations/0022_add_service_port.py create mode 100644 netbox/templates/dcim/inc/_port.html create mode 100644 netbox/templates/dcim/serviceport.html create mode 100644 netbox/templates/dcim/serviceport_add.html create mode 100644 netbox/templates/dcim/serviceport_edit.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ef7a4be605f..d96ec4c7089 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,7 +6,7 @@ DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, -) + ServicePort) from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -442,3 +442,32 @@ class InterfaceConnectionSerializer(serializers.ModelSerializer): class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + +# +# Service Ports +# + +class ServicePortSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + ip_address = DeviceIPAddressNestedSerializer() + + class Meta: + model = ServicePort + fields = ['id', 'device', 'ip_address', 'port', 'protocol', 'name', 'description'] + + +class ServicePortNestedSerializer(ServicePortSerializer): + device = DeviceNestedSerializer() + ip_address = DeviceIPAddressNestedSerializer() + + class Meta(ServicePortSerializer.Meta): + fields = ['id', 'device', 'ip_address', 'port', 'protocol'] + + +class ServicePortDetailSerializer(ServicePortSerializer): + device = DeviceNestedSerializer() + ip_address = DeviceIPAddressNestedSerializer() + + class Meta(ServicePortSerializer.Meta): + fields = ['id', 'device', 'ip_address', 'port', 'protocol', 'name', 'description'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 23787f4b422..e8a4f450834 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -69,6 +69,10 @@ url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'), url(r'^interface-connections/(?P\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'), + # Service ports + url(r'^service-ports/$', ServicePortListView.as_view(), name='serviceport_list'), + url(r'^service-ports/(?P\d+)/$', ServicePortDetailView.as_view(), name='serviceport_detail'), + # Miscellaneous url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'), url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0322208eece..6e7d7148cf4 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -12,7 +12,7 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, -) + ServicePort) from dcim import filters from extras.api.views import CustomFieldModelAPIView from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer @@ -422,6 +422,26 @@ def get(self, request, pk): return Response(lldp_neighbors) +# +# Service Port +# + +class ServicePortListView(generics.ListAPIView): + """ + List IP addresses (filterable) + """ + queryset = ServicePort.objects.select_related('device', 'ip_address', 'port', 'protocol', 'name', 'description') + serializer_class = serializers.ServicePortSerializer + + +class ServicePortDetailView(generics.RetrieveAPIView): + """ + Retrieve a single IP address + """ + queryset = ServicePort.objects.select_related('device', 'ip_address', 'port', 'protocol', 'name', 'description') + serializer_class = serializers.ServicePortSerializer + + # # Miscellaneous # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f5cec6474ed..9a87f37e5fe 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -18,7 +18,7 @@ ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, - Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + Rack, RackGroup, RackRole, Site, ServicePort, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD ) @@ -1275,6 +1275,57 @@ def __init__(self, device, *args, **kwargs): self.fields['set_as_primary'].initial = True +# +# Service Port +# + +class ServicePortForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = ServicePort + fields = ['device', 'ip_address', 'protocol', 'port', 'name', 'description'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class ServicePortCreateForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = ServicePort + fields = ['ip_address', 'protocol', 'port', 'name', 'description'] + help_texts = { + 'port': '0-65535', + 'name': 'Service running on this port', + 'description': 'Service description' + } + labels = { + 'ip_address': "IP Address", + } + + def __init__(self, device, *args, **kwargs): + super(ServicePortCreateForm, self).__init__(*args, **kwargs) + self.fields['ip_address'].queryset = IPAddress.objects.filter(interface__device=device) + + +class ServiceEditForm(forms.ModelForm, BootstrapMixin): + class Meta: + model = ServicePort + fields = ['ip_address', 'protocol', 'port', 'name', 'description'] + help_texts = { + 'port': '0-65535', + 'name': 'Service running on this port', + 'description': 'Service description' + } + labels = { + 'ip_address': "IP Address", + } + + def __init__(self, *args, **kwargs): + super(ServiceEditForm, self).__init__(*args, **kwargs) + self.fields['ip_address'].queryset = IPAddress.objects.filter(interface__device=kwargs['instance'].device) + + # # Modules # diff --git a/netbox/dcim/migrations/0022_add_service_port.py b/netbox/dcim/migrations/0022_add_service_port.py new file mode 100644 index 00000000000..0465182572e --- /dev/null +++ b/netbox/dcim/migrations/0022_add_service_port.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-25 23:50 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0010_ipaddress_help_texts'), + ('dcim', '0021_add_ff_flexstack'), + ] + + operations = [ + migrations.CreateModel( + name='ServicePort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')], default=0)), + ('port', models.PositiveIntegerField()), + ('name', models.CharField(max_length=30)), + ('description', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_ports', to='dcim.Device', verbose_name=b'device')), + ('ip_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_ports', to='ipam.IPAddress', verbose_name=b'ip_address')), + ], + options={ + 'ordering': ['device', 'ip_address', 'port'], + 'verbose_name': 'Service Port', + 'verbose_name_plural': 'Service Ports', + }, + ), + migrations.AlterUniqueTogether( + name='serviceport', + unique_together={('device', 'ip_address', 'port', 'protocol')}, + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 784f4ef02fb..9ce7c0b4f79 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -181,6 +181,12 @@ ] +SERVICE_PORT_CHOICES = ( + (6, 'TCP'), + (17, 'UDP'), +) + + def order_interfaces(queryset, sql_col, primary_ordering=tuple()): """ Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the @@ -1246,3 +1252,67 @@ def __unicode__(self): def get_parent_url(self): return reverse('dcim:device_inventory', args=[self.device.pk]) + + +class ServicePort(CreatedUpdatedModel): + """ + A ServicePort represents a port on a specific IPAddress on which a service is running. + The port can be one of 2 predefined protocols - TCP or UDP. + A ServicePort is always associated with a specific IPAddress on a Device. + + The combination of IPAddress, Port Number and Port Protocol is always unique for ServicePort. + + If a port number + port protocol combination is already assigned to no specific IPAddress + that means it is assigned on all IPs on the device + """ + + device = models.ForeignKey('Device', related_name='service_ports', on_delete=models.CASCADE, + blank=False, null=False, verbose_name='device') + + ip_address = models.ForeignKey('ipam.IPAddress', related_name='service_ports', on_delete=models.CASCADE, + blank=True, null=True, verbose_name='ip_address') + protocol = models.PositiveSmallIntegerField(choices=SERVICE_PORT_CHOICES, default=0) + + port = models.PositiveIntegerField() + name = models.CharField(max_length=30, blank=False, null=False) + description = models.TextField(blank=True) + + class Meta: + ordering = ['device', 'ip_address', 'port'] + verbose_name = 'Service Port' + verbose_name_plural = 'Service Ports' + unique_together = ['device', 'ip_address', 'port', 'protocol'] + + def __unicode__(self): + port_protocol = dict(SERVICE_PORT_CHOICES).get(self.protocol) + return u'{}/{}'.format(self.port, port_protocol) + + def get_absolute_url(self): + return reverse('dcim:serviceport', args=[self.pk]) + + @property + def short_description(self): + if self.description: + return self.description[:30] + return None + + def clean(self): + # if port is already assigned to no specific IPAddress + # that means it is assigned on all IPs on the device + port_assigned_on_all_ips = bool(ServicePort.objects.filter( + ip_address__address=None, port=self.port, protocol=self.protocol).exclude(pk=self.id)) + if port_assigned_on_all_ips: + raise ValidationError( + 'Port already assigned all IPAddresses for this device') + + def save(self, *args, **kwargs): + super(ServicePort, self).save(*args, **kwargs) + + def to_csv(self): + return ','.join([ + str(self.device_id), + self.ip_address, + self.port, + self.name, + self.description, + ]) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 3ec01811636..7e19e07dff3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -168,4 +168,10 @@ url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), url(r'^modules/(?P\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'), + # ServicePorts + url(r'^devices/(?P\d+)/service-ports/add/$', views.serviceport_add, name='serviceport_add'), + url(r'^service-ports/(?P\d+)/$', views.serviceport, name='serviceport'), + url(r'^service-ports/(?P\d+)/edit/$', views.ServicePortEditView.as_view(), name='serviceport_edit'), + url(r'^service-ports/(?P\d+)/delete/$', views.ServicePortDeleteView.as_view(), name='serviceport_delete'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index de4fe4228d9..48d1db7c9d5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -27,7 +27,7 @@ CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackRole, Site, + RackRole, ServicePort, Site, ) @@ -575,6 +575,7 @@ def device(request, pk): # Find all IP addresses assigned to this device ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ .order_by('address') + service_ports = ServicePort.objects.filter(device=device).order_by('ip_address', 'port') # Find any related devices for convenient linking in the UI related_devices = [] @@ -604,6 +605,7 @@ def device(request, pk): 'mgmt_interfaces': mgmt_interfaces, 'device_bays': device_bays, 'ip_addresses': ip_addresses, + 'service_ports': service_ports, 'secrets': secrets, 'related_devices': related_devices, 'show_graphs': show_graphs, @@ -1600,6 +1602,81 @@ def ipaddress_assign(request, pk): }) +# +# Service Ports +# + +def serviceport(request, pk): + service_port = get_object_or_404(ServicePort.objects.select_related('device'), pk=pk) + + return render(request, 'dcim/serviceport.html', { + 'service_port': service_port, + }) + + +class ServicePortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_serviceport' + model = ServicePort + form_class = forms.ServiceEditForm + fields_initial = ['ip_address', 'port' 'protocol', 'name', 'description'] + template_name = 'dcim/serviceport_edit.html' + + def post(self, request, *args, **kwargs): + service_port = self.get_object(kwargs) + device_url = reverse('dcim:device', kwargs={'pk': service_port.device.pk}) + self.success_url = device_url + self.cancel_url = device_url + + return super(ServicePortEditView, self).post(request, *args, **kwargs) + + +class ServicePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_serviceport' + model = ServicePort + + def post(self, request, *args, **kwargs): + service_port = self.get_object(kwargs) + self.redirect_url = reverse('dcim:device', kwargs={'pk': service_port.device.pk}) + + return super(ServicePortDeleteView, self).post(request, *args, **kwargs) + + +@permission_required(['dcim.change_device', 'dcim.add_serviceport']) +def serviceport_add(request, pk): + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = forms.ServicePortCreateForm(device, request.POST) + if form.is_valid(): + serv_form = forms.ServicePortForm({ + 'device': device.pk, + 'ip_address': form.cleaned_data['ip_address'].pk if form.cleaned_data['ip_address'] else None, + 'protocol': form.cleaned_data['protocol'], + 'port': form.cleaned_data['port'], + 'name': form.cleaned_data['name'], + 'description': form.cleaned_data['description'], + }) + if serv_form.is_valid(): + service_port = serv_form.save(commit=False) + service_port.save() + messages.success(request, "Added new port {0} for device {1}".format(service_port, + service_port.device)) + + if '_addanother' in request.POST: + return redirect('dcim:serviceport_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = forms.ServicePortCreateForm(device) + + return render(request, 'dcim/serviceport_add.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + # # Modules # diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 08bb5db0455..0ed89bc06ea 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,7 +10,7 @@ from .models import ( Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, - VLAN_STATUS_CHOICES, VRF, + VLAN_STATUS_CHOICES, VRF ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 5f28acaed13..1079f176c1c 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -13,10 +13,8 @@ from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel from utilities.sql import NullsFirstQuerySet - from .fields import IPNetworkField, IPAddressField - AF_CHOICES = ( (4, 'IPv4'), (6, 'IPv6'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0ad11a38c69..d56ea581864 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,8 @@ ) from . import filters, forms, tables -from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from .models import (Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, + Prefix, RIR, Role, VLAN, VLANGroup, VRF) def add_available_prefixes(parent, prefix_list): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 71b9b175ba5..36c032bfe72 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -205,6 +205,30 @@ {% endif %} {% endif %} +
+
+ Service Ports +
+ {% if service_ports %} + + {% for port in service_ports %} + {% include 'dcim/inc/_port.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
+ {% endif %} + {% if perms.dcim.add_serviceport %} + + {% endif %} +
Critical Connections diff --git a/netbox/templates/dcim/inc/_port.html b/netbox/templates/dcim/inc/_port.html new file mode 100644 index 00000000000..37c923b1eb2 --- /dev/null +++ b/netbox/templates/dcim/inc/_port.html @@ -0,0 +1,33 @@ + + + {% if port.ip_address %} + {{ port.ip_address }} + {% else %} + All IP addresses + {% endif %} + + + {{ port }} + + {{ port.name }} + + {% if port.short_description %} + {{ port.short_description }} + {% if port.short_description != port.description %} + ... + {% endif %} + {% endif %} + + + {% if perms.dcim.change_serviceport%} + + + + {% endif %} + {% if perms.dcim.delete_serviceport %} + + + + {% endif %} + + diff --git a/netbox/templates/dcim/serviceport.html b/netbox/templates/dcim/serviceport.html new file mode 100644 index 00000000000..4bc3a6f0617 --- /dev/null +++ b/netbox/templates/dcim/serviceport.html @@ -0,0 +1,76 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}{{ ipaddress }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if perms.dcim.change_serviceport %} + + + Edit this Service Port + + {% endif %} + {% if perms.dcim.delete_serviceport %} + + + Delete this Service Port + + {% endif %} +
+

{{ service_port }}

+
+
+
+
+ Service Port +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Device + + + {{ service_port.device }} ({{ service_port.device }}) + +
IP Address{{ service_port.ip_address }}
Name{{ service_port.name }}
Description + {% if service_port.description %} + {{ service_port.description }} + {% else %} + N/A + {% endif %} +
Created{{ service_port.created }}
Last Updated{{ service_port.last_updated }}
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/serviceport_add.html b/netbox/templates/dcim/serviceport_add.html new file mode 100644 index 00000000000..bb7ff2aceca --- /dev/null +++ b/netbox/templates/dcim/serviceport_add.html @@ -0,0 +1,43 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Add a Service Port{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ Add a Service Port +
+
+
+ +
+

{{ device }}

+
+
+ {% render_form form %} +
+
+
+
+ + + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/serviceport_edit.html b/netbox/templates/dcim/serviceport_edit.html new file mode 100644 index 00000000000..c662205c3a0 --- /dev/null +++ b/netbox/templates/dcim/serviceport_edit.html @@ -0,0 +1,26 @@ +{% extends 'utilities/obj_edit.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block form %} +
+
Service Port
+
+
+ +
+

{{ obj.device }}

+
+
+ {% render_field form.ip_address %} + {% render_field form.port %} + {% render_field form.protocol %} + {% render_field form.name %} + {% render_field form.description %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %}