diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 2b7dcd4e4..797149643 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -8,6 +8,7 @@ * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI +* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis @@ -29,4 +30,5 @@ * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) +* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers * secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index f09f8c828..102929476 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,5 +1,4 @@ from collections import OrderedDict -from itertools import count, groupby from django.conf import settings from django.contrib.auth.models import User @@ -22,7 +21,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.mptt import TreeManager -from utilities.utils import serialize_object +from utilities.utils import array_to_string, serialize_object from .devices import Device from .power import PowerFeed @@ -642,9 +641,4 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): @property def unit_list(self): - """ - Express the assigned units as a string of summarized ranges. For example: - [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" - """ - group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + return array_to_string(self.units) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 18f42186f..d40c9bb29 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -117,4 +117,4 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service - fields = ['id', 'url', 'name', 'protocol', 'port'] + fields = ['id', 'url', 'name', 'protocol', 'ports'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 6be0b8a42..074cba9d6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -282,6 +282,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Service fields = [ - 'id', 'url', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags', + 'id', 'url', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 79fc05334..69453ea6c 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface @@ -542,11 +542,15 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): to_field_name='name', label='Virtual machine (name)', ) + port = NumericArrayFilter( + field_name='ports', + lookup_expr='contains' + ) tag = TagFilter() class Meta: model = Service - fields = ['id', 'name', 'protocol', 'port'] + fields = ['id', 'name', 'protocol'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1e8e9038a..b10af8c4c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,8 +10,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, + ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, VirtualMachine, VMInterface from .choices import * @@ -1155,9 +1155,12 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class ServiceForm(BootstrapMixin, CustomFieldModelForm): - port = forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -1167,7 +1170,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Service fields = [ - 'name', 'protocol', 'port', 'ipaddresses', 'description', 'tags', + 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', ] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " @@ -1244,11 +1247,11 @@ class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit required=False, widget=StaticSelect2() ) - port = forms.IntegerField( - validators=[ - MinValueValidator(1), - MaxValueValidator(65535), - ], + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), required=False ) description = forms.CharField( diff --git a/netbox/ipam/migrations/0039_service_ports_array.py b/netbox/ipam/migrations/0039_service_ports_array.py new file mode 100644 index 000000000..63e592c03 --- /dev/null +++ b/netbox/ipam/migrations/0039_service_ports_array.py @@ -0,0 +1,43 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +def replicate_ports(apps, schema_editor): + Service = apps.get_model('ipam', 'Service') + # TODO: Figure out how to cast IntegerField to an array so we can use .update() + for service in Service.objects.all(): + Service.objects.filter(pk=service.pk).update(ports=[service.port]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0038_custom_field_data'), + ] + + operations = [ + migrations.AddField( + model_name='service', + name='ports', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(65535) + ] + ), + default=[], + size=None + ), + preserve_default=False, + ), + + migrations.AlterModelOptions( + name='service', + options={'ordering': ('protocol', 'ports', 'pk')}, + ), + migrations.RunPython( + code=replicate_ports + ), + ] diff --git a/netbox/ipam/migrations/0040_service_drop_port.py b/netbox/ipam/migrations/0040_service_drop_port.py new file mode 100644 index 000000000..d1db82678 --- /dev/null +++ b/netbox/ipam/migrations/0040_service_drop_port.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0039_service_ports_array'), + ] + + operations = [ + migrations.RemoveField( + model_name='service', + name='port', + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 6e7cb0bd4..f34ed3749 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,6 +2,7 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey 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 @@ -13,7 +14,7 @@ from dcim.models import Device, Interface from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object +from utilities.utils import array_to_string, serialize_object from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * @@ -1008,12 +1009,14 @@ class Service(ChangeLoggedModel, CustomFieldModel): max_length=50, choices=ServiceProtocolChoices ) - port = models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ], - verbose_name='Port number' + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' ) ipaddresses = models.ManyToManyField( to='ipam.IPAddress', @@ -1029,13 +1032,13 @@ class Service(ChangeLoggedModel, CustomFieldModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] class Meta: - ordering = ('protocol', 'port', 'pk') # (protocol, port) may be non-unique + ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique def __str__(self): - return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' def get_absolute_url(self): return reverse('ipam:service', args=[self.pk]) @@ -1058,6 +1061,10 @@ class Service(ChangeLoggedModel, CustomFieldModel): self.virtual_machine.name if self.virtual_machine else None, self.name, self.get_protocol_display(), - self.port, + self.ports, self.description, ) + + @property + def port_list(self): + return array_to_string(self.ports) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index d7a64f7db..532b0bcad 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -623,11 +623,15 @@ class ServiceTable(BaseTable): parent = tables.LinkColumn( order_by=('device', 'virtual_machine') ) + ports = tables.TemplateColumn( + template_code='{{ record.port_list }}', + verbose_name='Ports' + ) tags = TagColumn( url_name='ipam:service_list' ) class Meta(BaseTable.Meta): model = Service - fields = ('pk', 'name', 'parent', 'protocol', 'port', 'ipaddresses', 'description', 'tags') - default_columns = ('pk', 'name', 'parent', 'protocol', 'port', 'description') + fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags') + default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 3f2ac470d..4f514aab0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -428,7 +428,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service - brief_fields = ['id', 'name', 'port', 'protocol', 'url'] + brief_fields = ['id', 'name', 'ports', 'protocol', 'url'] @classmethod def setUpTestData(cls): @@ -444,9 +444,9 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): Device.objects.bulk_create(devices) services = ( - Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1), - Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2), - Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=3), + Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]), + Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]), ) Service.objects.bulk_create(services) @@ -455,18 +455,18 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'device': devices[1].pk, 'name': 'Service 4', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 4, + 'ports': [4], }, { 'device': devices[1].pk, 'name': 'Service 5', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 5, + 'ports': [5], }, { 'device': devices[1].pk, 'name': 'Service 6', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 6, + 'ports': [6], }, ] diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 560313f0a..1ecf5a486 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -742,12 +742,12 @@ class ServiceTestCase(TestCase): VirtualMachine.objects.bulk_create(virtual_machines) services = ( - Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1001), - Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1002), - Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=1003), - Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2001), - Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2002), - Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=2003), + Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), + Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]), + Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]), + Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]), + Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]), + Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]), ) Service.objects.bulk_create(services) @@ -764,8 +764,8 @@ class ServiceTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_port(self): - params = {'port': ['1001', '1002', '1003']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'port': '1001'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_device(self): devices = Device.objects.all()[:2] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 7992e4ddf..fc595ac9c 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -373,9 +373,9 @@ class ServiceTestCase( device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) Service.objects.bulk_create([ - Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), - Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=102), - Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), + Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), + Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), + Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), ]) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') @@ -385,14 +385,14 @@ class ServiceTestCase( 'virtual_machine': None, 'name': 'Service X', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 999, + 'ports': '104,105', 'ipaddresses': [], 'description': 'A new service', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "device,name,protocol,port,description", + "device,name,protocol,ports,description", "Device 1,Service 1,tcp,1,First service", "Device 1,Service 2,tcp,2,Second service", "Device 1,Service 3,udp,3,Third service", @@ -400,6 +400,6 @@ class ServiceTestCase( cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, - 'port': 888, + 'ports': '106,107', 'description': 'New description', } diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f0e2607e..0978ddd8e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -843,9 +843,6 @@ class ServiceEditView(ObjectEditView): ) return obj - def get_return_url(self, request, service): - return service.parent.get_absolute_url() - class ServiceBulkImportView(BulkImportView): queryset = Service.objects.all() diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 9611be175..43f39096f 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -1,13 +1,10 @@