Compare commits

...

12 Commits

Author SHA1 Message Date
Martin Rødvand
576099e3b8 Formatting 2025-07-24 22:07:56 +02:00
Martin Rødvand
a5d1500307 Add conditional to hide internet dependent links in an isolated deployment 2025-07-24 22:04:41 +02:00
Jonathan Ramstedt
ffa9a52667 Closes #18936: add color name support for cable bulk import (#19949)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-24 09:54:49 -07:00
bctiemann
47320f9958 Merge pull request #19912 from miaow2/19903-regexp
Closes #19903: Add `regex` and `iregex` filter lookup expressions and corresponding tests
2025-07-24 12:32:19 -04:00
github-actions
d08a1bd07d Update source translation strings 2025-07-24 05:05:44 +00:00
Martin Hauser
14c4aeca54 Closes #19840 - Enable Site Filtering for Devices in Cable Bulk Import (#19923)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
* feat(dcim): Add site fields to Cable bulk import form

Introduces `side_a_site` and `side_b_site` fields for the Cable bulk
import form. Limits device choices on both sides to the selected site
for improved input validation and consistency.

* feat(dcim): Enhance test data setup with multiple sites

Refactors tests to create multiple sites and assign devices accordingly.
Updates CSV data to include `side_a_site` and `side_b_site` fields for
scenarios involving multiple sites. This improves test coverage and
alignment with real-world use cases.

* docs(dcim): Update comments explaining indent for CSV import

Improved the inline comments to clarify the rationale behind allowing
devices with duplicate names on different sites during CSV bulk import.
2025-07-23 15:50:05 -05:00
Jason Novinger
26bec1275f Fixes #19934: add description field to Tenant bulk edit form (#19937) 2025-07-23 13:41:00 -07:00
Jason Novinger
fa2d7f6516 Fixes #19916: restore Rack device representation behavior
The select list of 'Images and Label', 'Images Only', and 'Label Only'
was broken during recent work while implementing #19823.

This fixes the issue by placing the `rack_elevation` class attribute on
the <div> element that contains the SVG after being loaded by HTMX. In
addition, we needed to slightly modify the selectors in the frontend
code that looked for the elements within the SVG to hide and/or show.
Previously, it was looking inside of a contentDocument embedded in an
<object> element. The simplified version just looks inside of the
SVG containing div.
2025-07-23 08:45:40 -04:00
Marco Spizzuoco
d571cb4867 Closes #19902: add clip path to avoid overflow of device name, truncate text to improve centering (#19913)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-22 09:44:14 -07:00
bluikko
2129355c30 Closes #19926: Remove RHEL firewalld note
Closes: #19926
2025-07-22 08:04:53 -04:00
Artem Kotik
c40bfb1445 Add regex and iregex filter lookup expressions and corresponding tests 2025-07-18 16:56:54 +02:00
github-actions
b88b5b0b1b Update source translation strings
Some checks failed
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-07-16 05:06:12 +00:00
15 changed files with 513 additions and 360 deletions

View File

@@ -302,13 +302,6 @@ Quit the server with CONTROL-C.
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser. Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
!!! note
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
```no-highlight
firewall-cmd --zone=public --add-port=8000/tcp
```
!!! danger "Not for production use" !!! danger "Not for production use"
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**

View File

