diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 3b87a49e4..78231890b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.5 + placeholder: v3.2.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1fc0268ab..71d45092c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.5 + placeholder: v3.2.6 validations: required: true - type: dropdown diff --git a/NOTICE b/NOTICE index e6dc6408a..4e4dd600c 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1,7 @@ Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC. + +This project contains code developed expressly for NetBox, and its reuse in +other projects may introduce issues affecting performance, data integrity, +and security. + +For more information, please see https://github.com/netbox-community/netbox. diff --git a/base_requirements.txt b/base_requirements.txt index 98d3f78c2..9dc85231b 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,3 +1,7 @@ +# HTML sanitizer +# https://github.com/mozilla/bleach +bleach + # The Python web framework on which NetBox is built # https://github.com/django/django Django @@ -126,7 +130,3 @@ tablib # Timezone data (required by django-timezone-field on Python 3.9+) # https://github.com/python/tzdata tzdata - -# HTML sanitizer -# https://github.com/mozilla/bleach -bleach \ No newline at end of file diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index 2fa046fcf..d376dc5c4 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -43,18 +43,6 @@ changes in the database indefinitely. --- -## JOBRESULT_RETENTION - -Default: 90 - -The number of days to retain job results (scripts and reports). Set this to `0` to retain -job results in the database indefinitely. - -!!! warning - If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - ## CUSTOM_VALIDATORS This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: @@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API. --- +## JOBRESULT_RETENTION + +Default: 90 + +The number of days to retain job results (scripts and reports). Set this to `0` to retain +job results in the database indefinitely. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + ## MAINTENANCE_MODE Default: False @@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob --- +## POWERFEED_DEFAULT_AMPERAGE + +Default: 15 + +The default value for the `amperage` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_MAX_UTILIZATION + +Default: 80 + +The default value (percentage) for the `max_utilization` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_VOLTAGE + +Default: 120 + +The default value for the `voltage` field when creating new power feeds. + +--- + ## PREFER_IPV4 Default: False diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index da73816b6..e0c01688d 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -10,7 +10,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: -* Text: Free-form text (up to 255 characters) +* Text: Free-form text (intended for single-line use) * Long text: Free-form of any length; supports Markdown rendering * Integer: A whole number (positive or negative) * Boolean: True or false diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index bb5182702..8775da01f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,28 @@ # NetBox v3.2 +## v3.2.6 (2022-07-11) + +### Enhancements + +* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes +* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID +* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device +* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list +* [#9686](https://github.com/netbox-community/netbox/issues/9686) - Add tenant group column for all object tables with tenant assignments + +### Bug Fixes + +* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends +* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned +* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer +* [#9632](https://github.com/netbox-community/netbox/issues/9632) - Automatically focus on search box when expanding dropdowns +* [#9657](https://github.com/netbox-community/netbox/issues/9657) - Fix filtering for custom fields and webhooks in the UI +* [#9682](https://github.com/netbox-community/netbox/issues/9682) - Fix bulk assignment of ASNs to sites +* [#9687](https://github.com/netbox-community/netbox/issues/9687) - Don't restrict custom text field lengths when entering via UI form +* [#9704](https://github.com/netbox-community/netbox/issues/9704) - Include `last_updated` field on JournalEntry REST API serializer + +--- + ## v3.2.5 (2022-06-20) ### Enhancements @@ -25,7 +48,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column -* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 40f8918ae..ea4310def 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -2,7 +2,7 @@ import django_tables2 as tables from circuits.models import * from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from .columns import CommitRateColumn __all__ = ( @@ -39,7 +39,7 @@ class CircuitTypeTable(NetBoxTable): default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') -class CircuitTable(NetBoxTable): +class CircuitTable(TenancyColumnsMixin, NetBoxTable): cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -48,7 +48,6 @@ class CircuitTable(NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() - tenant = TenantColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, verbose_name='Side A' @@ -69,7 +68,7 @@ class CircuitTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Circuit fields = ( - 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f3b1269f9..11f211b27 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -30,7 +30,7 @@ class ProviderView(generic.ObjectView): circuits = Circuit.objects.restrict(request.user, 'view').filter( provider=instance ).prefetch_related( - 'type', 'tenant', 'terminations__site' + 'type', 'tenant', 'tenant__group', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) @@ -91,7 +91,7 @@ class ProviderNetworkView(generic.ObjectView): Q(termination_a__provider_network=instance.pk) | Q(termination_z__provider_network=instance.pk) ).prefetch_related( - 'type', 'tenant', 'terminations__site' + 'type', 'tenant', 'tenant__group', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) @@ -192,7 +192,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + 'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z' ) filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0ec0e07e0..1be8bb9dc 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ 'ComponentNestedModuleSerializer', + 'ModuleBayNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleBayNestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'serial'] + + class ComponentNestedModuleSerializer(WritableNestedSerializer): """ Used by device component serializers. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..c2cb846a9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -886,12 +886,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): class ModuleBaySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') device = NestedDeviceSerializer() - # installed_module = NestedModuleSerializer(required=False, allow_null=True) + installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) class Meta: model = ModuleBay fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c4c25f654..3fa652a9b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -611,7 +611,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class ModuleBayViewSet(NetBoxModelViewSet): - queryset = ModuleBay.objects.prefetch_related('tags') + queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module') serializer_class = serializers.ModuleBaySerializer filterset_class = filtersets.ModuleBayFilterSet brief_prefetch_fields = ['device'] diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..155f19c88 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -49,15 +49,6 @@ WIRELESS_IFACE_TYPES = [ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES -# -# Power feeds -# - -POWERFEED_VOLTAGE_DEFAULT = 120 -POWERFEED_AMPERAGE_DEFAULT = 20 -POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage - - # # Device components # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f052a8be9..4b4201578 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -992,6 +992,12 @@ class ModuleFilterSet(NetBoxModelFilterSet): to_field_name='model', label='Module type (model)', ) + module_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay', + queryset=ModuleBay.objects.all(), + to_field_name='id', + label='Module Bay (ID)' + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..043af751d 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -521,13 +521,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + virtual_chassis = DynamicModelChoiceField( + queryset=VirtualChassis.objects.all(), + required=False + ) + vc_position = forms.IntegerField( + required=False, + label='Position', + help_text="The position in the virtual chassis this device is identified by" + ) + vc_priority = forms.IntegerField( + required=False, + label='Priority', + help_text="The priority of the device in the virtual chassis" + ) class Meta: model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', - 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index bb99d199f..374d3bf45 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -386,9 +386,9 @@ class Migration(migrations.Migration): ('type', models.CharField(default='primary', max_length=50)), ('supply', models.CharField(default='ac', max_length=50)), ('phase', models.CharField(default='single-phase', max_length=50)), - ('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])), - ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), - ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), + ('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])), + ('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), ('available_power', models.PositiveIntegerField(default=0, editable=False)), ('comments', models.TextField(blank=True)), ], diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a0609c12..714ee3ff5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -95,8 +95,7 @@ class ModularComponentModel(ComponentModel): inventory_items = GenericRelation( to='dcim.InventoryItem', content_type_field='component_type', - object_id_field='component_id', - related_name='%(class)ss', + object_id_field='component_id' ) class Meta: diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 08f89e3b0..5978d86bd 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,6 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * +from netbox.config import ConfigItem from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): default=PowerFeedPhaseChoices.PHASE_SINGLE ) voltage = models.SmallIntegerField( - default=POWERFEED_VOLTAGE_DEFAULT, + default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'), validators=[ExclusionValidator([0])] ) amperage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=POWERFEED_AMPERAGE_DEFAULT + default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE') ) max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], - default=POWERFEED_MAX_UTILIZATION_DEFAULT, + default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'), help_text="Maximum permissible draw (percentage)" ) available_power = models.PositiveIntegerField( diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 4b062ad48..4fd1b3266 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import Cable from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -15,7 +15,7 @@ __all__ = ( # Cables # -class CableTable(NetBoxTable): +class CableTable(TenancyColumnsMixin, NetBoxTable): termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -53,7 +53,6 @@ class CableTable(NetBoxTable): verbose_name='Termination B' ) status = columns.ChoiceFieldColumn() - tenant = TenantColumn() length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by=('_abs_length', 'length_unit') @@ -67,7 +66,7 @@ class CableTable(NetBoxTable): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', + 'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0f015b7f3..710921beb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -6,7 +6,7 @@ from dcim.models import ( InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from .template_code import * __all__ = ( @@ -137,13 +137,12 @@ class PlatformTable(NetBoxTable): # Devices # -class DeviceTable(NetBoxTable): +class DeviceTable(TenancyColumnsMixin, NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK ) status = columns.ChoiceFieldColumn() - tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -200,7 +199,7 @@ class DeviceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Device fields = ( - 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', + 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', @@ -211,12 +210,11 @@ class DeviceTable(NetBoxTable): ) -class DeviceImportTable(NetBoxTable): +class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) status = columns.ChoiceFieldColumn() - tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -232,7 +230,7 @@ class DeviceImportTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Device - fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') + fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index e6368cb74..5412e2297 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin __all__ = ( 'RackTable', @@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable): # Racks # -class RackTable(NetBoxTable): +class RackTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -48,7 +48,6 @@ class RackTable(NetBoxTable): site = tables.Column( linkify=True ) - tenant = TenantColumn() status = columns.ChoiceFieldColumn() role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( @@ -87,7 +86,7 @@ class RackTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', ) @@ -101,7 +100,7 @@ class RackTable(NetBoxTable): # Rack reservations # -class RackReservationTable(NetBoxTable): +class RackReservationTable(TenancyColumnsMixin, NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -110,7 +109,6 @@ class RackReservationTable(NetBoxTable): accessor=Accessor('rack__site'), linkify=True ) - tenant = TenantColumn() rack = tables.Column( linkify=True ) @@ -125,7 +123,7 @@ class RackReservationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = RackReservation fields = ( - 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', + 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index fa3c73e12..1945199a3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from .template_code import LOCATION_BUTTONS __all__ = ( @@ -75,7 +75,7 @@ class SiteGroupTable(NetBoxTable): # Sites # -class SiteTable(NetBoxTable): +class SiteTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -96,7 +96,6 @@ class SiteTable(NetBoxTable): url_params={'site_id': 'pk'}, verbose_name='ASN Count' ) - tenant = TenantColumn() comments = columns.MarkdownColumn() contacts = columns.ManyToManyColumn( linkify_item=True @@ -108,7 +107,7 @@ class SiteTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Site fields = ( - 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count', + 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions', ) @@ -119,14 +118,13 @@ class SiteTable(NetBoxTable): # Locations # -class LocationTable(NetBoxTable): +class LocationTable(TenancyColumnsMixin, NetBoxTable): name = columns.MPTTColumn( linkify=True ) site = tables.Column( linkify=True ) - tenant = TenantColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, @@ -150,7 +148,7 @@ class LocationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', + 'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 273ee6570..47aa9368c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1849,6 +1849,11 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'module_type': [module_types[0].model, module_types[1].model]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_module_bay(self): + module_bays = ModuleBay.objects.all()[:2] + params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): device_types = Device.objects.all()[:2] params = {'device_id': [device_types[0].pk, device_types[1].pk]} diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 35a1056b2..a466246f5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -561,7 +561,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackListView(generic.ObjectListView): queryset = Rack.objects.prefetch_related( - 'site', 'location', 'tenant', 'role', 'devices__device_type' + 'site', 'location', 'tenant', 'tenant_group', 'role', 'devices__device_type' ).annotate( device_count=count_related(Device, 'rack') ) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 28902c323..01011b276 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('Rack Elevations', { 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), }), + ('Power', { + 'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION') + }), ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e05d4083c..bdb54067a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -221,7 +221,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): model = JournalEntry fields = [ 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', - 'created_by', 'kind', 'comments', 'tags', 'custom_fields', + 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated', ] def validate(self, data): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25477fbda..bb8d16c42 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -32,6 +32,9 @@ class WebhookFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) content_types = ContentTypeFilter() http_method = django_filters.MultipleChoiceFilter( choices=WebhookHttpMethodChoices @@ -40,8 +43,8 @@ class WebhookFilterSet(BaseFilterSet): class Meta: model = Webhook fields = [ - 'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', - 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', + 'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', + 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] def search(self, queryset, name, value): @@ -58,11 +61,17 @@ class CustomFieldFilterSet(BaseFilterSet): method='search', label='Search', ) + type = django_filters.MultipleChoiceFilter( + choices=CustomFieldTypeChoices + ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) content_types = ContentTypeFilter() class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description'] + fields = ['id', 'name', 'required', 'filter_logic', 'weight', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5d66c8be8..71bcfd4c2 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,12 +32,13 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('type', 'content_types', 'weight', 'required')), + ('Attributes', ('type', 'content_type_id', 'weight', 'required')), ) - content_types = ContentTypeMultipleChoiceField( + content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - required=False + required=False, + label='Object type' ) type = MultipleChoiceField( choices=CustomFieldTypeChoices, @@ -110,13 +111,14 @@ class ExportTemplateFilterForm(FilterForm): class WebhookFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_types', 'http_method', 'enabled')), + ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) - content_types = ContentTypeMultipleChoiceField( + content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), - required=False + required=False, + label='Object type' ) http_method = MultipleChoiceField( choices=WebhookHttpMethodChoices, diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 49afe1bba..6a8c1dacf 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -365,13 +365,8 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): # Text else: - if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: - max_length = None - widget = forms.Textarea - else: - max_length = 255 - widget = None - field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget) + widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None + field = forms.CharField(required=required, initial=initial, widget=widget) if self.validation_regex: field.validators = [ RegexValidator( diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bdb8de9db..aa9d724a4 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -7,7 +7,9 @@ from django.test import TestCase from circuits.models import Provider from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup -from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices +from extras.choices import ( + CustomFieldTypeChoices, CustomFieldFilterLogicChoices, JournalEntryKindChoices, ObjectChangeActionChoices, +) from extras.filtersets import * from extras.models import * from ipam.models import IPAddress @@ -16,6 +18,65 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +class CustomFieldTestCase(TestCase, BaseFilterSetTests): + queryset = CustomField.objects.all() + filterset = CustomFieldFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + custom_fields = ( + CustomField( + name='Custom Field 1', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=True, + weight=100, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ), + CustomField( + name='Custom Field 2', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False, + weight=200, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ), + CustomField( + name='Custom Field 3', + type=CustomFieldTypeChoices.TYPE_BOOLEAN, + required=False, + weight=300, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + ), + ) + CustomField.objects.bulk_create(custom_fields) + custom_fields[0].content_types.add(content_types[0]) + custom_fields[1].content_types.add(content_types[1]) + custom_fields[2].content_types.add(content_types[2]) + + def test_name(self): + params = {'name': ['Custom Field 1', 'Custom Field 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_required(self): + params = {'required': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_filter_logic(self): + params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() filterset = WebhookFilterSet @@ -62,6 +123,8 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): def test_content_types(self): params = {'content_types': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_type_create(self): params = {'type_create': True} diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index bbd6bb97b..3d67d4d37 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,7 +1,8 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import Location, Rack, Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup, Device +from virtualization.models import VirtualMachine from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -265,6 +266,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Device/VM', ('device_id', 'virtual_machine_id')), ) parent = forms.CharField( required=False, @@ -298,6 +300,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('Present in VRF') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Assigned Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Assigned VM'), + ) status = MultipleChoiceField( choices=IPAddressStatusChoices, required=False diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 558631585..bec05eeff 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -4,7 +4,7 @@ from django_tables2.utils import Accessor from ipam.models import * from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin, TenantColumn __all__ = ( 'AggregateTable', @@ -99,7 +99,7 @@ class RIRTable(NetBoxTable): # ASNs # -class ASNTable(NetBoxTable): +class ASNTable(TenancyColumnsMixin, NetBoxTable): asn = tables.Column( linkify=True ) @@ -122,7 +122,6 @@ class ASNTable(NetBoxTable): linkify_item=True, verbose_name='Sites' ) - tenant = TenantColumn() tags = columns.TagColumn( url_name='ipam:asn_list' ) @@ -130,7 +129,7 @@ class ASNTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'description', 'sites', 'tags', + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') @@ -140,12 +139,11 @@ class ASNTable(NetBoxTable): # Aggregates # -class AggregateTable(NetBoxTable): +class AggregateTable(TenancyColumnsMixin, NetBoxTable): prefix = tables.Column( linkify=True, verbose_name='Aggregate' ) - tenant = TenantColumn() date_added = tables.DateColumn( format="Y-m-d", verbose_name='Added' @@ -164,7 +162,7 @@ class AggregateTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Aggregate fields = ( - 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', + 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -225,7 +223,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): """ -class PrefixTable(NetBoxTable): +class PrefixTable(TenancyColumnsMixin, NetBoxTable): prefix = columns.TemplateColumn( template_code=PREFIX_LINK, export_raw=True, @@ -256,7 +254,6 @@ class PrefixTable(NetBoxTable): template_code=VRF_LINK, verbose_name='VRF' ) - tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -289,7 +286,7 @@ class PrefixTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( @@ -303,7 +300,7 @@ class PrefixTable(NetBoxTable): # # IP ranges # -class IPRangeTable(NetBoxTable): +class IPRangeTable(TenancyColumnsMixin, NetBoxTable): start_address = tables.Column( linkify=True ) @@ -317,7 +314,6 @@ class IPRangeTable(NetBoxTable): role = tables.Column( linkify=True ) - tenant = TenantColumn() utilization = columns.UtilizationColumn( accessor='utilization', orderable=False @@ -329,7 +325,7 @@ class IPRangeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = IPRange fields = ( - 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', + 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description', 'utilization', 'tags', 'created', 'last_updated', ) default_columns = ( @@ -344,7 +340,7 @@ class IPRangeTable(NetBoxTable): # IPAddresses # -class IPAddressTable(NetBoxTable): +class IPAddressTable(TenancyColumnsMixin, NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -357,7 +353,6 @@ class IPAddressTable(NetBoxTable): default=AVAILABLE_LABEL ) role = columns.ChoiceFieldColumn() - tenant = TenantColumn() assigned_object = tables.Column( linkify=True, orderable=False, @@ -386,7 +381,7 @@ class IPAddressTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = IPAddress fields = ( - 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', + 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'assigned', 'dns_name', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 4551a1c3d..7878de507 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,7 +5,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from ipam.models import * from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin, TenantColumn from virtualization.models import VMInterface __all__ = ( @@ -90,7 +90,7 @@ class VLANGroupTable(NetBoxTable): # VLANs # -class VLANTable(NetBoxTable): +class VLANTable(TenancyColumnsMixin, NetBoxTable): vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -104,7 +104,6 @@ class VLANTable(NetBoxTable): group = tables.Column( linkify=True ) - tenant = TenantColumn() status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) @@ -123,7 +122,7 @@ class VLANTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VLAN fields = ( - 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', + 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', 'description', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 727f402ff..69807410b 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -2,7 +2,7 @@ import django_tables2 as tables from ipam.models import * from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin __all__ = ( 'RouteTargetTable', @@ -20,14 +20,13 @@ VRF_TARGETS = """ # VRFs # -class VRFTable(NetBoxTable): +class VRFTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) rd = tables.Column( verbose_name='RD' ) - tenant = TenantColumn() enforce_unique = columns.BooleanColumn( verbose_name='Unique' ) @@ -46,7 +45,7 @@ class VRFTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -56,16 +55,15 @@ class VRFTable(NetBoxTable): # Route targets # -class RouteTargetTable(NetBoxTable): +class RouteTargetTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) - tenant = TenantColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) class Meta(NetBoxTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) + fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a01f2d052..e6b11c427 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -298,7 +298,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView): def get_children(self, request, parent): return Prefix.objects.restrict(request.user, 'view').filter( prefix__net_contained_or_equal=str(parent.prefix) - ).prefetch_related('site', 'role', 'tenant', 'vlan') + ).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan') def prep_table_data(self, request, queryset, parent): # Determine whether to show assigned prefixes, available prefixes, or both @@ -470,7 +470,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( - 'site', 'vrf', 'vlan', 'role', 'tenant', + 'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group' ) def prep_table_data(self, request, queryset, parent): @@ -499,7 +499,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'role', 'tenant', 'tenant__group', ) def get_extra_context(self, request, instance): @@ -587,7 +587,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'role', 'tenant', 'tenant__group', ) def get_extra_context(self, request, instance): @@ -680,13 +680,16 @@ class IPAddressView(generic.ObjectView): service_filter = Q(ipaddresses=instance) # Find services listening on all IPs on the assigned device/vm - if instance.assigned_object and instance.assigned_object.parent_object: - parent_object = instance.assigned_object.parent_object + try: + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object - if isinstance(parent_object, VirtualMachine): - service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) - elif isinstance(parent_object, Device): - service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + except AttributeError: + pass services = Service.objects.restrict(request.user, 'view').filter(service_filter) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a13e8d192..00fb3ee66 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -348,3 +348,26 @@ class LDAPBackend: ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) return obj + + +# Custom Social Auth Pipeline Handlers +def user_default_groups_handler(backend, user, response, *args, **kwargs): + """ + Custom pipeline handler which adds remote auth users to the default group specified in the + configuration file. + """ + logger = logging.getLogger('netbox.auth.user_default_groups_handler') + if settings.REMOTE_AUTH_DEFAULT_GROUPS: + # Assign default groups to the user + group_list = [] + for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: + try: + group_list.append(Group.objects.get(name=name)) + except Group.DoesNotExist: + logging.error( + f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if group_list: + user.groups.add(*group_list) + else: + user.groups.clear() + logger.debug(f"Stripping user {user} from Groups") diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 68c96b38a..e2295888f 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -82,6 +82,31 @@ PARAMS = ( field=forms.IntegerField ), + # Power + ConfigParam( + name='POWERFEED_DEFAULT_VOLTAGE', + label='Powerfeed voltage', + default=120, + description="Default voltage for powerfeeds", + field=forms.IntegerField + ), + + ConfigParam( + name='POWERFEED_DEFAULT_AMPERAGE', + label='Powerfeed amperage', + default=15, + description="Default amperage for powerfeeds", + field=forms.IntegerField + ), + + ConfigParam( + name='POWERFEED_DEFAULT_MAX_UTILIZATION', + label='Powerfeed max utilization', + default=80, + description="Default max utilization for powerfeeds", + field=forms.IntegerField + ), + # Security ConfigParam( name='ALLOWED_URL_SCHEMES', diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 8ca0d98c1..cc04e9aa8 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -34,7 +34,7 @@ CIRCUIT_TYPES = OrderedDict( }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( - 'type', 'provider', 'tenant', 'terminations__site' + 'type', 'provider', 'tenant', 'tenant__group', 'terminations__site' ), 'filterset': circuits.filtersets.CircuitFilterSet, 'table': circuits.tables.CircuitTable, @@ -53,13 +53,13 @@ CIRCUIT_TYPES = OrderedDict( DCIM_TYPES = OrderedDict( ( ('site', { - 'queryset': Site.objects.prefetch_related('region', 'tenant'), + 'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'), 'filterset': dcim.filtersets.SiteFilterSet, 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { - 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( + 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate( device_count=count_related(Device, 'rack') ), 'filterset': dcim.filtersets.RackFilterSet, @@ -100,7 +100,7 @@ DCIM_TYPES = OrderedDict( }), ('device', { 'queryset': Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', + 'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), 'filterset': dcim.filtersets.DeviceFilterSet, 'table': dcim.tables.DeviceTable, @@ -148,7 +148,7 @@ DCIM_TYPES = OrderedDict( IPAM_TYPES = OrderedDict( ( ('vrf', { - 'queryset': VRF.objects.prefetch_related('tenant'), + 'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'), 'filterset': ipam.filtersets.VRFFilterSet, 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', @@ -160,25 +160,25 @@ IPAM_TYPES = OrderedDict( 'url': 'ipam:aggregate_list', }), ('prefix', { - 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), + 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'), 'filterset': ipam.filtersets.PrefixFilterSet, 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { - 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), + 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'), 'filterset': ipam.filtersets.IPAddressFilterSet, 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { - 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), + 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'), 'filterset': ipam.filtersets.VLANFilterSet, 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { - 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), + 'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'), 'filterset': ipam.filtersets.ASNFilterSet, 'table': ipam.tables.ASNTable, 'url': 'ipam:asn_list', @@ -223,7 +223,7 @@ VIRTUALIZATION_TYPES = OrderedDict( }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( - 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', + 'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6', ), 'filterset': virtualization.filtersets.VirtualMachineFilterSet, 'table': virtualization.tables.VirtualMachineTable, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f86f3a8f2..b9d7e20b9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.5' +VERSION = '3.2.6' # Hostname HOSTNAME = platform.node() @@ -483,6 +483,19 @@ for param in dir(configuration): SOCIAL_AUTH_JSONFIELD_ENABLED = True +SOCIAL_AUTH_PIPELINE = ( + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.social_user', + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.social_auth.associate_by_email', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'netbox.authentication.user_default_groups_handler', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', +) + # # Django Prometheus diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 96efc0de7..5bdf5cbc9 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError +from django.db.models.fields.reverse_related import ManyToManyRel from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -484,7 +485,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): setattr(obj, name, None if model_field.null else '') # ManyToManyFields - elif isinstance(model_field, ManyToManyField): + elif isinstance(model_field, (ManyToManyField, ManyToManyRel)): if form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Normal fields diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index bc0cabef0..b611079e1 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 26bb1c514..494ba0b56 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index f5b605d58..798694f0e 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -411,6 +411,7 @@ export class APISelect { } finally { this.setOptionStyles(); this.enable(); + this.slim.slim.search.input.focus(); this.base.dispatchEvent(this.loadEvent); } } diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d3d6f03dc..68af0c08f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -46,13 +46,7 @@ Rack - - {% if object.rack %} - {{ object.rack }} - {% else %} - {{ ''|placeholder }} - {% endif %} - + {{ object.rack|linkify|placeholder }} Position @@ -161,9 +155,7 @@ Role - - {{ object.device_role }} - + {{ object.device_role|linkify }} Platform @@ -173,7 +165,7 @@ Primary IPv4 {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} + {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip|linkify }}) {% elif object.primary_ip4.nat_outside %} @@ -188,7 +180,7 @@ Primary IPv6 {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} + {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip|linkify }}) {% elif object.primary_ip6.nat_outside %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 7cbb224c9..38125e83c 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -86,6 +86,15 @@ {% render_field form.tenant %} +
+
+
Virtual Chassis
+
+ {% render_field form.virtual_chassis %} + {% render_field form.vc_position %} + {% render_field form.vc_priority %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index 363b067a8..0ed95d9eb 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -5,25 +5,29 @@ {% render_errors form %} {% block content %} - {% if perms.extras.add_journalentry %} -
-
-
-

New Journal Entry

- {% csrf_token %} - {% render_form form %} -
-
- Cancel - -
-
-
- {% endif %}
{% render_table table 'inc/table.html' %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+ {% if perms.extras.add_journalentry %} +
+
+

New Journal Entry

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+
+ Cancel + +
+
+
+
+
+ {% endif %} {% endblock %} diff --git a/netbox/tenancy/tables/columns.py b/netbox/tenancy/tables/columns.py index bb5aba5de..21622f18b 100644 --- a/netbox/tenancy/tables/columns.py +++ b/netbox/tenancy/tables/columns.py @@ -2,6 +2,8 @@ import django_tables2 as tables __all__ = ( 'TenantColumn', + 'TenantGroupColumn', + 'TenancyColumnsMixin', ) @@ -24,3 +26,32 @@ class TenantColumn(tables.TemplateColumn): def value(self, value): return str(value) if value else None + + +class TenantGroupColumn(tables.TemplateColumn): + """ + Include the tenant group description. + """ + template_code = """ + {% if record.tenant and record.tenant.group %} + {{ record.tenant.group }} + {% elif record.vrf.tenant and record.vrf.tenant.group %} + {{ record.vrf.tenant.group }}* + {% else %} + — + {% endif %} + """ + + def __init__(self, accessor=tables.A('tenant__group'), *args, **kwargs): + if 'verbose_name' not in kwargs: + kwargs['verbose_name'] = 'Tenant Group' + + super().__init__(template_code=self.template_code, accessor=accessor, *args, **kwargs) + + def value(self, value): + return str(value) if value else None + + +class TenancyColumnsMixin(tables.Table): + tenant_group = TenantGroupColumn() + tenant = TenantColumn() diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py index bdd4face6..2c261b0d3 100644 --- a/netbox/utilities/management/commands/__init__.py +++ b/netbox/utilities/management/commands/__init__.py @@ -1,6 +1,8 @@ from django.db import models from timezone_field import TimeZoneField +from netbox.config import ConfigItem + SKIP_FIELDS = ( TimeZoneField, @@ -26,4 +28,9 @@ def custom_deconstruct(field): for attr in EXEMPT_ATTRS: kwargs.pop(attr, None) + # Ignore any field defaults which reference a ConfigItem + kwargs = { + k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem) + } + return name, path, args, kwargs diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index a0c98425a..ccffe23fd 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -1,6 +1,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( @@ -56,7 +57,7 @@ class ClusterGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'cluster_count', 'description') -class ClusterTable(NetBoxTable): +class ClusterTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) @@ -66,9 +67,6 @@ class ClusterTable(NetBoxTable): group = tables.Column( linkify=True ) - tenant = tables.Column( - linkify=True - ) site = tables.Column( linkify=True ) @@ -93,7 +91,7 @@ class ClusterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', + 'pk', 'id', 'name', 'type', 'group', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 89dbdf901..8cff96227 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from virtualization.models import VirtualMachine, VMInterface __all__ = ( @@ -24,7 +24,7 @@ VMINTERFACE_BUTTONS = """ # Virtual machines # -class VirtualMachineTable(NetBoxTable): +class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -34,7 +34,6 @@ class VirtualMachineTable(NetBoxTable): linkify=True ) role = columns.ColoredLabelColumn() - tenant = TenantColumn() comments = columns.MarkdownColumn() primary_ip4 = tables.Column( linkify=True, @@ -56,7 +55,7 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/requirements.txt b/requirements.txt index 1fdace4f0..f987ad7ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -bleach==5.0.0 -Django==4.0.5 +bleach==5.0.1 +Django==4.0.6 django-cors-headers==3.13.0 -django-debug-toolbar==3.4.0 +django-debug-toolbar==3.5.0 django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 @@ -19,13 +19,13 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.3.6 +mkdocs-material==8.3.9 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.1 +Pillow==9.2.0 psycopg2-binary==2.9.3 PyYAML==6.0 -sentry-sdk==1.5.12 +sentry-sdk==1.7.0 social-auth-app-django==5.0.0 social-auth-core==4.3.0 svgwrite==1.4.2