Compare commits

..

7 Commits
v4.5.7 ... main

Author SHA1 Message Date
Jeremy Stretch
1bbecef77d Fixes #21841: Fix display of the "edit" button for script modules (#21851) 2026-04-07 08:48:40 -07:00
Jeremy Stretch
1ebeb71ad8 Fixes #21845: Remove whitespace from connection values in interface CSV exports (#21850) 2026-04-07 10:38:22 -05:00
Martin Hauser
d6a1cc5558 test(tables): Add reusable StandardTableTestCase
Introduce `TableTestCases.StandardTableTestCase`, a shared base class
for model-backed table smoke tests. It currently discovers sortable
columns from list-view querysets and verifies that each renders without
exceptions in both ascending and descending order.

Add per-table smoke tests across circuits, core, dcim, extras, ipam,
tenancy, users, virtualization, vpn, and wireless apps.

Fixes #21766
2026-04-06 13:53:13 -04:00
github-actions
09f7df0726 Update source translation strings 2026-04-04 05:26:28 +00:00
Martin Hauser
f242f17ce5 Fixes #21542: Increase supported interface speed values above 2.1 Tbps (#21834) 2026-04-03 16:55:11 -05:00
bctiemann
7d71503ea2 Merge pull request #21837 from netbox-community/21795-update-humanize_speed-to-support-decimal-gbpstbps-output
Closes #21795: Improve humanize_speed formatting for decimal Gbps/Tbps values
2026-04-03 13:06:55 -04:00
Martin Hauser
e07a5966ae feat(dcim): Support decimal Gbps/Tbps output in humanize_speed
Update the humanize_speed template filter to always use the largest
appropriate unit, even when the result is not a whole number.
Previously, values like 2500000 Kbps rendered as "2500 Mbps" instead of
"2.5 Gbps", and 1600000000 Kbps rendered as "1600 Gbps" instead of
"1.6 Tbps".

Fixes #21795
2026-04-03 15:36:42 +02:00
29 changed files with 1301 additions and 486 deletions

View File