@@ -81,7 +81,7 @@ GET /api/ipam/vlans/?vid__gt=900
String based (char) fields (Name, Address, etc) support these lookup expressions: String based (char) fields (Name, Address, etc) support these lookup expressions:
| Filter | Description | | Filter | Description |
|---------|----------------------------------------| |----------|----------------------------------------|
| `n` | Not equal to | | `n` | Not equal to |
| `ic` | Contains (case-insensitive) | | `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) | | `nic` | Does not contain (case-insensitive) |
@@ -92,6 +92,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
| `ie` | Exact match (case-insensitive) | | `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) | | `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) | | `empty` | Is empty/null (boolean) |
| `regex` | Regexp matching |
| `iregex` | Regexp matching (case-insensitive) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name: Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm): class CableImportForm(NetBoxModelImportForm):
# Termination A # Termination A
side_a_site = CSVModelChoiceField(
label=_('Side A site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device A (if any)'),
)
side_a_device = CSVModelChoiceField( side_a_device = CSVModelChoiceField(
label=_('Side A device'), label=_('Side A device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
) )
# Termination B # Termination B
side_b_site = CSVModelChoiceField(
label=_('Side B site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device B (if any)'),
)
side_b_device = CSVModelChoiceField( side_b_device = CSVModelChoiceField(
label=_('Side B device'), label=_('Side B device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
required=False, required=False,
help_text=_('Length unit') help_text=_('Length unit')
) )
color = forms.CharField(
label=_('Color'),
required=False,
max_length=16,
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
)
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', 'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
)
def _clean_side(self, side): def _clean_side(self, side):
""" """
Derive a Cable's A/B termination objects. Derive a Cable's A/B termination objects.
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
setattr(self.instance, f'{side}_terminations', [termination_object]) setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object return termination_object
def _clean_color(self, color):
"""
Derive a colors hex code
:param color: color as hex or color name
"""
color_parsed = color.strip().lower()
for hex_code, label in ColorChoices.CHOICES:
if color.lower() == label.lower():
color_parsed = hex_code
if len(color_parsed) > 6:
raise forms.ValidationError(
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
)
return color_parsed
def clean_side_a_name(self): def clean_side_a_name(self):
return self._clean_side('a') return self._clean_side('a')
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
length_unit = self.cleaned_data.get('length_unit', None) length_unit = self.cleaned_data.get('length_unit', None)
return length_unit if length_unit is not None else '' return length_unit if length_unit is not None else ''
def clean_color(self):
color = self.cleaned_data.get('color', None)
return self._clean_color(color) if color is not None else ''
# #
# Virtual chassis # Virtual chassis
# #
class VirtualChassisImportForm(NetBoxModelImportForm): class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField( master = CSVModelChoiceField(
label=_('Master'), label=_('Master'),

View File

@@ -3,6 +3,7 @@ import svgwrite
from svgwrite.container import Hyperlink from svgwrite.container import Hyperlink
from svgwrite.image import Image from svgwrite.image import Image
from svgwrite.gradients import LinearGradient from svgwrite.gradients import LinearGradient
from svgwrite.masking import ClipPath
from svgwrite.shapes import Rect from svgwrite.shapes import Rect
from svgwrite.text import Text from svgwrite.text import Text
@@ -67,6 +68,20 @@ def get_device_description(device):
return description return description
def truncate_text(text, width, font_size=15):
"""
Truncate text to fit within the width of a rectangle.
:param text: The text to truncate
:param width: Width of rectangle
:param font_size: Font size (default is 15, ~0.875rem)
"""
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
max_char = int(width / char_width)
return text if len(text) <= max_char else text[:max_char] + '...'
class RackElevationSVG: class RackElevationSVG:
""" """
Use this class to render a rack elevation as an SVG image. Use this class to render a rack elevation as an SVG image.
@@ -177,12 +192,26 @@ class RackElevationSVG:
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent") link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
link.set_desc(description) link.set_desc(description)
# Create clipPath element
# This is necessary as fallback because the truncate_text method is an approximation
clip_id = f"clip-{device.id}"
clip_path = ClipPath(id=clip_id)
clip_path.add(Rect(coords, size))
self.drawing.defs.add(clip_path)
# Name to display
display_name = truncate_text(name, size[0])
# Add rect element to hyperlink # Add rect element to hyperlink
if color: if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else: else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) link.add(
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
class_=f'label{css_extra}')
)
# Embed device type image if provided # Embed device type image if provided
if self.include_images and image: if self.include_images and image:

View File

@@ -3266,17 +3266,27 @@ class CableTestCase(
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1') sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis') vc = VirtualChassis.objects.create(name='Virtual Chassis')
# NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
# different sites.
# The CSV test below demonstrates that devices with identical names on different sites can be created
# and referenced successfully.
devices = ( devices = (
Device(name='Device 1', site=site, device_type=devicetype, role=role), # Create 'Device 1' assigned to 'Site 1'
Device(name='Device 2', site=site, device_type=devicetype, role=role), Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 3', site=site, device_type=devicetype, role=role), Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 4', site=site, device_type=devicetype, role=role), Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@@ -3327,13 +3337,15 @@ class CableTestCase(
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
# the same device name, provided those devices belong to different sites.
cls.csv_data = ( cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", "side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", "Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", "Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", "Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4", "Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5", "Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
) )
cls.csv_update_data = ( cls.csv_update_data = (

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,7 @@ function showRackElements(
selector: string, selector: string,
elevation: HTMLObjectElement, elevation: HTMLObjectElement,
): void { ): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? []; const elements = elevation.querySelectorAll(selector) ?? [];
for (const element of elements) { for (const element of elements) {
element.classList.remove('hidden'); element.classList.remove('hidden');
} }
@@ -45,7 +45,7 @@ function hideRackElements(
selector: string, selector: string,
elevation: HTMLObjectElement, elevation: HTMLObjectElement,
): void { ): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? []; const elements = elevation.querySelectorAll(selector) ?? [];
for (const element of elements) { for (const element of elements) {
element.classList.add('hidden'); element.classList.add('hidden');
} }

View File

@@ -55,7 +55,7 @@ Blocks:
{# Release info #} {# Release info #}
<div class="text-muted text-center fs-5 my-3"> <div class="text-muted text-center fs-5 my-3">
{{ settings.RELEASE.name }} {{ settings.RELEASE.name }}
{% if not settings.RELEASE.features.commercial %} {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
<div> <div>
<a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> | <a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> |
<a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a> <a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a>
@@ -184,7 +184,7 @@ Blocks:
{% endif %} {% endif %}
{# Commercial links #} {# Commercial links #}
{% if settings.RELEASE.features.commercial %} {% if settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
{# LinkedIn #} {# LinkedIn #}
<li class="list-inline-item"> <li class="list-inline-item">
<a href="https://www.linkedin.com/company/netboxlabs/" target="_blank" class="link-secondary" rel="noopener" aria-label="LinkedIn"> <a href="https://www.linkedin.com/company/netboxlabs/" target="_blank" class="link-secondary" rel="noopener" aria-label="LinkedIn">
@@ -200,6 +200,7 @@ Blocks:
{# Community links #} {# Community links #}
{% else %} {% else %}
{% if not settings.ISOLATED_DEPLOYMENT %}
{# GitHub #} {# GitHub #}
<li class="list-inline-item"> <li class="list-inline-item">
<a href="https://github.com/netbox-community/netbox" target="_blank" class="link-secondary" rel="noopener" aria-label="{% trans "Source Code" %}"> <a href="https://github.com/netbox-community/netbox" target="_blank" class="link-secondary" rel="noopener" aria-label="{% trans "Source Code" %}">
@@ -213,6 +214,7 @@ Blocks:
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endif %}
{% endblock footer_links %} {% endblock footer_links %}
</ul> </ul>
{# /Footer links #} {# /Footer links #}

View File

@@ -1,5 +1,5 @@
{% load i18n %} {% load i18n %}
<div style="margin-left: -30px"> <div style="margin-left: -30px" class="rack_elevation">
<div <div
hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
hx-trigger="intersect" hx-trigger="intersect"

View File

@@ -45,12 +45,17 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm):
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
required=False required=False
) )
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = Tenant model = Tenant
fieldsets = ( fieldsets = (
FieldSet('group'), FieldSet('group', 'description'),
) )
nullable_fields = ('group',) nullable_fields = ('group', 'description')
# #

View File

@@ -98,6 +98,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'group': tenant_groups[1].pk, 'group': tenant_groups[1].pk,
'description': 'Bulk edit description',
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
ie='iexact', ie='iexact',
nie='iexact', nie='iexact',
empty='empty', empty='empty',
regex='regex',
iregex='iregex',
) )
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(

View File

@@ -180,6 +180,10 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['charfield__niew'].exclude, True) self.assertEqual(self.filters['charfield__niew'].exclude, True)
self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty') self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
self.assertEqual(self.filters['charfield__empty'].exclude, False) self.assertEqual(self.filters['charfield__empty'].exclude, False)
self.assertEqual(self.filters['charfield__regex'].lookup_expr, 'regex')
self.assertEqual(self.filters['charfield__regex'].exclude, False)
self.assertEqual(self.filters['charfield__iregex'].lookup_expr, 'iregex')
self.assertEqual(self.filters['charfield__iregex'].exclude, False)
def test_number_filter(self): def test_number_filter(self):
self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter) self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
@@ -220,6 +224,10 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['macaddressfield__iew'].exclude, False) self.assertEqual(self.filters['macaddressfield__iew'].exclude, False)
self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith') self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith')
self.assertEqual(self.filters['macaddressfield__niew'].exclude, True) self.assertEqual(self.filters['macaddressfield__niew'].exclude, True)
self.assertEqual(self.filters['macaddressfield__regex'].lookup_expr, 'regex')
self.assertEqual(self.filters['macaddressfield__regex'].exclude, False)
self.assertEqual(self.filters['macaddressfield__iregex'].lookup_expr, 'iregex')
self.assertEqual(self.filters['macaddressfield__iregex'].exclude, False)
def test_model_choice_filter(self): def test_model_choice_filter(self):
self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter) self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter)
@@ -257,6 +265,10 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False) self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False)
self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith') self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith')
self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True) self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True)
self.assertEqual(self.filters['multivaluecharfield__regex'].lookup_expr, 'regex')
self.assertEqual(self.filters['multivaluecharfield__regex'].exclude, False)
self.assertEqual(self.filters['multivaluecharfield__iregex'].lookup_expr, 'iregex')
self.assertEqual(self.filters['multivaluecharfield__iregex'].exclude, False)
def test_multi_value_date_filter(self): def test_multi_value_date_filter(self):
self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter) self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter)
@@ -340,6 +352,10 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False) self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False)
self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith') self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith')
self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True) self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True)
self.assertEqual(self.filters['multiplechoicefield__regex'].lookup_expr, 'regex')
self.assertEqual(self.filters['multiplechoicefield__regex'].exclude, False)
self.assertEqual(self.filters['multiplechoicefield__iregex'].lookup_expr, 'iregex')
self.assertEqual(self.filters['multiplechoicefield__iregex'].exclude, False)
def test_tag_filter(self): def test_tag_filter(self):
self.assertIsInstance(self.filters['tagfield'], TagFilter) self.assertIsInstance(self.filters['tagfield'], TagFilter)
@@ -534,6 +550,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
params = {'slug__niew': ['-1']} params = {'slug__niew': ['-1']}
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
def test_site_slug_regex(self):
params = {'slug__regex': ['^def-[a-z]*-2$']}
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
def test_site_slug_iregex(self):
params = {'slug__iregex': ['^DEF-[a-z]*-2$']}
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
def test_provider_asn_lt(self): def test_provider_asn_lt(self):
params = {'asn__lt': [65101]} params = {'asn__lt': [65101]}
self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1) self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
@@ -618,6 +642,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
params = {'mac_address__nic': ['aa:', 'bb']} params = {'mac_address__nic': ['aa:', 'bb']}
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
def test_device_mac_address_regex(self):
params = {'mac_address__regex': ['^cc.*:03$']}
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
def test_device_mac_address_iregex(self):
params = {'mac_address__iregex': ['^CC.*:03$']}
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
def test_interface_rf_role_empty(self): def test_interface_rf_role_empty(self):
params = {'rf_role__empty': 'true'} params = {'rf_role__empty': 'true'}
self.assertEqual(InterfaceFilterSet(params, Interface.objects.all()).qs.count(), 5) self.assertEqual(InterfaceFilterSet(params, Interface.objects.all()).qs.count(), 5)