diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 51c0a5a0d..54f4f7eae 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.4.6 + placeholder: v3.4.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 7c6b4e151..c9bc56ffc 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.4.6 + placeholder: v3.4.7 validations: required: true - type: dropdown diff --git a/README.md b/README.md index e3c9611c0..99ad9a597 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations. [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)            [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) -            - [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            diff --git a/base_requirements.txt b/base_requirements.txt index 7292c676b..f3303e6e3 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -121,7 +121,8 @@ social-auth-core # Django app for social-auth-core # https://github.com/python-social-auth/social-app-django -social-auth-app-django +# See https://github.com/python-social-auth/social-app-django/issues/429 +social-auth-app-django==5.0.0 # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite diff --git a/docs/configuration/development.md b/docs/configuration/development.md index 3af56b0e3..1579f2cdb 100644 --- a/docs/configuration/development.md +++ b/docs/configuration/development.md @@ -18,4 +18,4 @@ interface. Default: False -This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. +This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 07adf5c6a..1fda8d0d3 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated Default: `'netbox.authentication.RemoteUserBackend'` -This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. +This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given. * `netbox.authentication.RemoteUserBackend` * `netbox.authentication.LDAPBackend` diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 7061274f1..a6abb8b73 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i * `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) * `PORT` - TCP port to use for the connection (default: `25`) * `USERNAME` - Username with which to authenticate -* `PASSSWORD` - Password with which to authenticate +* `PASSWORD` - Password with which to authenticate * `USE_SSL` - Use SSL when connecting to the server (default: `False`) * `USE_TLS` - Use TLS when connecting to the server (default: `False`) * `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) diff --git a/docs/customization/reports.md b/docs/customization/reports.md index b83c4a177..f7d9109ec 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r !!! note To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below. - ![Adding the run action to a permission](/media/admin_ui_run_permission.png) + ![Adding the run action to a permission](../media/admin_ui_run_permission.png) ### Via the Web UI diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 583a4f3e9..546bfb7b4 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user ( ```postgresql CREATE DATABASE netbox; CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; -GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox; +ALTER DATABASE netbox OWNER TO netbox; ``` !!! danger "Use a strong password" diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 6f54a8cb0..28d9b55b6 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -584,11 +584,16 @@ Additionally, a token can be set to expire at a specific time. This can be usefu #### Client IP Restriction -!!! note - This feature was introduced in NetBox v3.3. - Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) +#### Creating Tokens for Other Users + +It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users. + +![Adding the grant action to a permission](../media/admin_ui_grant_permission.png) + +!!! warning "Exercise Caution" + The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise. ### Authenticating to the API diff --git a/docs/media/admin_ui_grant_permission.png b/docs/media/admin_ui_grant_permission.png new file mode 100644 index 000000000..2b82dcca2 Binary files /dev/null and b/docs/media/admin_ui_grant_permission.png differ diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 6ed66a61d..596bd0180 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,15 +1,55 @@ # NetBox v3.4 -## v3.4.7 (FUTURE) +## v3.4.8 (FUTURE) ### Enhancements -* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms +* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types +* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group +* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table +* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation +* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled +* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN +* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type ### Bug Fixes +* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests +* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID +* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field +* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk +* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API +* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters +* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships +* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes +* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device +* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models +* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields + +--- + +## v3.4.7 (2023-03-28) + +### Enhancements + +* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval +* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms +* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set +* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter +* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings +* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces + +### Bug Fixes + +* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters +* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters * [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type * [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list +* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates +* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form +* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority +* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags +* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts --- diff --git a/mkdocs.yml b/mkdocs.yml index 2317dad6d..57ffaf461 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,9 @@ theme: custom_dir: docs/_theme/ icon: repo: fontawesome/brands/github + features: + - content.code.copy + - navigation.footer palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -20,7 +23,8 @@ theme: icon: material/lightbulb name: Switch to Light Mode plugins: - - search + - search: + lang: en - mkdocstrings: handlers: python: diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 477f9c1ab..e2cd2b9a9 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): template_code=CIRCUITTERMINATION_LINK, verbose_name='Side Z' ) - commit_rate = CommitRateColumn() + commit_rate = CommitRateColumn( + verbose_name='Commit Rate' + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='circuits:circuit_list' diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3168509ba..461113147 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') ) + filterset = filtersets.CircuitTypeFilterSet table = tables.CircuitTypeTable diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 493ccbbea..d9f378c6b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -24,6 +24,7 @@ __all__ = ( 'CableFilterSet', 'CabledObjectFilterSet', 'CableTerminationFilterSet', + 'CommonInterfaceFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -1321,11 +1322,63 @@ class PowerOutletFilterSet( fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] +class CommonInterfaceFilterSet(django_filters.FilterSet): + vlan_id = django_filters.CharFilter( + method='filter_vlan_id', + label=_('Assigned VLAN') + ) + vlan = django_filters.CharFilter( + method='filter_vlan', + label=_('Assigned VID') + ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label=_('VRF'), + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label=_('VRF (RD)'), + ) + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn', + queryset=L2VPN.objects.all(), + label=_('L2VPN (ID)'), + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('L2VPN'), + ) + + def filter_vlan_id(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id=value) | + Q(tagged_vlans=value) + ) + + def filter_vlan(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id__vid=value) | + Q(tagged_vlans__vid=value) + ) + + class InterfaceFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, CabledObjectFilterSet, - PathEndpointFilterSet + PathEndpointFilterSet, + CommonInterfaceFilterSet ): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis # members @@ -1370,14 +1423,6 @@ class InterfaceFilterSet( poe_type = django_filters.MultipleChoiceFilter( choices=InterfacePoETypeChoices ) - vlan_id = django_filters.CharFilter( - method='filter_vlan_id', - label=_('Assigned VLAN') - ) - vlan = django_filters.CharFilter( - method='filter_vlan', - label=_('Assigned VID') - ) type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, null_value=None @@ -1388,17 +1433,6 @@ class InterfaceFilterSet( rf_channel = django_filters.MultipleChoiceFilter( choices=WirelessChannelChoices ) - vrf_id = django_filters.ModelMultipleChoiceFilter( - field_name='vrf', - queryset=VRF.objects.all(), - label=_('VRF'), - ) - vrf = django_filters.ModelMultipleChoiceFilter( - field_name='vrf__rd', - queryset=VRF.objects.all(), - to_field_name='rd', - label=_('VRF (RD)'), - ) vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', queryset=VirtualDeviceContext.objects.all(), @@ -1416,17 +1450,6 @@ class InterfaceFilterSet( to_field_name='name', label='Virtual Device Context', ) - l2vpn_id = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn', - queryset=L2VPN.objects.all(), - label=_('L2VPN (ID)'), - ) - l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn__identifier', - queryset=L2VPN.objects.all(), - to_field_name='identifier', - label=_('L2VPN'), - ) class Meta: model = Interface @@ -1456,24 +1479,6 @@ class InterfaceFilterSet( except Device.DoesNotExist: return queryset.none() - def filter_vlan_id(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - return queryset.filter( - Q(untagged_vlan_id=value) | - Q(tagged_vlans=value) - ) - - def filter_vlan(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - return queryset.filter( - Q(untagged_vlan_id__vid=value) | - Q(tagged_vlans__vid=value) - ) - def filter_kind(self, queryset, name, value): value = value.strip().lower() return { @@ -1662,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): field_name='terminations__termination_type' ) termination_a_id = MultiValueNumberFilter( + method='filter_by_cable_end_a', field_name='terminations__termination_id' ) termination_b_type = ContentTypeFilter( field_name='terminations__termination_type' ) termination_b_id = MultiValueNumberFilter( + method='filter_by_cable_end_b', field_name='terminations__termination_id' ) type = django_filters.MultipleChoiceFilter( @@ -1725,6 +1732,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): # Supported objects: device, rack, location, site return queryset.filter(**{f'terminations___{name}__in': value}).distinct() + def filter_by_cable_end(self, queryset, name, value, side): + # Filter by termination id and cable_end type + return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct() + + def filter_by_cable_end_a(self, queryset, name, value): + # Filter by termination id and cable_end type + return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A) + + def filter_by_cable_end_b(self, queryset, name, value): + # Filter by termination id and cable_end type + return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B) + class CableTerminationFilterSet(BaseFilterSet): termination_type = ContentTypeFilter() diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 11fdfa6d2..4127aa3ea 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -103,9 +103,9 @@ class RearPortBulkCreateForm( class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name', 'label', 'position_pattern', 'description', 'tags') + field_order = ('name', 'label', 'position', 'description', 'tags') replication_fields = ('name', 'label', 'position') - position_pattern = ExpandableNameField( + position = ExpandableNameField( label=_('Position'), required=False, help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)') diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index bd466ca48..24bd3e62d 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1175,6 +1175,14 @@ class InterfaceBulkEditForm( }, label=_('LAG') ) + vdcs = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) speed = forms.IntegerField( required=False, widget=SelectSpeedWidget(), @@ -1240,14 +1248,14 @@ class InterfaceBulkEditForm( fieldsets = ( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), - ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( - 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index da658d732..d29e8e250 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -11,7 +11,9 @@ from dcim.models import * from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField +from utilities.forms import ( + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField +) from virtualization.models import Cluster from wireless.choices import WirelessRoleChoices from .common import ModuleCommonForm @@ -667,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent LAG interface') ) + vdcs = CSVModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + to_field_name='name', + help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")' + ) type = CSVChoiceField( choices=InterfaceTypeChoices, help_text=_('Physical medium') @@ -706,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', - 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) @@ -722,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm): self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) + self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params) def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data @@ -730,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm): else: return self.cleaned_data['enabled'] + def clean_vdcs(self): + for vdc in self.cleaned_data['vdcs']: + if vdc.device != self.cleaned_data['device']: + raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}") + return self.cleaned_data['vdcs'] + class FrontPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 6de193043..46f783cb7 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', ] def clean(self): diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 062734355..cf5f30ee4 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -152,8 +152,6 @@ class Cable(PrimaryModel): # Validate length and length_unit if self.length is not None and not self.length_unit: raise ValidationError("Must specify a unit when setting a cable length") - elif self.length is None: - self.length_unit = '' if self.pk is None and (not self.a_terminations or not self.b_terminations): raise ValidationError("Must define A and B terminations when creating a new cable.") @@ -187,6 +185,10 @@ class Cable(PrimaryModel): else: self._abs_length = None + # Clear length_unit if no length is defined + if self.length is None: + self.length_unit = '' + super().save(*args, **kwargs) # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 3b136987d..6ff58b0f0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -120,6 +120,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel): ), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Cache the original DeviceType ID for reference under clean() + self._original_device_type = self.device_type_id + def to_objectchange(self, action): objectchange = super().to_objectchange(action) if self.device_type is not None: @@ -131,6 +137,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel): def clean(self): super().clean() + if self.pk is not None and self._original_device_type != self.device_type_id: + raise ValidationError({ + "device_type": "Component templates cannot be moved to a different device type." + }) + # A component template must belong to a DeviceType *or* to a ModuleType if self.device_type and self.module_type: raise ValidationError( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 26a6ade98..c30ce3a97 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel): ), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Cache the original Device ID for reference under clean() + self._original_device = self.device_id + def __str__(self): if self.label: return f"{self.name} ({self.label})" @@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel): objectchange.related_object = self.device return objectchange + def clean(self): + super().clean() + + if self.pk is not None and self._original_device != self.device_id: + raise ValidationError({ + "device": "Components cannot be moved to a different device." + }) + @property def parent_object(self): return self.device @@ -794,8 +808,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({ 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", }) - elif self.rf_channel: - self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') # Validate channel width against interface type and selected channel (if any) if self.rf_channel_width: @@ -803,8 +815,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) - elif self.rf_channel: - self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') # VLAN validation @@ -815,6 +825,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd f"interface's parent device, or it must be global." }) + def save(self, *args, **kwargs): + + # Set absolute channel attributes from selected options + if self.rf_channel and not self.rf_channel_frequency: + self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') + if self.rf_channel and not self.rf_channel_width: + self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') + + super().save(*args, **kwargs) + @property def _occupied(self): return super()._occupied or bool(self.wireless_link_id) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 603129228..2061a951e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin): blank=True ) + images = GenericRelation( + to='extras.ImageAttachment' + ) + clone_fields = ( 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) @@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel): raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to location {self.location}.", }) - elif self.rack: - self.location = self.rack.location if self.rack is None: if self.face: @@ -776,8 +778,10 @@ class Device(PrimaryModel, ConfigContextModel): bulk_create: If True, bulk_create() will be called to create all components in a single query (default). Otherwise, save() will be called on each instance individually. """ - components = [obj.instantiate(device=self) for obj in queryset] - if components and bulk_create: + if bulk_create: + components = [obj.instantiate(device=self) for obj in queryset] + if not components: + return model = components[0]._meta.model model.objects.bulk_create(components) # Manually send the post_save signal for each of the newly created components @@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel): using='default', update_fields=None ) - elif components: - for component in components: + else: + for obj in queryset: + component = obj.instantiate(device=self) component.save() def save(self, *args, **kwargs): @@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel): if is_new and not self.airflow: self.airflow = self.device_type.airflow + if self.rack and self.rack.location: + self.location = self.rack.location + super().save(*args, **kwargs) # If this is a new Device, instantiate all the related components per the DeviceType definition diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 03be2fdb3..e61e0f2a3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin): # Validate outer dimensions and unit if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: raise ValidationError("Must specify a unit when setting an outer width/depth") - elif self.outer_width is None and self.outer_depth is None: - self.outer_unit = '' # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin): else: self._abs_max_weight = None + # Clear unit if outer width & depth are not set + if self.outer_width is None and self.outer_depth is None: + self.outer_unit = '' + super().save(*args, **kwargs) @property diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b49e799c..5a6261eba 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') ) + filterset = filtersets.RackRoleFilterSet table = tables.RackRoleTable @@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer') ) + filterset = filtersets.ManufacturerFilterSet table = tables.ManufacturerTable @@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView): device_count=count_related(Device, 'device_role'), vm_count=count_related(VirtualMachine, 'role') ) + filterset = filtersets.DeviceRoleFilterSet table = tables.DeviceRoleTable @@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView): class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() + filterset = filtersets.PlatformFilterSet table = tables.PlatformTable @@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItem.objects.all() + filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItemRole.objects.annotate( inventoryitem_count=count_related(InventoryItem, 'role'), ) + filterset = filtersets.InventoryItemRoleFilterSet table = tables.InventoryItemRoleTable diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8b9c6dcb1..01b841a0f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer): 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] + def validate_type(self, value): + if self.instance and self.instance.type != value: + raise serializers.ValidationError('Changing the type of custom fields is not supported.') + + return value + def get_data_type(self, obj): types = CustomFieldTypeChoices if obj.type == types.TYPE_INTEGER: diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a21cf21e2..b7e606f7d 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,6 +1,8 @@ +import json + from django import forms +from django.db.models import Q from django.contrib.contenttypes.models import ContentType -from django.http import QueryDict from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup @@ -36,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): object_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), # TODO: Come up with a canonical way to register suitable models - limit_choices_to=FeatureQuery('webhooks'), + limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']), required=False, help_text=_("Type of the related object (for object/multi-object fields only)") ) @@ -63,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): 'ui_visibility': StaticSelect(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present. + if self.instance.pk: + self.fields['type'].disabled = True + class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( @@ -128,11 +137,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, initial=None, **kwargs): - # Convert any parameters delivered via initial data to a dictionary + # Convert any parameters delivered via initial data to JSON data if initial and 'parameters' in initial: if type(initial['parameters']) is str: - # TODO: Make a utility function for this - initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + initial['parameters'] = json.loads(initial['parameters']) super().__init__(*args, initial=initial, **kwargs) @@ -254,6 +262,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): 'tenants', 'tags', ) + def __init__(self, *args, initial=None, **kwargs): + + # Convert data delivered via initial data to JSON data + if initial and 'data' in initial: + if type(initial['data']) is str: + initial['data'] = json.loads(initial['data']) + + super().__init__(*args, initial=initial, **kwargs) + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py index d2ec01006..ed7f49304 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form): help_text=_("Interval at which this report is re-run (in minutes)") ) - def clean_schedule_at(self): + def clean(self): scheduled_time = self.cleaned_data['schedule_at'] - if scheduled_time and scheduled_time < timezone.now(): + if scheduled_time and scheduled_time < local_now(): raise forms.ValidationError(_('Scheduled time must be in the future.')) - return scheduled_time + # When interval is used without schedule at, raise an exception + if self.cleaned_data['interval'] and not scheduled_time: + self.cleaned_data['schedule_at'] = local_now() + + return self.cleaned_data def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 8216c5413..ca7398132 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form): # When interval is used without schedule at, raise an exception if self.cleaned_data['_interval'] and not scheduled_time: - raise forms.ValidationError(_('Scheduled time must be set when recurs is used.')) + self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index d8d3510d7..4a38115dd 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -5,7 +5,7 @@ from django.urls import reverse from extras.querysets import ConfigContextQuerySet from netbox.models import ChangeLoggedModel -from netbox.models.features import WebhooksMixin +from netbox.models.features import CloningMixin, WebhooksMixin from utilities.utils import deepmerge @@ -19,7 +19,7 @@ __all__ = ( # Config contexts # -class ConfigContext(WebhooksMixin, ChangeLoggedModel): +class ConfigContext(CloningMixin, WebhooksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B @@ -108,6 +108,12 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): objects = ConfigContextQuerySet.as_manager() + clone_fields = ( + 'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', + 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', 'data', + ) + class Meta: ordering = ['weight', 'name'] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fa16b8501..836562b95 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ for ct in content_types: model = ct.model_class() - instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}) + instances = model.objects.filter(custom_field_data__has_key=self.name) for instance in instances: del instance.custom_field_data[self.name] model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e608f81b1..3cab6154d 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -245,7 +245,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged ) clone_fields = ( - 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: @@ -280,7 +280,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged } -class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='export_templates', @@ -301,7 +301,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): max_length=50, blank=True, verbose_name='MIME type', - help_text=_('Defaults to text/plain') + help_text=_('Defaults to text/plain; charset=utf-8') ) file_extension = models.CharField( max_length=15, @@ -313,6 +313,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text=_("Download file as attachment") ) + clone_fields = ( + 'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', + ) + class Meta: ordering = ('name',) @@ -353,7 +357,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): Render the template to an HTTP response, delivered as a named file attachment """ output = self.render(queryset) - mime_type = 'text/plain' if not self.mime_type else self.mime_type + mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type # Build the response response = HttpResponse(output, content_type=mime_type) @@ -406,7 +410,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge parameters = models.JSONField() clone_fields = ( - 'enabled', 'weight', + 'content_types', 'weight', 'enabled', 'parameters', ) class Meta: diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 827d969e3..a5a202614 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -5,7 +5,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, WebhooksMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -14,7 +14,7 @@ from utilities.fields import ColorField # Tags # -class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): +class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): id = models.BigAutoField( primary_key=True ) @@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): blank=True, ) + clone_fields = ( + 'color', 'description', + ) + class Meta: ordering = ['name'] diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 81a607eec..c915d596a 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,13 +1,10 @@ import datetime -from unittest import skipIf from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware -from django_rq.queues import get_connection from rest_framework import status -from rq import Worker from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from extras.api.views import ReportViewSet, ScriptViewSet @@ -16,8 +13,6 @@ from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases -rq_worker_running = Worker.count(get_connection('default')) - class AppTest(APITestCase): @@ -107,6 +102,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): bulk_update_data = { 'description': 'New description', } + update_data = { + 'content_types': ['dcim.device'], + 'name': 'New_Name', + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -539,16 +539,6 @@ class ReportTest(APITestCase): self.assertEqual(response.data['name'], self.TestReport.__name__) - @skipIf(not rq_worker_running, "RQ worker not running") - def test_run_report(self): - self.add_permissions('extras.run_script') - - url = reverse('extras-api:report-run', kwargs={'pk': None}) - response = self.client.post(url, {}, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - - self.assertEqual(response.data['result']['status']['value'], 'pending') - class ScriptTest(APITestCase): @@ -589,27 +579,6 @@ class ScriptTest(APITestCase): self.assertEqual(response.data['vars']['var2'], 'IntegerVar') self.assertEqual(response.data['vars']['var3'], 'BooleanVar') - @skipIf(not rq_worker_running, "RQ worker not running") - def test_run_script(self): - self.add_permissions('extras.run_script') - - script_data = { - 'var1': 'FooBar', - 'var2': 123, - 'var3': False, - } - - data = { - 'data': script_data, - 'commit': True, - } - - url = reverse('extras-api:script-detail', kwargs={'pk': None}) - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - - self.assertEqual(response.data['result']['status']['value'], 'pending') - class CreatedUpdatedFilterTest(APITestCase): diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 35402bda3..dee2127dd 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -4,6 +4,7 @@ from django.test import TestCase from dcim.forms import SiteForm from dcim.models import Site from extras.choices import CustomFieldTypeChoices +from extras.forms import SavedFilterForm from extras.models import CustomField @@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase): for field_type, _ in CustomFieldTypeChoices.CHOICES: self.assertIn(field_type, instance.custom_field_data) self.assertIsNone(instance.custom_field_data[field_type]) + + +class SavedFilterFormTest(TestCase): + + def test_basic_submit(self): + """ + Test form submission and validation + """ + form = SavedFilterForm({ + 'name': 'test-sf', + 'slug': 'test-sf', + 'content_types': [ContentType.objects.get_for_model(Site).pk], + 'weight': 100, + 'parameters': { + "status": [ + "active" + ] + } + }) + self.assertTrue(form.is_valid()) + form.save() diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 91d3b5c58..d8a3c377d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView): class ConfigContextBulkDeleteView(generic.BulkDeleteView): queryset = ConfigContext.objects.all() + filterset = filtersets.ConfigContextFilterSet table = tables.ConfigContextTable diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 972b98db2..6546722ca 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') + fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description', + 'comments', 'tags') class L2VPNTerminationImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index e8bf13375..9effc3add 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if self.prefix: - # Clear host bits from prefix - self.prefix = self.prefix.cidr - # /0 masks are not acceptable if self.prefix.prefixlen == 0: raise ValidationError({ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index ea6441650..5af1bc769 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.add_permissions('ipam.delete_vlan') url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk}) - with disable_warnings('django.request'): + with disable_warnings('netbox.api.views.ModelViewSet'): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_409_CONFLICT) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 130014f3f..5f10fb5a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView): class RoleBulkDeleteView(generic.BulkDeleteView): queryset = Role.objects.all() + filterset = filtersets.RoleFilterSet table = tables.RoleTable diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index a4c8e0ec2..f686828b6 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): class Meta: abstract = True + def clean(self): + """ + Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist. + """ + super().clean() + + for field in self._meta.get_fields(): + if isinstance(field, GenericForeignKey): + ct_value = getattr(self, field.ct_field) + fk_value = getattr(self, field.fk_field) + + if ct_value is None and fk_value is not None: + raise ValidationError({ + field.ct_field: "This field cannot be null.", + }) + if fk_value is None and ct_value is not None: + raise ValidationError({ + field.fk_field: "This field cannot be null.", + }) + + if ct_value and fk_value: + klass = getattr(self, field.ct_field).model_class() + if not klass.objects.filter(pk=fk_value).exists(): + raise ValidationError({ + field.fk_field: f"Related object not found using the provided value: {fk_value}." + }) + class PrimaryModel(NetBoxModel): """ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 62482a26f..71c1a7c5c 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,4 @@ +import json from collections import defaultdict from functools import cached_property @@ -111,7 +112,11 @@ class CloningMixin(models.Model): for field_name in getattr(self, 'clone_fields', []): field = self._meta.get_field(field_name) field_value = field.value_from_object(self) - if field_value not in (None, ''): + if field_value and isinstance(field, models.ManyToManyField): + attrs[field_name] = [v.pk for v in field_value] + elif field_value and isinstance(field, models.JSONField): + attrs[field_name] = json.dumps(field_value) + elif field_value not in (None, ''): attrs[field_name] = field_value # Include tags (if applicable) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 45fe32841..be2d95630 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.4.7-dev' +VERSION = '3.4.8-dev' # Hostname HOSTNAME = platform.node() @@ -396,8 +396,10 @@ TEMPLATES = [ ] # Set up authentication backends +if type(REMOTE_AUTH_BACKEND) not in (list, tuple): + REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] AUTHENTICATION_BACKENDS = [ - REMOTE_AUTH_BACKEND, + *REMOTE_AUTH_BACKEND, 'netbox.authentication.ObjectPermissionBackend', ] diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 6060475d8..211b869e6 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -16,7 +16,6 @@ from django_tables2.export import TableExport from extras.models import ExportTemplate from extras.signals import clear_webhooks -from utilities.choices import ImportFormatChoices from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields @@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): ] nullified_fields = request.POST.getlist('_nullify') updated_objects = [] + model_fields = {} + m2m_fields = {} + + # Build list of model fields and m2m fields for later iteration + for name in standard_fields: + try: + model_field = self.queryset.model._meta.get_field(name) + if isinstance(model_field, (ManyToManyField, ManyToManyRel)): + m2m_fields[name] = model_field + else: + model_fields[name] = model_field + + except FieldDoesNotExist: + # This form field is used to modify a field rather than set its value directly + model_fields[name] = None for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): @@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): obj.snapshot() # Update standard fields. If a field is listed in _nullify, delete its value. - for name in standard_fields: - - try: - model_field = self.queryset.model._meta.get_field(name) - except FieldDoesNotExist: - # This form field is used to modify a field rather than set its value directly - model_field = None - + for name, model_field in model_fields.items(): # Handle nullification if name in form.nullable_fields and name in nullified_fields: - if isinstance(model_field, ManyToManyField): - getattr(obj, name).set([]) - else: - setattr(obj, name, None if model_field.null else '') - - # ManyToManyFields - elif isinstance(model_field, (ManyToManyField, ManyToManyRel)): - if form.cleaned_data[name]: - getattr(obj, name).set(form.cleaned_data[name]) + setattr(obj, name, None if model_field.null else '') # Normal fields elif name in form.changed_data: setattr(obj, name, form.cleaned_data[name]) @@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): obj.save() updated_objects.append(obj) + # Handle M2M fields after save + for name, m2m_field in m2m_fields.items(): + if name in form.nullable_fields and name in nullified_fields: + getattr(obj, name).clear() + else: + getattr(obj, name).set(form.cleaned_data[name]) + # Add/remove tags if form.cleaned_data.get('add_tags', None): obj.tags.add(*form.cleaned_data['add_tags']) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 2fc6d0d98..6b247d81a 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -70,10 +70,17 @@ Blocks: {% endif %} + {% if settings.DEBUG and not settings.DEVELOPER %} + + {% endif %} + {% if config.MAINTENANCE_MODE %} {% endif %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 1c747b44b..0ad0637e3 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -98,7 +98,7 @@
Comments
- {% render_field form.comments %} + {% render_field form.comments %}
{% if form.custom_fields %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index aa31db97c..896e4d8a5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -139,7 +139,7 @@ {% if object.virtual_chassis.master == vc_member %}{% endif %} - {{ vc_member.vc_priority|default:"" }} + {{ vc_member.vc_priority|placeholder }} {% endfor %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index b814e65ef..185482162 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -111,7 +111,6 @@
-
Comments
{% render_field form.comments %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 930390a56..ab2fa3382 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -98,6 +98,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index cd9ed637a..03624df1f 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -85,7 +85,6 @@ {% endif %}
-
Comments
{% render_field form.comments %}
{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 3c0b10826..fe68a9fc7 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -8,6 +8,7 @@ {% render_field form.name %} {% render_field form.domain %} + {% render_field form.description %} {% render_field form.tags %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 433837cf5..82b4c5bcc 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -27,7 +27,6 @@
-
Comments
{% render_field vc_form.comments %}
diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index c61fb723f..ff2ca26ad 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -85,7 +85,6 @@ Context: {% if form.comments %}
-
Comments
{% render_field form.comments %}
{% endif %} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index bf86e6c41..bc0a6797c 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -33,9 +33,6 @@ {% endif %}
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index b9a988009..4aa1c610a 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -139,9 +139,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html index 5c47dd2f8..2d8d183c5 100644 --- a/netbox/templates/ipam/service_create.html +++ b/netbox/templates/ipam/service_create.html @@ -66,9 +66,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index 709d816c1..f2a2f711d 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -53,9 +53,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index f4432efe3..0c4b68e7e 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -56,9 +56,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html deleted file mode 100644 index 462ae5148..000000000 --- a/netbox/templates/wireless/wirelesslink_edit.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
-
-
-
Side A
-
- {% render_field form.device_a %} - {% render_field form.interface_a %} -
-
-
-
-
-
Side B
-
- {% render_field form.device_b %} - {% render_field form.interface_b %} -
-
-
-
-
-
Comments
-
- {% render_field form.comments %} -
- {% if form.custom_fields %} -
-
-
Custom Fields
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f4c9b6d04..3ee39f0a7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -84,6 +84,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView): 'tenant_count', cumulative=True ) + filterset = filtersets.TenantGroupFilterSet table = tables.TenantGroupTable @@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): 'contact_count', cumulative=True ) + filterset = filtersets.ContactGroupFilterSet table = tables.ContactGroupTable @@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView): class ContactRoleBulkDeleteView(generic.BulkDeleteView): queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet table = tables.ContactRoleTable diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index f1f1fc975..3194a2d28 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField from netbox.api.serializers import ValidatedModelSerializer @@ -91,6 +92,16 @@ class TokenSerializer(ValidatedModelSerializer): data['key'] = Token.generate_key() return super().to_internal_value(data) + def validate(self, data): + + # If the Token is being created on behalf of another user, enforce the grant_token permission. + request = self.context.get('request') + token_user = data.get('user') + if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'): + raise PermissionDenied("This user does not have permission to create tokens for other users.") + + return super().validate(data) + class TokenProvisionSerializer(serializers.Serializer): username = serializers.CharField() diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index a0bf8a49e..281f656d2 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -12,7 +12,7 @@ class AppTest(APITestCase): def test_root(self): url = reverse('users-api:api-root') - response = self.client.get('{}?format=api'.format(url), **self.header) + response = self.client.get(f'{url}?format=api', **self.header) self.assertEqual(response.status_code, 200) @@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase): 'password': 'password6', }, ] + bulk_update_data = { + 'email': 'test@example.com', + } @classmethod def setUpTestData(cls): users = ( - User(username='User_1'), - User(username='User_2'), - User(username='User_3'), + User(username='User_1', password='password1'), + User(username='User_2', password='password2'), + User(username='User_3', password='password3'), ) User.objects.bulk_create(users) @@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase): ) Group.objects.bulk_create(users) + def test_bulk_update_objects(self): + """ + Disabled test. There's no attribute we can set in bulk for Groups. + """ + return + class TokenTest( # No GraphQL support for Token @@ -144,6 +153,26 @@ class TokenTest( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 403) + def test_provision_token_other_user(self): + """ + Test provisioning a Token for a different User with & without the grant_token permission. + """ + self.add_permissions('users.add_token') + user2 = User.objects.create_user(username='testuser2') + data = { + 'user': user2.id, + } + url = reverse('users-api:token-list') + + # Attempt to create a new Token for User2 *without* the grant_token permission + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign grant_token permission and successfully create a new Token for User2 + self.add_permissions('users.grant_token') + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 201) + class ObjectPermissionTest( # No GraphQL support for ObjectPermission diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index ee9543452..bb8226e4d 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -34,8 +34,8 @@ class CommentField(forms.CharField): Markdown syntax is supported """ - def __init__(self, *, label='', help_text=help_text, required=False, **kwargs): - super().__init__(label=label, help_text=help_text, required=required, **kwargs) + def __init__(self, *, help_text=help_text, required=False, **kwargs): + super().__init__(help_text=help_text, required=required, **kwargs) class SlugField(forms.SlugField): diff --git a/netbox/utilities/templates/buttons/clone.html b/netbox/utilities/templates/buttons/clone.html index 24e685c3d..8fe62a1c8 100644 --- a/netbox/utilities/templates/buttons/clone.html +++ b/netbox/utilities/templates/buttons/clone.html @@ -1,3 +1,5 @@ - -  Clone - +{% if url %} + + Clone + +{% endif %} diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 85c04df92..85bd86bbc 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -3,11 +3,8 @@
- {# Render the field label, except for: #} - {# 1. Checkboxes (label appears to the right of the field #} - {# 2. Textareas with no label set (will expand across entire row) #} - {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %} - {% else %} + {# Render the field label, except for checkboxes #} + {% if field|widget_type != 'checkboxinput' %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index bcdb099d8..dbd0240b9 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -20,6 +20,8 @@ def clone_button(instance): param_string = prepare_cloned_fields(instance).urlencode() if param_string: url = f'{url}?{param_string}' + else: + url = None return { 'url': url, diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 5f6e80a3e..6cdf33dd1 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,5 +1,6 @@ import datetime import decimal +import json from urllib.parse import quote from typing import Dict, Any @@ -321,7 +322,7 @@ def applied_filters(context, model, form, query_params): save_link = None if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET: content_type = ContentType.objects.get_for_model(model).pk - parameters = context['request'].GET.urlencode() + parameters = json.dumps(context['request'].GET) url = reverse('extras:savedfilter_add') save_link = f"{url}?content_types={content_type}¶meters={quote(parameters)}" diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index aec0d896c..57092bb7d 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -48,6 +48,10 @@ def get_viewname(model, action=None, rest_api=False): if is_plugin: viewname = f'plugins-api:{app_label}-api:{model_name}' else: + # Alter the app_label for group and user model_name to point to users app + if app_label == 'auth' and model_name in ['group', 'user']: + app_label = 'users' + viewname = f'{app_label}-api:{model_name}' # Append the action, if any if action: diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 8f656811a..cf716ca32 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,9 +2,9 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ +from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet -from ipam.models import L2VPN, VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -250,7 +250,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) -class VMInterfaceFilterSet(NetBoxModelFilterSet): +class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', queryset=Cluster.objects.all(), @@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet): mac_address = MultiValueMACAddressFilter( label=_('MAC address'), ) - vrf_id = django_filters.ModelMultipleChoiceFilter( - field_name='vrf', - queryset=VRF.objects.all(), - label=_('VRF'), - ) - vrf = django_filters.ModelMultipleChoiceFilter( - field_name='vrf__rd', - queryset=VRF.objects.all(), - to_field_name='rd', - label=_('VRF (RD)'), - ) - l2vpn_id = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn', - queryset=L2VPN.objects.all(), - label=_('L2VPN (ID)'), - ) - l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn__identifier', - queryset=L2VPN.objects.all(), - to_field_name='identifier', - label=_('L2VPN'), - ) class Meta: model = VMInterface diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index cc39044f9..6e9cc5664 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): raise ValidationError({ 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).' }) - elif self.cluster: - self.site = self.cluster.site # Validate assigned cluster device if self.device and not self.cluster: @@ -201,6 +199,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): field: f"The specified IP address ({ip}) is not assigned to this VM.", }) + def save(self, *args, **kwargs): + + # Assign site from cluster if not set + if self.cluster and not self.site: + self.site = self.cluster.site + + super().save(*args, **kwargs) + def get_status_color(self): return VirtualMachineStatusChoices.colors.get(self.status) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index f7fa4cb39..782b9f07f 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -72,7 +72,7 @@ class VirtualMachineTestCase(TestCase): # VM with cluster site but no direct site should have its site set automatically vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0]) - vm.full_clean() + vm.save() self.assertEqual(vm.site, sites[0]) def test_vm_name_case_sensitivity(self): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index af130fcce..1ff21f1e0 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -80,6 +80,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') ) + filterset = filtersets.ClusterTypeFilterSet table = tables.ClusterTypeTable @@ -147,6 +148,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ) + filterset = filtersets.ClusterGroupFilterSet table = tables.ClusterGroupTable diff --git a/requirements.txt b/requirements.txt index 92f60335a..5844f62b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ bleach==5.0.1 Django==4.1.7 django-cors-headers==3.14.0 django-debug-toolbar==3.8.1 -django-filter==22.1 +django-filter==23.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 @@ -19,18 +19,18 @@ graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.2 +mkdocs-material==9.1.4 mkdocstrings[python-legacy]==0.20.0 netaddr==0.8.0 Pillow==9.4.0 psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 social-auth-app-django==5.0.0 -social-auth-core[openidconnect]==4.3.0 +social-auth-core[openidconnect]==4.4.0 svgwrite==1.4.3 -tablib==3.3.0 -tzdata==2022.7 +tablib==3.4.0 +tzdata==2023.2 # Workaround for #7401 jsonschema==3.2.0