@@ -1,48 +1,46 @@
from django.test import RequestFactory, TestCase, tag
from circuits.models import CircuitGroupAssignment, CircuitTermination
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
from circuits.tables import *
from utilities.testing import TableTestCases
@tag('regression')
class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name
for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
class CircuitTypeTableTest(TableTestCases.StandardTableTestCase):
table = CircuitTypeTable
@tag('regression')
class CircuitGroupAssignmentTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
assignment = CircuitGroupAssignment.objects.all()
disallowed = {
'actions',
}
class CircuitTableTest(TableTestCases.StandardTableTestCase):
table = CircuitTable
orderable_columns = [
column.name
for column in CircuitGroupAssignmentTable(assignment).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = CircuitGroupAssignmentTable(assignment)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
class CircuitTerminationTableTest(TableTestCases.StandardTableTestCase):
table = CircuitTerminationTable
class CircuitGroupTableTest(TableTestCases.StandardTableTestCase):
table = CircuitGroupTable
class CircuitGroupAssignmentTableTest(TableTestCases.StandardTableTestCase):
table = CircuitGroupAssignmentTable
class ProviderTableTest(TableTestCases.StandardTableTestCase):
table = ProviderTable
class ProviderAccountTableTest(TableTestCases.StandardTableTestCase):
table = ProviderAccountTable
class ProviderNetworkTableTest(TableTestCases.StandardTableTestCase):
table = ProviderNetworkTable
class VirtualCircuitTypeTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTypeTable
class VirtualCircuitTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTable
class VirtualCircuitTerminationTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTerminationTable

View File

@@ -0,0 +1,26 @@
from core.models import ObjectChange
from core.tables import *
from utilities.testing import TableTestCases
class DataSourceTableTest(TableTestCases.StandardTableTestCase):
table = DataSourceTable
class DataFileTableTest(TableTestCases.StandardTableTestCase):
table = DataFileTable
class JobTableTest(TableTestCases.StandardTableTestCase):
table = JobTable
class ObjectChangeTableTest(TableTestCases.StandardTableTestCase):
table = ObjectChangeTable
queryset_sources = [
('ObjectChangeListView', ObjectChange.objects.valid_models()),
]
class ConfigRevisionTableTest(TableTestCases.StandardTableTestCase):
table = ConfigRevisionTable

View File

@@ -26,6 +26,7 @@ from tenancy.models import *
from users.filterset_mixins import OwnerFilterMixin
from users.models import User
from utilities.filters import (
MultiValueBigNumberFilter,
MultiValueCharFilter,
MultiValueContentTypeFilter,
MultiValueMACAddressFilter,
@@ -2175,7 +2176,7 @@ class InterfaceFilterSet(
distinct=False,
label=_('LAG interface (ID)'),
)
speed = MultiValueNumberFilter()
speed = MultiValueBigNumberFilter(min_value=0)
duplex = django_filters.MultipleChoiceFilter(
choices=InterfaceDuplexChoices,
distinct=False,

View File

@@ -20,7 +20,13 @@ from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField
from utilities.forms.fields import (
ColorField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
JSONField,
PositiveBigIntegerField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from virtualization.models import Cluster
@@ -1420,7 +1426,7 @@ class InterfaceBulkEditForm(
'device_id': '$device',
}
)
speed = forms.IntegerField(
speed = PositiveBigIntegerField(
label=_('Speed'),
required=False,
widget=NumberWithOptions(

View File

@@ -19,7 +19,7 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, PositiveBigIntegerField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -1603,7 +1603,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
choices=InterfaceTypeChoices,
required=False
)
speed = forms.IntegerField(
speed = PositiveBigIntegerField(
label=_('Speed'),
required=False,
widget=NumberWithOptions(

View File

@@ -47,7 +47,13 @@ if TYPE_CHECKING:
VRFFilter,
)
from netbox.graphql.enums import ColorEnum
from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter
from netbox.graphql.filter_lookups import (
BigIntegerLookup,
FloatLookup,
IntegerArrayLookup,
IntegerLookup,
TreeNodeFilter,
)
from users.graphql.filters import UserFilter
from virtualization.graphql.filters import ClusterFilter
from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter
@@ -519,7 +525,7 @@ class InterfaceFilter(
strawberry_django.filter_field()
)
mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (

View File

@@ -433,6 +433,7 @@ class MACAddressType(PrimaryObjectType):
)
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_name: str
speed: BigInt | None
wwn: str | None
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None

View File

@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0226_modulebay_rebuild_tree'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='speed',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@@ -806,7 +806,7 @@ class Interface(
verbose_name=_('management only'),
help_text=_('This interface is used only for out-of-band management')
)
speed = models.PositiveIntegerField(
speed = models.PositiveBigIntegerField(
blank=True,
null=True,
verbose_name=_('speed (Kbps)')

View File

@@ -382,6 +382,17 @@ class PathEndpointTable(CableTerminationTable):
orderable=False
)
def value_connection(self, value):
if value:
connections = []
for termination in value:
if hasattr(termination, 'parent_object'):
connections.append(f'{termination.parent_object} > {termination}')
else:
connections.append(str(termination))
return ', '.join(connections)
return None
class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
device = tables.Column(
@@ -683,6 +694,15 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
orderable=False
)
def value_connection(self, record, value):
if record.is_virtual and hasattr(record, 'virtual_circuit_termination') and record.virtual_circuit_termination:
connections = [
f"{t.interface.parent_object} > {t.interface} via {t.parent_object}"
for t in record.connected_endpoints
]
return ', '.join(connections)
return super().value_connection(value)
class Meta(DeviceComponentTable.Meta):
model = models.Interface
fields = (

View File

@@ -1930,9 +1930,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
{
'device': device.pk,
'name': 'Interface 4',
'type': '1000base-t',
'type': 'other',
'mode': InterfaceModeChoices.MODE_TAGGED,
'speed': 1000000,
'speed': 16_000_000_000,
'duplex': 'full',
'vrf': vrfs[0].pk,
'poe_mode': InterfacePoEModeChoices.MODE_PD,

View File

@@ -4655,7 +4655,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
enabled=True,
mgmt_only=True,
tx_power=40,
speed=100000,
speed=16_000_000_000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
@@ -4757,7 +4757,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_speed(self):
params = {'speed': [1000000, 100000]}
params = {'speed': [16_000_000_000, 1_000_000, 100_000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_duplex(self):

View File

@@ -0,0 +1,204 @@
from dcim.models import ConsolePort, Interface, PowerPort
from dcim.tables import *
from utilities.testing import TableTestCases
#
# Sites
#
class RegionTableTest(TableTestCases.StandardTableTestCase):
table = RegionTable
class SiteGroupTableTest(TableTestCases.StandardTableTestCase):
table = SiteGroupTable
class SiteTableTest(TableTestCases.StandardTableTestCase):
table = SiteTable
class LocationTableTest(TableTestCases.StandardTableTestCase):
table = LocationTable
#
# Racks
#
class RackRoleTableTest(TableTestCases.StandardTableTestCase):
table = RackRoleTable
class RackTypeTableTest(TableTestCases.StandardTableTestCase):
table = RackTypeTable
class RackTableTest(TableTestCases.StandardTableTestCase):
table = RackTable
class RackReservationTableTest(TableTestCases.StandardTableTestCase):
table = RackReservationTable
#
# Device types
#
class ManufacturerTableTest(TableTestCases.StandardTableTestCase):
table = ManufacturerTable
class DeviceTypeTableTest(TableTestCases.StandardTableTestCase):
table = DeviceTypeTable
#
# Module types
#
class ModuleTypeProfileTableTest(TableTestCases.StandardTableTestCase):
table = ModuleTypeProfileTable
class ModuleTypeTableTest(TableTestCases.StandardTableTestCase):
table = ModuleTypeTable
class ModuleTableTest(TableTestCases.StandardTableTestCase):
table = ModuleTable
#
# Devices
#
class DeviceRoleTableTest(TableTestCases.StandardTableTestCase):
table = DeviceRoleTable
class PlatformTableTest(TableTestCases.StandardTableTestCase):
table = PlatformTable
class DeviceTableTest(TableTestCases.StandardTableTestCase):
table = DeviceTable
#
# Device components
#
class ConsolePortTableTest(TableTestCases.StandardTableTestCase):
table = ConsolePortTable
class ConsoleServerPortTableTest(TableTestCases.StandardTableTestCase):
table = ConsoleServerPortTable
class PowerPortTableTest(TableTestCases.StandardTableTestCase):
table = PowerPortTable
class PowerOutletTableTest(TableTestCases.StandardTableTestCase):
table = PowerOutletTable
class InterfaceTableTest(TableTestCases.StandardTableTestCase):
table = InterfaceTable
class FrontPortTableTest(TableTestCases.StandardTableTestCase):
table = FrontPortTable
class RearPortTableTest(TableTestCases.StandardTableTestCase):
table = RearPortTable
class ModuleBayTableTest(TableTestCases.StandardTableTestCase):
table = ModuleBayTable
class DeviceBayTableTest(TableTestCases.StandardTableTestCase):
table = DeviceBayTable
class InventoryItemTableTest(TableTestCases.StandardTableTestCase):
table = InventoryItemTable
class InventoryItemRoleTableTest(TableTestCases.StandardTableTestCase):
table = InventoryItemRoleTable
#
# Connections
#
class ConsoleConnectionTableTest(TableTestCases.StandardTableTestCase):
table = ConsoleConnectionTable
queryset_sources = [
('ConsoleConnectionsListView', ConsolePort.objects.filter(_path__is_complete=True)),
]
class PowerConnectionTableTest(TableTestCases.StandardTableTestCase):
table = PowerConnectionTable
queryset_sources = [
('PowerConnectionsListView', PowerPort.objects.filter(_path__is_complete=True)),
]
class InterfaceConnectionTableTest(TableTestCases.StandardTableTestCase):
table = InterfaceConnectionTable
queryset_sources = [
('InterfaceConnectionsListView', Interface.objects.filter(_path__is_complete=True)),
]
#
# Cables
#
class CableTableTest(TableTestCases.StandardTableTestCase):
table = CableTable
#
# Power
#
class PowerPanelTableTest(TableTestCases.StandardTableTestCase):
table = PowerPanelTable
class PowerFeedTableTest(TableTestCases.StandardTableTestCase):
table = PowerFeedTable
#
# Virtual chassis
#
class VirtualChassisTableTest(TableTestCases.StandardTableTestCase):
table = VirtualChassisTable
#
# Virtual device contexts
#
class VirtualDeviceContextTableTest(TableTestCases.StandardTableTestCase):
table = VirtualDeviceContextTable
#
# MAC addresses
#
class MACAddressTableTest(TableTestCases.StandardTableTestCase):
table = MACAddressTable

View File

@@ -2961,13 +2961,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.form_data = {
'device': device.pk,
'name': 'Interface X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'type': InterfaceTypeChoices.TYPE_OTHER,
'enabled': False,
'bridge': interfaces[4].pk,
'lag': interfaces[3].pk,
'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 65000,
'speed': 1000000,
'speed': 16_000_000_000,
'duplex': 'full',
'mgmt_only': True,
'description': 'A front port',
@@ -2985,13 +2985,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'type': InterfaceTypeChoices.TYPE_OTHER,
'enabled': False,
'bridge': interfaces[4].pk,
'lag': interfaces[3].pk,
'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 2000,
'speed': 100000,
'speed': 16_000_000_000,
'duplex': 'half',
'mgmt_only': True,
'description': 'A front port',

View File

@@ -1,24 +1,93 @@
from django.test import RequestFactory, TestCase, tag
from extras.models import EventRule
from extras.tables import EventRuleTable
from extras.models import Bookmark, Notification, Subscription
from extras.tables import *
from utilities.testing import TableTestCases
@tag('regression')
class EventRuleTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
rule = EventRule.objects.all()
disallowed = {
'actions',
}
class CustomFieldTableTest(TableTestCases.StandardTableTestCase):
table = CustomFieldTable
orderable_columns = [
column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = EventRuleTable(rule)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
class CustomFieldChoiceSetTableTest(TableTestCases.StandardTableTestCase):
table = CustomFieldChoiceSetTable
class CustomLinkTableTest(TableTestCases.StandardTableTestCase):
table = CustomLinkTable
class ExportTemplateTableTest(TableTestCases.StandardTableTestCase):
table = ExportTemplateTable
class SavedFilterTableTest(TableTestCases.StandardTableTestCase):
table = SavedFilterTable
class TableConfigTableTest(TableTestCases.StandardTableTestCase):
table = TableConfigTable
class BookmarkTableTest(TableTestCases.StandardTableTestCase):
table = BookmarkTable
# The list view for this table lives in account.views (not extras.views),
# so auto-discovery cannot find it. Provide an explicit queryset source.
queryset_sources = [
('Bookmark.objects.all()', Bookmark.objects.all()),
]
class NotificationGroupTableTest(TableTestCases.StandardTableTestCase):
table = NotificationGroupTable
class NotificationTableTest(TableTestCases.StandardTableTestCase):
table = NotificationTable
# The list view for this table lives in account.views (not extras.views),
# so auto-discovery cannot find it. Provide an explicit queryset source.
queryset_sources = [
('Notification.objects.all()', Notification.objects.all()),
]
class SubscriptionTableTest(TableTestCases.StandardTableTestCase):
table = SubscriptionTable
# The list view for this table lives in account.views (not extras.views),
# so auto-discovery cannot find it. Provide an explicit queryset source.
queryset_sources = [
('Subscription.objects.all()', Subscription.objects.all()),
]
class WebhookTableTest(TableTestCases.StandardTableTestCase):
table = WebhookTable
class EventRuleTableTest(TableTestCases.StandardTableTestCase):
table = EventRuleTable
class TagTableTest(TableTestCases.StandardTableTestCase):
table = TagTable
class ConfigContextProfileTableTest(TableTestCases.StandardTableTestCase):
table = ConfigContextProfileTable
class ConfigContextTableTest(TableTestCases.StandardTableTestCase):
table = ConfigContextTable
class ConfigTemplateTableTest(TableTestCases.StandardTableTestCase):
table = ConfigTemplateTable
class ImageAttachmentTableTest(TableTestCases.StandardTableTestCase):
table = ImageAttachmentTable
class JournalEntryTableTest(TableTestCases.StandardTableTestCase):
table = JournalEntryTable

View File

@@ -1,9 +1,10 @@
from django.test import RequestFactory, TestCase
from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange, Prefix
from ipam.tables import AnnotatedIPAddressTable
from ipam.models import FHRPGroupAssignment, IPAddress, IPRange, Prefix
from ipam.tables import *
from ipam.utils import annotate_ip_space
from utilities.testing import TableTestCases
class AnnotatedIPAddressTableTest(TestCase):
@@ -168,3 +169,85 @@ class AnnotatedIPAddressTableTest(TestCase):
# Pools are fully usable
self.assertEqual(available.first_ip, '2001:db8:1::/126')
self.assertEqual(available.size, 4)
#
# Table ordering tests
#
class VRFTableTest(TableTestCases.StandardTableTestCase):
table = VRFTable
class RouteTargetTableTest(TableTestCases.StandardTableTestCase):
table = RouteTargetTable
class RIRTableTest(TableTestCases.StandardTableTestCase):
table = RIRTable
class AggregateTableTest(TableTestCases.StandardTableTestCase):
table = AggregateTable
class RoleTableTest(TableTestCases.StandardTableTestCase):
table = RoleTable
class PrefixTableTest(TableTestCases.StandardTableTestCase):
table = PrefixTable
class IPRangeTableTest(TableTestCases.StandardTableTestCase):
table = IPRangeTable
class IPAddressTableTest(TableTestCases.StandardTableTestCase):
table = IPAddressTable
class FHRPGroupTableTest(TableTestCases.StandardTableTestCase):
table = FHRPGroupTable
class FHRPGroupAssignmentTableTest(TableTestCases.StandardTableTestCase):
table = FHRPGroupAssignmentTable
# No ObjectListView exists for this table; it is only rendered inline on
# the FHRPGroup detail view. Provide an explicit queryset source.
queryset_sources = [
('FHRPGroupAssignment.objects.all()', FHRPGroupAssignment.objects.all()),
]
class VLANGroupTableTest(TableTestCases.StandardTableTestCase):
table = VLANGroupTable
class VLANTableTest(TableTestCases.StandardTableTestCase):
table = VLANTable
class VLANTranslationPolicyTableTest(TableTestCases.StandardTableTestCase):
table = VLANTranslationPolicyTable
class VLANTranslationRuleTableTest(TableTestCases.StandardTableTestCase):
table = VLANTranslationRuleTable
class ASNRangeTableTest(TableTestCases.StandardTableTestCase):
table = ASNRangeTable
class ASNTableTest(TableTestCases.StandardTableTestCase):
table = ASNTable
class ServiceTemplateTableTest(TableTestCases.StandardTableTestCase):
table = ServiceTemplateTable
class ServiceTableTest(TableTestCases.StandardTableTestCase):
table = ServiceTable

View File

@@ -11,7 +11,7 @@
<h2 class="card-header" id="module{{ module.pk }}">
<i class="mdi mdi-file-document-outline"></i> {{ module }}
<div class="card-actions">
{% if perms.extras.edit_scriptmodule %}
{% if perms.extras.change_scriptmodule %}
<a href="{% url 'extras:scriptmodule_edit' pk=module.pk %}" class="btn btn-ghost-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</a>

View File

@@ -0,0 +1,26 @@
from tenancy.tables import *
from utilities.testing import TableTestCases
class TenantGroupTableTest(TableTestCases.StandardTableTestCase):
table = TenantGroupTable
class TenantTableTest(TableTestCases.StandardTableTestCase):
table = TenantTable
class ContactGroupTableTest(TableTestCases.StandardTableTestCase):
table = ContactGroupTable
class ContactRoleTableTest(TableTestCases.StandardTableTestCase):
table = ContactRoleTable
class ContactTableTest(TableTestCases.StandardTableTestCase):
table = ContactTable
class ContactAssignmentTableTest(TableTestCases.StandardTableTestCase):
table = ContactAssignmentTable

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,26 @@
from django.test import RequestFactory, TestCase, tag
from users.models import Token
from users.tables import TokenTable
from users.tables import *
from utilities.testing import TableTestCases
class TokenTableTest(TestCase):
@tag('regression')
def test_every_orderable_field_does_not_throw_exception(self):
tokens = Token.objects.all()
disallowed = {'actions'}
class TokenTableTest(TableTestCases.StandardTableTestCase):
table = TokenTable
orderable_columns = [
column.name for column in TokenTable(tokens).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for direction in ('-', ''):
with self.subTest(col=col, direction=direction):
table = TokenTable(tokens)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
class UserTableTest(TableTestCases.StandardTableTestCase):
table = UserTable
class GroupTableTest(TableTestCases.StandardTableTestCase):
table = GroupTable
class ObjectPermissionTableTest(TableTestCases.StandardTableTestCase):
table = ObjectPermissionTable
class OwnerGroupTableTest(TableTestCases.StandardTableTestCase):
table = OwnerGroupTable
class OwnerTableTest(TableTestCases.StandardTableTestCase):
table = OwnerTable

View File

@@ -7,9 +7,12 @@ from django_filters.constants import EMPTY_VALUES
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from .forms.fields import BigIntegerField
__all__ = (
'ContentTypeFilter',
'MultiValueArrayFilter',
'MultiValueBigNumberFilter',
'MultiValueCharFilter',
'MultiValueContentTypeFilter',
'MultiValueDateFilter',
@@ -77,6 +80,11 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.IntegerField)
@extend_schema_field(OpenApiTypes.INT64)
class MultiValueBigNumberFilter(MultiValueNumberFilter):
field_class = multivalue_field_factory(BigIntegerField)
@extend_schema_field(OpenApiTypes.DECIMAL)
class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.DecimalField)

View File

@@ -2,6 +2,7 @@ import json
from django import forms
from django.conf import settings
from django.db.models import BigIntegerField as BigIntegerModelField
from django.db.models import Count
from django.forms.fields import InvalidJSONInput
from django.forms.fields import JSONField as _JSONField
@@ -13,17 +14,39 @@ from utilities.forms import widgets
from utilities.validators import EnhancedURLValidator
__all__ = (
'BigIntegerField',
'ColorField',
'CommentField',
'JSONField',
'LaxURLField',
'MACAddressField',
'PositiveBigIntegerField',
'QueryField',
'SlugField',
'TagFilterField',
)
class BigIntegerField(forms.IntegerField):
"""
An IntegerField constrained to the range of a signed 64-bit integer.
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('min_value', -BigIntegerModelField.MAX_BIGINT - 1)
kwargs.setdefault('max_value', BigIntegerModelField.MAX_BIGINT)
super().__init__(*args, **kwargs)
class PositiveBigIntegerField(BigIntegerField):
"""
An IntegerField constrained to the range supported by Django's
PositiveBigIntegerField model field.
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('min_value', 0)
super().__init__(*args, **kwargs)
class QueryField(forms.CharField):
"""
A CharField subclass used for global search/query fields in filter forms.

View File

@@ -186,26 +186,52 @@ def action_url(parser, token):
return ActionURLNode(model, action, kwargs, asvar)
def _format_speed(speed, divisor, unit):
"""
Format a speed value with a given divisor and unit.
Handles decimal values and strips trailing zeros for clean output.
"""
whole, remainder = divmod(speed, divisor)
if remainder == 0:
return f'{whole} {unit}'
# Divisors are powers of 10, so len(str(divisor)) - 1 matches the decimal precision.
precision = len(str(divisor)) - 1
fraction = f'{remainder:0{precision}d}'.rstrip('0')
return f'{whole}.{fraction} {unit}'
@register.filter()
def humanize_speed(speed):
"""
Humanize speeds given in Kbps. Examples:
Humanize speeds given in Kbps, always using the largest appropriate unit.
1544 => "1.544 Mbps"
100000 => "100 Mbps"
10000000 => "10 Gbps"
Decimal values are displayed when the result is not a whole number;
trailing zeros after the decimal point are stripped for clean output.
Examples:
1_544 => "1.544 Mbps"
100_000 => "100 Mbps"
1_000_000 => "1 Gbps"
2_500_000 => "2.5 Gbps"
10_000_000 => "10 Gbps"
800_000_000 => "800 Gbps"
1_600_000_000 => "1.6 Tbps"
"""
if not speed:
return ''
if speed >= 1000000000 and speed % 1000000000 == 0:
return '{} Tbps'.format(int(speed / 1000000000))
if speed >= 1000000 and speed % 1000000 == 0:
return '{} Gbps'.format(int(speed / 1000000))
if speed >= 1000 and speed % 1000 == 0:
return '{} Mbps'.format(int(speed / 1000))
if speed >= 1000:
return '{} Mbps'.format(float(speed) / 1000)
return '{} Kbps'.format(speed)
speed = int(speed)
if speed >= 1_000_000_000:
return _format_speed(speed, 1_000_000_000, 'Tbps')
if speed >= 1_000_000:
return _format_speed(speed, 1_000_000, 'Gbps')
if speed >= 1_000:
return _format_speed(speed, 1_000, 'Mbps')
return f'{speed} Kbps'
def _humanize_capacity(value, divisor=1000):

View File

@@ -1,5 +1,6 @@
from .api import *
from .base import *
from .filtersets import *
from .tables import *
from .utils import *
from .views import *

View File

@@ -0,0 +1,157 @@
import inspect
from importlib import import_module
from django.test import RequestFactory
from netbox.views import generic
from .base import TestCase
__all__ = (
"ModelTableTestCase",
"TableTestCases",
)
class ModelTableTestCase(TestCase):
"""
Shared helpers for model-backed table ordering smoke tests.
Concrete subclasses should set `table` and may override `queryset_sources`,
`queryset_source_view_classes`, or `excluded_orderable_columns` as needed.
"""
table = None
excluded_orderable_columns = frozenset({"actions"})
# Optional explicit override for odd cases
queryset_sources = None
# Only these view types are considered sortable queryset sources by default
queryset_source_view_classes = (generic.ObjectListView,)
@classmethod
def validate_table_test_case(cls):
"""
Assert that the test case is correctly configured with a model-backed table.
Raises:
AssertionError: If ``table`` is not set or is not backed by a Django model.
"""
if cls.table is None:
raise AssertionError(f"{cls.__name__} must define `table`")
if getattr(cls.table._meta, "model", None) is None:
raise AssertionError(f"{cls.__name__}.table must be model-backed")
def get_request(self):
"""
Build a minimal ``GET`` request authenticated as the test user.
"""
request = RequestFactory().get("/")
request.user = self.user
return request
def get_table(self, queryset):
"""
Instantiate the table class under test with the given *queryset*.
"""
return self.table(queryset)
@classmethod
def is_queryset_source_view(cls, view):
"""
Return ``True`` if *view* is a list-style view class that declares
this test case's table and exposes a usable queryset.
"""
model = cls.table._meta.model
app_label = model._meta.app_label
return (
inspect.isclass(view)
and view.__module__.startswith(f"{app_label}.views")
and getattr(view, "table", None) is cls.table
and getattr(view, "queryset", None) is not None
and issubclass(view, cls.queryset_source_view_classes)
)
@classmethod
def get_queryset_sources(cls):
"""
Return iterable of (label, queryset) pairs to test.
The label is included in the subtest failure output.
By default, only discover list-style views that declare this table.
That keeps bulk edit/delete confirmation tables out of the ordering
smoke test.
"""
if cls.queryset_sources is not None:
return tuple(cls.queryset_sources)
model = cls.table._meta.model
app_label = model._meta.app_label
module = import_module(f"{app_label}.views")
sources = []
for _, view in inspect.getmembers(module, inspect.isclass):
if not cls.is_queryset_source_view(view):
continue
queryset = view.queryset
if hasattr(queryset, "all"):
queryset = queryset.all()
sources.append((view.__name__, queryset))
if not sources:
raise AssertionError(
f"{cls.__name__} could not find any list-style queryset source for "
f"{cls.table.__module__}.{cls.table.__name__}; "
"set `queryset_sources` explicitly if needed."
)
return tuple(sources)
def iter_orderable_columns(self, queryset):
"""
Yield the names of all orderable columns for *queryset*, excluding
any listed in ``excluded_orderable_columns``.
"""
for column in self.get_table(queryset).columns:
if not column.orderable:
continue
if column.name in self.excluded_orderable_columns:
continue
yield column.name
class TableTestCases:
"""
Keep test_* methods nested to avoid unittest auto-discovering the reusable
base classes directly.
"""
class StandardTableTestCase(ModelTableTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.validate_table_test_case()
def test_every_orderable_column_renders(self):
"""
Verify that each declared ordering can be applied without error.
This is intentionally a smoke test. It validates ordering against
the configured queryset sources but does not create model
instances by default, so it complements rather than replaces
data-backed rendering tests for tables whose behavior depends on
populated querysets.
"""
request = self.get_request()
for source_name, queryset in self.get_queryset_sources():
for column_name in self.iter_orderable_columns(queryset):
for direction, prefix in (("asc", ""), ("desc", "-")):
with self.subTest(source=source_name, column=column_name, direction=direction):
table = self.get_table(queryset)
table.order_by = f"{prefix}{column_name}"
table.as_html(request)

View File

@@ -3,7 +3,7 @@ from unittest.mock import patch
from django.test import TestCase, override_settings
from utilities.templatetags.builtins.tags import static_with_params
from utilities.templatetags.helpers import _humanize_capacity
from utilities.templatetags.helpers import _humanize_capacity, humanize_speed
class StaticWithParamsTest(TestCase):
@@ -90,3 +90,87 @@ class HumanizeCapacityTest(TestCase):
def test_default_divisor_is_1000(self):
self.assertEqual(_humanize_capacity(2000), '2.00 GB')
class HumanizeSpeedTest(TestCase):
"""
Test the humanize_speed filter for correct unit selection and decimal formatting.
"""
# Falsy / empty inputs
def test_none(self):
self.assertEqual(humanize_speed(None), '')
def test_zero(self):
self.assertEqual(humanize_speed(0), '')
def test_empty_string(self):
self.assertEqual(humanize_speed(''), '')
# Kbps (below 1000)
def test_kbps(self):
self.assertEqual(humanize_speed(100), '100 Kbps')
def test_kbps_low(self):
self.assertEqual(humanize_speed(1), '1 Kbps')
# Mbps (1,000 999,999)
def test_mbps_whole(self):
self.assertEqual(humanize_speed(100_000), '100 Mbps')
def test_mbps_decimal(self):
self.assertEqual(humanize_speed(1_544), '1.544 Mbps')
def test_mbps_10(self):
self.assertEqual(humanize_speed(10_000), '10 Mbps')
# Gbps (1,000,000 999,999,999)
def test_gbps_whole(self):
self.assertEqual(humanize_speed(1_000_000), '1 Gbps')
def test_gbps_decimal(self):
self.assertEqual(humanize_speed(2_500_000), '2.5 Gbps')
def test_gbps_10(self):
self.assertEqual(humanize_speed(10_000_000), '10 Gbps')
def test_gbps_25(self):
self.assertEqual(humanize_speed(25_000_000), '25 Gbps')
def test_gbps_40(self):
self.assertEqual(humanize_speed(40_000_000), '40 Gbps')
def test_gbps_100(self):
self.assertEqual(humanize_speed(100_000_000), '100 Gbps')
def test_gbps_400(self):
self.assertEqual(humanize_speed(400_000_000), '400 Gbps')
def test_gbps_800(self):
self.assertEqual(humanize_speed(800_000_000), '800 Gbps')
# Tbps (1,000,000,000+)
def test_tbps_whole(self):
self.assertEqual(humanize_speed(1_000_000_000), '1 Tbps')
def test_tbps_decimal(self):
self.assertEqual(humanize_speed(1_600_000_000), '1.6 Tbps')
# Edge cases
def test_string_input(self):
"""Ensure string values are cast to int correctly."""
self.assertEqual(humanize_speed('2500000'), '2.5 Gbps')
def test_non_round_remainder_preserved(self):
"""Ensure fractional parts with interior zeros are preserved."""
self.assertEqual(humanize_speed(1_001_000), '1.001 Gbps')
def test_trailing_zeros_stripped(self):
"""Ensure trailing fractional zeros are stripped (5.500 → 5.5)."""
self.assertEqual(humanize_speed(5_500_000), '5.5 Gbps')

View File

@@ -0,0 +1,26 @@
from utilities.testing import TableTestCases
from virtualization.tables import *
class ClusterTypeTableTest(TableTestCases.StandardTableTestCase):
table = ClusterTypeTable
class ClusterGroupTableTest(TableTestCases.StandardTableTestCase):
table = ClusterGroupTable
class ClusterTableTest(TableTestCases.StandardTableTestCase):
table = ClusterTable
class VirtualMachineTableTest(TableTestCases.StandardTableTestCase):
table = VirtualMachineTable
class VMInterfaceTableTest(TableTestCases.StandardTableTestCase):
table = VMInterfaceTable
class VirtualDiskTableTest(TableTestCases.StandardTableTestCase):
table = VirtualDiskTable

View File

@@ -1,23 +1,42 @@
from django.test import RequestFactory, TestCase, tag
from vpn.models import TunnelTermination
from vpn.tables import TunnelTerminationTable
from utilities.testing import TableTestCases
from vpn.tables import *
@tag('regression')
class TunnelTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = TunnelTermination.objects.all()
fake_request = RequestFactory().get("/")
disallowed = {'actions'}
class TunnelGroupTableTest(TableTestCases.StandardTableTestCase):
table = TunnelGroupTable
orderable_columns = [
column.name for column in TunnelTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
for col in orderable_columns:
for dir in ('-', ''):
table = TunnelTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.as_html(fake_request)
class TunnelTableTest(TableTestCases.StandardTableTestCase):
table = TunnelTable
class TunnelTerminationTableTest(TableTestCases.StandardTableTestCase):
table = TunnelTerminationTable
class IKEProposalTableTest(TableTestCases.StandardTableTestCase):
table = IKEProposalTable
class IKEPolicyTableTest(TableTestCases.StandardTableTestCase):
table = IKEPolicyTable
class IPSecProposalTableTest(TableTestCases.StandardTableTestCase):
table = IPSecProposalTable
class IPSecPolicyTableTest(TableTestCases.StandardTableTestCase):
table = IPSecPolicyTable
class IPSecProfileTableTest(TableTestCases.StandardTableTestCase):
table = IPSecProfileTable
class L2VPNTableTest(TableTestCases.StandardTableTestCase):
table = L2VPNTable
class L2VPNTerminationTableTest(TableTestCases.StandardTableTestCase):
table = L2VPNTerminationTable

View File

@@ -0,0 +1,14 @@
from utilities.testing import TableTestCases
from wireless.tables import *
class WirelessLANGroupTableTest(TableTestCases.StandardTableTestCase):
table = WirelessLANGroupTable
class WirelessLANTableTest(TableTestCases.StandardTableTestCase):
table = WirelessLANTable
class WirelessLinkTableTest(TableTestCases.StandardTableTestCase):
table = WirelessLinkTable