Merge branch 'develop-2.10' into 1503-secret-assignment

This commit is contained in:
Jeremy Stretch 2020-09-22 09:24:03 -04:00 committed by GitHub
commit d44c2ba8fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 158 additions and 73 deletions

View File

@ -8,6 +8,7 @@
* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#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 * [#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 * [#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 * [#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 * [#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` * dcim.VirtualChassis: Added `custom_fields`
* extras.ExportTemplate: The `template_language` field has been removed * extras.ExportTemplate: The `template_language` field has been removed
* extras.Graph: This API endpoint has been removed (see #4349) * 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`. * 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`.

View File

@ -1,5 +1,4 @@
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -22,7 +21,7 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager 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 .devices import Device
from .power import PowerFeed from .power import PowerFeed
@ -642,9 +641,4 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel):
@property @property
def unit_list(self): def unit_list(self):
""" return array_to_string(self.units)
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)

View File

@ -117,4 +117,4 @@ class NestedServiceSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = models.Service model = models.Service
fields = ['id', 'url', 'name', 'protocol', 'port'] fields = ['id', 'url', 'name', 'protocol', 'ports']

View File

@ -282,6 +282,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta: class Meta:
model = Service model = Service
fields = [ 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', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter,
TreeNodeMultipleChoiceFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
@ -542,11 +542,15 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
to_field_name='name', to_field_name='name',
label='Virtual machine (name)', label='Virtual machine (name)',
) )
port = NumericArrayFilter(
field_name='ports',
lookup_expr='contains'
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:
model = Service model = Service
fields = ['id', 'name', 'protocol', 'port'] fields = ['id', 'name', 'protocol']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -10,8 +10,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, ReturnURLForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, VirtualMachine, VMInterface from virtualization.models import Cluster, VirtualMachine, VMInterface
from .choices import * from .choices import *
@ -1155,9 +1155,12 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
# #
class ServiceForm(BootstrapMixin, CustomFieldModelForm): class ServiceForm(BootstrapMixin, CustomFieldModelForm):
port = forms.IntegerField( ports = NumericArrayField(
min_value=SERVICE_PORT_MIN, base_field=forms.IntegerField(
max_value=SERVICE_PORT_MAX 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( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
@ -1167,7 +1170,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'name', 'protocol', 'port', 'ipaddresses', 'description', 'tags', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
] ]
help_texts = { help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " '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, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
port = forms.IntegerField( ports = NumericArrayField(
validators=[ base_field=forms.IntegerField(
MinValueValidator(1), min_value=SERVICE_PORT_MIN,
MaxValueValidator(65535), max_value=SERVICE_PORT_MAX
], ),
required=False required=False
) )
description = forms.CharField( description = forms.CharField(

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models 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.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet 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 virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
from .constants import * from .constants import *
@ -1008,12 +1009,14 @@ class Service(ChangeLoggedModel, CustomFieldModel):
max_length=50, max_length=50,
choices=ServiceProtocolChoices choices=ServiceProtocolChoices
) )
port = models.PositiveIntegerField( ports = ArrayField(
validators=[ base_field=models.PositiveIntegerField(
MinValueValidator(SERVICE_PORT_MIN), validators=[
MaxValueValidator(SERVICE_PORT_MAX) MinValueValidator(SERVICE_PORT_MIN),
], MaxValueValidator(SERVICE_PORT_MAX)
verbose_name='Port number' ]
),
verbose_name='Port numbers'
) )
ipaddresses = models.ManyToManyField( ipaddresses = models.ManyToManyField(
to='ipam.IPAddress', to='ipam.IPAddress',
@ -1029,13 +1032,13 @@ class Service(ChangeLoggedModel, CustomFieldModel):
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description']
class Meta: 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): 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): def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk]) 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.virtual_machine.name if self.virtual_machine else None,
self.name, self.name,
self.get_protocol_display(), self.get_protocol_display(),
self.port, self.ports,
self.description, self.description,
) )
@property
def port_list(self):
return array_to_string(self.ports)

View File

@ -623,11 +623,15 @@ class ServiceTable(BaseTable):
parent = tables.LinkColumn( parent = tables.LinkColumn(
order_by=('device', 'virtual_machine') order_by=('device', 'virtual_machine')
) )
ports = tables.TemplateColumn(
template_code='{{ record.port_list }}',
verbose_name='Ports'
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Service model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'port', 'ipaddresses', 'description', 'tags') fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'port', 'description') default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -428,7 +428,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
class ServiceTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase):
model = Service model = Service
brief_fields = ['id', 'name', 'port', 'protocol', 'url'] brief_fields = ['id', 'name', 'ports', 'protocol', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -444,9 +444,9 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1), Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2), Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]),
Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=3), Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
@ -455,18 +455,18 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 4', 'name': 'Service 4',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 4, 'ports': [4],
}, },
{ {
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 5', 'name': 'Service 5',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 5, 'ports': [5],
}, },
{ {
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 6', 'name': 'Service 6',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 6, 'ports': [6],
}, },
] ]

View File

@ -742,12 +742,12 @@ class ServiceTestCase(TestCase):
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1001), Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1002), Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=1003), 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, port=2001), 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, port=2002), 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, port=2003), Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
@ -764,8 +764,8 @@ class ServiceTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_port(self): def test_port(self):
params = {'port': ['1001', '1002', '1003']} params = {'port': '1001'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]

View File

@ -373,9 +373,9 @@ class ServiceTestCase(
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
Service.objects.bulk_create([ Service.objects.bulk_create([
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=102), Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@ -385,14 +385,14 @@ class ServiceTestCase(
'virtual_machine': None, 'virtual_machine': None,
'name': 'Service X', 'name': 'Service X',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 999, 'ports': '104,105',
'ipaddresses': [], 'ipaddresses': [],
'description': 'A new service', 'description': 'A new service',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
"device,name,protocol,port,description", "device,name,protocol,ports,description",
"Device 1,Service 1,tcp,1,First service", "Device 1,Service 1,tcp,1,First service",
"Device 1,Service 2,tcp,2,Second service", "Device 1,Service 2,tcp,2,Second service",
"Device 1,Service 3,udp,3,Third service", "Device 1,Service 3,udp,3,Third service",
@ -400,6 +400,6 @@ class ServiceTestCase(
cls.bulk_edit_data = { cls.bulk_edit_data = {
'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
'port': 888, 'ports': '106,107',
'description': 'New description', 'description': 'New description',
} }

View File

@ -843,9 +843,6 @@ class ServiceEditView(ObjectEditView):
) )
return obj return obj
def get_return_url(self, request, service):
return service.parent.get_absolute_url()
class ServiceBulkImportView(BulkImportView): class ServiceBulkImportView(BulkImportView):
queryset = Service.objects.all() queryset = Service.objects.all()

View File

@ -1,13 +1,10 @@
<tr> <tr>
<td> <td><a href="{{ service.get_absolute_url }}">{{ service.name }}</a></td>
<a href="{{ service.get_absolute_url }}">{{ service.name }}</a> <td>{{ service.get_protocol_display }}</td>
</td> <td>{{ service.port_list }}</td>
<td>
{{ service.get_protocol_display }}/{{ service.port }}
</td>
<td> <td>
{% for ip in service.ipaddresses.all %} {% for ip in service.ipaddresses.all %}
<span>{{ ip.address.ip }}</span><br /> <a href="{{ ip.get_absolute_url }}">{{ ip.address.ip }}</a><br />
{% empty %} {% empty %}
<span class="text-muted">All IPs</span> <span class="text-muted">All IPs</span>
{% endfor %} {% endfor %}
@ -18,7 +15,7 @@
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_service %} {% if perms.ipam.change_service %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service"> <a href="{% url 'ipam:service_edit' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit service">
<i class="glyphicon glyphicon-pencil"></i> <i class="glyphicon glyphicon-pencil"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -62,8 +62,8 @@
<td>{{ service.get_protocol_display }}</td> <td>{{ service.get_protocol_display }}</td>
</tr> </tr>
<tr> <tr>
<td>Port</td> <td>Ports</td>
<td>{{ service.port }}</td> <td>{{ service.port_list }}</td>
</tr> </tr>
<tr> <tr>
<td>IP Addresses</td> <td>IP Addresses</td>

View File

@ -25,7 +25,7 @@
<label class="col-md-3 control-label required">Port</label> <label class="col-md-3 control-label required">Port</label>
<div class="col-md-9"> <div class="col-md-9">
{{ form.protocol }} {{ form.protocol }}
{{ form.port }} {{ form.ports }}
</div> </div>
</div> </div>
{% render_field form.ipaddresses %} {% render_field form.ipaddresses %}

View File

@ -68,7 +68,6 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
""" """
Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>] Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>]
""" """
def get_filter_predicate(self, v): def get_filter_predicate(self, v):
# null value filtering # null value filtering
if v is None: if v is None:
@ -84,7 +83,6 @@ class NullableCharFieldFilter(django_filters.CharFilter):
""" """
Allow matching on null field values by passing a special string used to signify NULL. Allow matching on null field values by passing a special string used to signify NULL.
""" """
def filter(self, qs, value): def filter(self, qs, value):
if value != settings.FILTERS_NULL_CHOICE_VALUE: if value != settings.FILTERS_NULL_CHOICE_VALUE:
return super().filter(qs, value) return super().filter(qs, value)
@ -107,6 +105,16 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class NumericArrayFilter(django_filters.NumberFilter):
"""
Filter based on the presence of an integer within an ArrayField.
"""
def filter(self, qs, value):
if value:
value = [value]
return super().filter(qs, value)
# #
# FilterSets # FilterSets
# #

View File

@ -1,6 +1,7 @@
import datetime import datetime
import json import json
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
@ -282,6 +283,16 @@ def curry(_curried_func, *args, **kwargs):
return _curried return _curried
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
# #
# Fake request object # Fake request object
# #