Przeglądaj źródła

Merge pull request #12234 from netbox-community/develop

Release v3.4.8
Jeremy Stretch 2 lat temu
rodzic
commit
3c91331e16
62 zmienionych plików z 389 dodań i 255 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 0 2
      README.md
  4. 1 1
      docs/configuration/development.md
  5. 1 1
      docs/customization/reports.md
  6. 1 1
      docs/installation/1-postgresql.md
  7. 8 3
      docs/integrations/rest-api.md
  8. BIN
      docs/media/admin_ui_grant_permission.png
  9. 32 0
      docs/release-notes/version-3.4.md
  10. 5 1
      mkdocs.yml
  11. 3 1
      netbox/circuits/tables/circuits.py
  12. 1 0
      netbox/circuits/views.py
  13. 68 49
      netbox/dcim/filtersets.py
  14. 2 2
      netbox/dcim/forms/bulk_create.py
  15. 4 2
      netbox/dcim/models/cables.py
  16. 11 0
      netbox/dcim/models/device_component_templates.py
  17. 24 4
      netbox/dcim/models/device_components.py
  18. 14 6
      netbox/dcim/models/devices.py
  19. 4 2
      netbox/dcim/models/racks.py
  20. 6 0
      netbox/dcim/views.py
  21. 6 0
      netbox/extras/api/serializers.py
  22. 9 1
      netbox/extras/forms/model_forms.py
  23. 1 1
      netbox/extras/models/customfields.py
  24. 2 2
      netbox/extras/models/models.py
  25. 5 36
      netbox/extras/tests/test_api.py
  26. 22 0
      netbox/extras/tests/test_forms.py
  27. 1 0
      netbox/extras/views.py
  28. 2 1
      netbox/ipam/forms/bulk_import.py
  29. 0 3
      netbox/ipam/models/ip.py
  30. 1 1
      netbox/ipam/tests/test_api.py
  31. 1 0
      netbox/ipam/views.py
  32. 28 0
      netbox/netbox/models/__init__.py
  33. 1 1
      netbox/netbox/settings.py
  34. 24 18
      netbox/netbox/views/generic/bulk_views.py
  35. 9 2
      netbox/templates/base/layout.html
  36. 1 1
      netbox/templates/dcim/cable_edit.html
  37. 0 1
      netbox/templates/dcim/device_edit.html
  38. 1 0
      netbox/templates/dcim/devicetype.html
  39. 0 1
      netbox/templates/dcim/rack_edit.html
  40. 0 1
      netbox/templates/dcim/virtualchassis_edit.html
  41. 2 2
      netbox/templates/generic/bulk_import.html
  42. 0 1
      netbox/templates/generic/object_edit.html
  43. 0 3
      netbox/templates/ipam/fhrpgroup_edit.html
  44. 0 3
      netbox/templates/ipam/ipaddress_edit.html
  45. 0 3
      netbox/templates/ipam/service_create.html
  46. 0 3
      netbox/templates/ipam/service_edit.html
  47. 0 3
      netbox/templates/ipam/vlan_edit.html
  48. 0 39
      netbox/templates/wireless/wirelesslink_edit.html
  49. 3 0
      netbox/tenancy/views.py
  50. 11 0
      netbox/users/api/serializers.py
  51. 33 4
      netbox/users/tests/test_api.py
  52. 2 2
      netbox/utilities/forms/fields/fields.py
  53. 5 3
      netbox/utilities/templates/buttons/clone.html
  54. 2 5
      netbox/utilities/templates/form_helpers/render_field.html
  55. 2 0
      netbox/utilities/templatetags/buttons.py
  56. 2 1
      netbox/utilities/templatetags/helpers.py
  57. 4 0
      netbox/utilities/utils.py
  58. 2 24
      netbox/virtualization/filtersets.py
  59. 8 2
      netbox/virtualization/models/virtualmachines.py
  60. 1 1
      netbox/virtualization/tests/test_models.py
  61. 2 0
      netbox/virtualization/views.py
  62. 9 9
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.4.7
+      placeholder: v3.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.4.7
+      placeholder: v3.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 0 - 2
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)
   [![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)
   [![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)
   <br />
   <br />
   [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
   &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

+ 1 - 1
docs/configuration/development.md

@@ -18,4 +18,4 @@ interface.
 
 
 Default: False
 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.

+ 1 - 1
docs/customization/reports.md

@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
 !!! note
 !!! 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.
     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
 ### Via the Web UI
 
 

+ 1 - 1
docs/installation/1-postgresql.md

@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
 ```postgresql
 ```postgresql
 CREATE DATABASE netbox;
 CREATE DATABASE netbox;
 CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
 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"
 !!! danger "Use a strong password"

+ 8 - 3
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
 #### 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.)
 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
 ### Authenticating to the API
 
 

BIN
docs/media/admin_ui_grant_permission.png


+ 32 - 0
docs/release-notes/version-3.4.md

@@ -1,5 +1,37 @@
 # NetBox v3.4
 # NetBox v3.4
 
 
+## v3.4.8 (2023-04-12)
+
+### Enhancements
+
+* [#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
+* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
+
+### Bug Fixes
+
+* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
+* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
+* [#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
+* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
+* [#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
+* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
+
+---
+
 ## v3.4.7 (2023-03-28)
 ## v3.4.7 (2023-03-28)
 
 
 ### Enhancements
 ### Enhancements

+ 5 - 1
mkdocs.yml

@@ -8,6 +8,9 @@ theme:
   custom_dir: docs/_theme/
   custom_dir: docs/_theme/
   icon:
   icon:
     repo: fontawesome/brands/github
     repo: fontawesome/brands/github
+  features:
+    - content.code.copy
+    - navigation.footer
   palette:
   palette:
     - media: "(prefers-color-scheme: light)"
     - media: "(prefers-color-scheme: light)"
       scheme: default
       scheme: default
@@ -20,7 +23,8 @@ theme:
         icon: material/lightbulb
         icon: material/lightbulb
         name: Switch to Light Mode
         name: Switch to Light Mode
 plugins:
 plugins:
-  - search
+  - search:
+      lang: en
   - mkdocstrings:
   - mkdocstrings:
       handlers:
       handlers:
         python:
         python:

+ 3 - 1
netbox/circuits/tables/circuits.py

@@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         template_code=CIRCUITTERMINATION_LINK,
         template_code=CIRCUITTERMINATION_LINK,
         verbose_name='Side Z'
         verbose_name='Side Z'
     )
     )
-    commit_rate = CommitRateColumn()
+    commit_rate = CommitRateColumn(
+        verbose_name='Commit Rate'
+    )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
         url_name='circuits:circuit_list'

+ 1 - 0
netbox/circuits/views.py

@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
         circuit_count=count_related(Circuit, 'type')
     )
     )
+    filterset = filtersets.CircuitTypeFilterSet
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
 
 
 
 

+ 68 - 49
netbox/dcim/filtersets.py

@@ -24,6 +24,7 @@ __all__ = (
     'CableFilterSet',
     'CableFilterSet',
     'CabledObjectFilterSet',
     'CabledObjectFilterSet',
     'CableTerminationFilterSet',
     'CableTerminationFilterSet',
+    'CommonInterfaceFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsolePortFilterSet',
     'ConsolePortFilterSet',
     'ConsolePortTemplateFilterSet',
     'ConsolePortTemplateFilterSet',
@@ -1321,11 +1322,63 @@ class PowerOutletFilterSet(
         fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
         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(
 class InterfaceFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
     CabledObjectFilterSet,
     CabledObjectFilterSet,
-    PathEndpointFilterSet
+    PathEndpointFilterSet,
+    CommonInterfaceFilterSet
 ):
 ):
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
     # members
     # members
@@ -1370,14 +1423,6 @@ class InterfaceFilterSet(
     poe_type = django_filters.MultipleChoiceFilter(
     poe_type = django_filters.MultipleChoiceFilter(
         choices=InterfacePoETypeChoices
         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(
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         choices=InterfaceTypeChoices,
         null_value=None
         null_value=None
@@ -1388,17 +1433,6 @@ class InterfaceFilterSet(
     rf_channel = django_filters.MultipleChoiceFilter(
     rf_channel = django_filters.MultipleChoiceFilter(
         choices=WirelessChannelChoices
         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(
     vdc_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs',
         field_name='vdcs',
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
@@ -1416,17 +1450,6 @@ class InterfaceFilterSet(
         to_field_name='name',
         to_field_name='name',
         label='Virtual Device Context',
         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:
     class Meta:
         model = Interface
         model = Interface
@@ -1456,24 +1479,6 @@ class InterfaceFilterSet(
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             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):
     def filter_kind(self, queryset, name, value):
         value = value.strip().lower()
         value = value.strip().lower()
         return {
         return {
@@ -1662,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         field_name='terminations__termination_type'
         field_name='terminations__termination_type'
     )
     )
     termination_a_id = MultiValueNumberFilter(
     termination_a_id = MultiValueNumberFilter(
+        method='filter_by_cable_end_a',
         field_name='terminations__termination_id'
         field_name='terminations__termination_id'
     )
     )
     termination_b_type = ContentTypeFilter(
     termination_b_type = ContentTypeFilter(
         field_name='terminations__termination_type'
         field_name='terminations__termination_type'
     )
     )
     termination_b_id = MultiValueNumberFilter(
     termination_b_id = MultiValueNumberFilter(
+        method='filter_by_cable_end_b',
         field_name='terminations__termination_id'
         field_name='terminations__termination_id'
     )
     )
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1725,6 +1732,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         # Supported objects: device, rack, location, site
         # Supported objects: device, rack, location, site
         return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
         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):
 class CableTerminationFilterSet(BaseFilterSet):
     termination_type = ContentTypeFilter()
     termination_type = ContentTypeFilter()

+ 2 - 2
netbox/dcim/forms/bulk_create.py

@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
 
 
 class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
 class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
     model = ModuleBay
     model = ModuleBay
-    field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
+    field_order = ('name', 'label', 'position', 'description', 'tags')
     replication_fields = ('name', 'label', 'position')
     replication_fields = ('name', 'label', 'position')
-    position_pattern = ExpandableNameField(
+    position = ExpandableNameField(
         label=_('Position'),
         label=_('Position'),
         required=False,
         required=False,
         help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
         help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')

+ 4 - 2
netbox/dcim/models/cables.py

@@ -152,8 +152,6 @@ class Cable(PrimaryModel):
         # Validate length and length_unit
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
         if self.length is not None and not self.length_unit:
             raise ValidationError("Must specify a unit when setting a cable length")
             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):
         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.")
             raise ValidationError("Must define A and B terminations when creating a new cable.")
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
         else:
         else:
             self._abs_length = None
             self._abs_length = None
 
 
+        # Clear length_unit if no length is defined
+        if self.length is None:
+            self.length_unit = ''
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)

+ 11 - 0
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):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)
         if self.device_type is not None:
         if self.device_type is not None:
@@ -131,6 +137,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
     def clean(self):
     def clean(self):
         super().clean()
         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
         # A component template must belong to a DeviceType *or* to a ModuleType
         if self.device_type and self.module_type:
         if self.device_type and self.module_type:
             raise ValidationError(
             raise ValidationError(

+ 24 - 4
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):
     def __str__(self):
         if self.label:
         if self.label:
             return f"{self.name} ({self.label})"
             return f"{self.name} ({self.label})"
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
         objectchange.related_object = self.device
         objectchange.related_object = self.device
         return objectchange
         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
     @property
     def parent_object(self):
     def parent_object(self):
         return self.device
         return self.device
@@ -794,8 +808,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
                 raise ValidationError({
                 raise ValidationError({
                     'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
                     '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)
         # Validate channel width against interface type and selected channel (if any)
         if self.rf_channel_width:
         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."})
                 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'):
             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."})
                 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
         # VLAN validation
 
 
@@ -815,6 +825,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
                                  f"interface's parent device, or it must be global."
                                  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
     @property
     def _occupied(self):
     def _occupied(self):
         return super()._occupied or bool(self.wireless_link_id)
         return super()._occupied or bool(self.wireless_link_id)

+ 14 - 6
netbox/dcim/models/devices.py

@@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin):
         blank=True
         blank=True
     )
     )
 
 
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
+
     clone_fields = (
     clone_fields = (
         'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
         'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
     )
     )
@@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel):
             raise ValidationError({
             raise ValidationError({
                 'rack': f"Rack {self.rack} does not belong to location {self.location}.",
                 '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.rack is None:
             if self.face:
             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
             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.
                          (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 = components[0]._meta.model
             model.objects.bulk_create(components)
             model.objects.bulk_create(components)
             # Manually send the post_save signal for each of the newly created components
             # Manually send the post_save signal for each of the newly created components
@@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel):
                     using='default',
                     using='default',
                     update_fields=None
                     update_fields=None
                 )
                 )
-        elif components:
-            for component in components:
+        else:
+            for obj in queryset:
+                component = obj.instantiate(device=self)
                 component.save()
                 component.save()
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel):
         if is_new and not self.airflow:
         if is_new and not self.airflow:
             self.airflow = self.device_type.airflow
             self.airflow = self.device_type.airflow
 
 
+        if self.rack and self.rack.location:
+            self.location = self.rack.location
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         # If this is a new Device, instantiate all the related components per the DeviceType definition
         # If this is a new Device, instantiate all the related components per the DeviceType definition

+ 4 - 2
netbox/dcim/models/racks.py

@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
         # Validate outer dimensions and unit
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_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")
             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
         # Validate max_weight and weight_unit
         if self.max_weight and not self.weight_unit:
         if self.max_weight and not self.weight_unit:
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
         else:
         else:
             self._abs_max_weight = None
             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)
         super().save(*args, **kwargs)
 
 
     @property
     @property

+ 6 - 0
netbox/dcim/views.py

@@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
     )
     )
+    filterset = filtersets.RackRoleFilterSet
     table = tables.RackRoleTable
     table = tables.RackRoleTable
 
 
 
 
@@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
         devicetype_count=count_related(DeviceType, 'manufacturer')
     )
     )
+    filterset = filtersets.ManufacturerFilterSet
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
 
 
 
 
@@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
         device_count=count_related(Device, 'device_role'),
         device_count=count_related(Device, 'device_role'),
         vm_count=count_related(VirtualMachine, 'role')
         vm_count=count_related(VirtualMachine, 'role')
     )
     )
+    filterset = filtersets.DeviceRoleFilterSet
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
 
 
 
 
@@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
 
 
 class PlatformBulkDeleteView(generic.BulkDeleteView):
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
+    filterset = filtersets.PlatformFilterSet
     table = tables.PlatformTable
     table = tables.PlatformTable
 
 
 
 
@@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
 
 
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
+    filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
 
 
@@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = InventoryItemRole.objects.annotate(
     queryset = InventoryItemRole.objects.annotate(
         inventoryitem_count=count_related(InventoryItem, 'role'),
         inventoryitem_count=count_related(InventoryItem, 'role'),
     )
     )
+    filterset = filtersets.InventoryItemRoleFilterSet
     table = tables.InventoryItemRoleTable
     table = tables.InventoryItemRoleTable
 
 
 
 

+ 6 - 0
netbox/extras/api/serializers.py

@@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
             'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
             '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):
     def get_data_type(self, obj):
         types = CustomFieldTypeChoices
         types = CustomFieldTypeChoices
         if obj.type == types.TYPE_INTEGER:
         if obj.type == types.TYPE_INTEGER:

+ 9 - 1
netbox/extras/forms/model_forms.py

@@ -1,6 +1,7 @@
 import json
 import json
 
 
 from django import forms
 from django import forms
+from django.db.models import Q
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -37,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         # TODO: Come up with a canonical way to register suitable models
         # 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,
         required=False,
         help_text=_("Type of the related object (for object/multi-object fields only)")
         help_text=_("Type of the related object (for object/multi-object fields only)")
     )
     )
@@ -64,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
             'ui_visibility': StaticSelect(),
             '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):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(

+ 1 - 1
netbox/extras/models/customfields.py

@@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
         """
         """
         for ct in content_types:
         for ct in content_types:
             model = ct.model_class()
             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:
             for instance in instances:
                 del instance.custom_field_data[self.name]
                 del instance.custom_field_data[self.name]
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)

+ 2 - 2
netbox/extras/models/models.py

@@ -301,7 +301,7 @@ class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLo
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
         verbose_name='MIME type',
         verbose_name='MIME type',
-        help_text=_('Defaults to <code>text/plain</code>')
+        help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
     )
     )
     file_extension = models.CharField(
     file_extension = models.CharField(
         max_length=15,
         max_length=15,
@@ -357,7 +357,7 @@ class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLo
         Render the template to an HTTP response, delivered as a named file attachment
         Render the template to an HTTP response, delivered as a named file attachment
         """
         """
         output = self.render(queryset)
         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
         # Build the response
         response = HttpResponse(output, content_type=mime_type)
         response = HttpResponse(output, content_type=mime_type)

+ 5 - 36
netbox/extras/tests/test_api.py

@@ -1,13 +1,10 @@
 import datetime
 import datetime
-from unittest import skipIf
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
-from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
-from rq import Worker
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
 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 extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
-rq_worker_running = Worker.count(get_connection('default'))
-
 
 
 class AppTest(APITestCase):
 class AppTest(APITestCase):
 
 
@@ -107,6 +102,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
+    update_data = {
+        'content_types': ['dcim.device'],
+        'name': 'New_Name',
+        'description': 'New description',
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -539,16 +539,6 @@ class ReportTest(APITestCase):
 
 
         self.assertEqual(response.data['name'], self.TestReport.__name__)
         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):
 class ScriptTest(APITestCase):
 
 
@@ -589,27 +579,6 @@ class ScriptTest(APITestCase):
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         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):
 class CreatedUpdatedFilterTest(APITestCase):
 
 

+ 22 - 0
netbox/extras/tests/test_forms.py

@@ -4,6 +4,7 @@ from django.test import TestCase
 from dcim.forms import SiteForm
 from dcim.forms import SiteForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
+from extras.forms import SavedFilterForm
 from extras.models import CustomField
 from extras.models import CustomField
 
 
 
 
@@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
         for field_type, _ in CustomFieldTypeChoices.CHOICES:
         for field_type, _ in CustomFieldTypeChoices.CHOICES:
             self.assertIn(field_type, instance.custom_field_data)
             self.assertIn(field_type, instance.custom_field_data)
             self.assertIsNone(instance.custom_field_data[field_type])
             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()

+ 1 - 0
netbox/extras/views.py

@@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
 
 
 class ConfigContextBulkDeleteView(generic.BulkDeleteView):
 class ConfigContextBulkDeleteView(generic.BulkDeleteView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
+    filterset = filtersets.ConfigContextFilterSet
     table = tables.ConfigContextTable
     table = tables.ConfigContextTable
 
 
 
 

+ 2 - 1
netbox/ipam/forms/bulk_import.py

@@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = L2VPN
         model = L2VPN
-        fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
+        fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
+                  'comments', 'tags')
 
 
 
 
 class L2VPNTerminationImportForm(NetBoxModelImportForm):
 class L2VPNTerminationImportForm(NetBoxModelImportForm):

+ 0 - 3
netbox/ipam/models/ip.py

@@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
 
 
         if self.prefix:
         if self.prefix:
 
 
-            # Clear host bits from prefix
-            self.prefix = self.prefix.cidr
-
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.prefix.prefixlen == 0:
             if self.prefix.prefixlen == 0:
                 raise ValidationError({
                 raise ValidationError({

+ 1 - 1
netbox/ipam/tests/test_api.py

@@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
 
 
         self.add_permissions('ipam.delete_vlan')
         self.add_permissions('ipam.delete_vlan')
         url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
         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)
             response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
         self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

+ 1 - 0
netbox/ipam/views.py

@@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
 
 
 class RoleBulkDeleteView(generic.BulkDeleteView):
 class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
+    filterset = filtersets.RoleFilterSet
     table = tables.RoleTable
     table = tables.RoleTable
 
 
 
 

+ 28 - 0
netbox/netbox/models/__init__.py

@@ -1,4 +1,5 @@
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
     class Meta:
     class Meta:
         abstract = True
         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):
 class PrimaryModel(NetBoxModel):
     """
     """

+ 1 - 1
netbox/netbox/settings.py

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.4.7'
+VERSION = '3.4.8'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 24 - 18
netbox/netbox/views/generic/bulk_views.py

@@ -16,7 +16,6 @@ from django_tables2.export import TableExport
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
-from utilities.choices import ImportFormatChoices
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
 from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
@@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
         ]
         ]
         nullified_fields = request.POST.getlist('_nullify')
         nullified_fields = request.POST.getlist('_nullify')
         updated_objects = []
         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']):
         for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
 
 
@@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                 obj.snapshot()
                 obj.snapshot()
 
 
             # Update standard fields. If a field is listed in _nullify, delete its value.
             # 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
                 # Handle nullification
                 if name in form.nullable_fields and name in nullified_fields:
                 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
                 # Normal fields
                 elif name in form.changed_data:
                 elif name in form.changed_data:
                     setattr(obj, name, form.cleaned_data[name])
                     setattr(obj, name, form.cleaned_data[name])
@@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             obj.save()
             obj.save()
             updated_objects.append(obj)
             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
             # Add/remove tags
             if form.cleaned_data.get('add_tags', None):
             if form.cleaned_data.get('add_tags', None):
                 obj.tags.add(*form.cleaned_data['add_tags'])
                 obj.tags.add(*form.cleaned_data['add_tags'])

+ 9 - 2
netbox/templates/base/layout.html

@@ -70,10 +70,17 @@ Blocks:
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 
+        {% if settings.DEBUG and not settings.DEVELOPER %}
+          <div class="alert alert-warning text-center mx-3" role="alert">
+            <strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
+            Performance may be limited. Debugging should never be enabled on a production system.
+          </div>
+        {% endif %}
+
         {% if config.MAINTENANCE_MODE %}
         {% if config.MAINTENANCE_MODE %}
           <div class="alert alert-warning text-center mx-3" role="alert">
           <div class="alert alert-warning text-center mx-3" role="alert">
-            <h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
-            <span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
+            <h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
+            NetBox is currently in maintenance mode. Functionality may be limited.
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 

+ 1 - 1
netbox/templates/dcim/cable_edit.html

@@ -98,7 +98,7 @@
             <div class="card">
             <div class="card">
               <h5 class="card-header text-center">Comments</h5>
               <h5 class="card-header text-center">Comments</h5>
               <div class="card-body">
               <div class="card-body">
-              {% render_field form.comments %}
+                {% render_field form.comments %}
               </div>
               </div>
             </div>
             </div>
           {% if form.custom_fields %}
           {% if form.custom_fields %}

+ 0 - 1
netbox/templates/dcim/device_edit.html

@@ -111,7 +111,6 @@
     </div>
     </div>
 
 
     <div class="field-group mb-5">
     <div class="field-group mb-5">
-      <h5 class="text-center">Comments</h5>
       {% render_field form.comments %}
       {% render_field form.comments %}
     </div>
     </div>
 
 

+ 1 - 0
netbox/templates/dcim/devicetype.html

@@ -98,6 +98,7 @@
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/comments.html' %}
             {% include 'inc/panels/comments.html' %}
+            {% include 'inc/panels/image_attachments.html' %}
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 0 - 1
netbox/templates/dcim/rack_edit.html

@@ -85,7 +85,6 @@
     {% endif %}
     {% endif %}
 
 
     <div class="field-group my-5">
     <div class="field-group my-5">
-      <h5 class="text-center">Comments</h5>
       {% render_field form.comments %}
       {% render_field form.comments %}
     </div>
     </div>
 {% endblock %}
 {% endblock %}

+ 0 - 1
netbox/templates/dcim/virtualchassis_edit.html

@@ -27,7 +27,6 @@
         </div>
         </div>
 
 
         <div class="field-group my-5">
         <div class="field-group my-5">
-          <h5 class="text-center">Comments</h5>
           {% render_field vc_form.comments %}
           {% render_field vc_form.comments %}
         </div>
         </div>
 
 

+ 2 - 2
netbox/templates/generic/bulk_import.html

@@ -15,12 +15,12 @@ Context:
 {% block tabs %}
 {% block tabs %}
   <ul class="nav nav-tabs px-3">
   <ul class="nav nav-tabs px-3">
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
+      <button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" data-href="#tab_data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
         Data Import
         Data Import
       </button>
       </button>
     </li>
     </li>
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
+      <button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" data-href="#tab_file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
         Upload File
         Upload File
       </button>
       </button>
     </li>
     </li>

+ 0 - 1
netbox/templates/generic/object_edit.html

@@ -85,7 +85,6 @@ Context:
 
 
             {% if form.comments %}
             {% if form.comments %}
               <div class="field-group mb-5">
               <div class="field-group mb-5">
-                <h5 class="text-center">Comments</h5>
                 {% render_field form.comments %}
                 {% render_field form.comments %}
               </div>
               </div>
             {% endif %}
             {% endif %}

+ 0 - 3
netbox/templates/ipam/fhrpgroup_edit.html

@@ -33,9 +33,6 @@
   {% endif %}
   {% endif %}
 
 
   <div class="field-group mb-5">
   <div class="field-group mb-5">
-    <div class="row mb-2">
-      <h5 class="offset-sm-3">Comments</h5>
-    </div>
     {% render_field form.comments %}
     {% render_field form.comments %}
   </div>
   </div>
 
 

+ 0 - 3
netbox/templates/ipam/ipaddress_edit.html

@@ -139,9 +139,6 @@
     </div>
     </div>
 
 
     <div class="field-group my-5">
     <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="text-center">Comments</h5>
-      </div>
       {% render_field form.comments %}
       {% render_field form.comments %}
     </div>
     </div>
 
 

+ 0 - 3
netbox/templates/ipam/service_create.html

@@ -66,9 +66,6 @@
   </div>
   </div>
 
 
   <div class="field-group my-5">
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
     {% render_field form.comments %}
   </div>
   </div>
 
 

+ 0 - 3
netbox/templates/ipam/service_edit.html

@@ -53,9 +53,6 @@
   </div>
   </div>
 
 
   <div class="field-group my-5">
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
     {% render_field form.comments %}
   </div>
   </div>
 
 

+ 0 - 3
netbox/templates/ipam/vlan_edit.html

@@ -56,9 +56,6 @@
   </div>
   </div>
 
 
   <div class="field-group my-5">
   <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="text-center">Comments</h5>
-    </div>
     {% render_field form.comments %}
     {% render_field form.comments %}
   </div>
   </div>
 
 

+ 0 - 39
netbox/templates/wireless/wirelesslink_edit.html

@@ -1,39 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-  <div class="row">
-    <div class="col">
-      <div class="field-group">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Side A</h5>
-        </div>
-        {% render_field form.device_a %}
-        {% render_field form.interface_a %}
-      </div>
-    </div>
-    <div class="col">
-      <div class="field-group">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Side B</h5>
-        </div>
-        {% render_field form.device_b %}
-        {% render_field form.interface_b %}
-      </div>
-    </div>
-  </div>
-  <div class="field-group my-5">
-    <div class="row mb-2">
-      <h5 class="offset-sm-3">Comments</h5>
-    </div>
-    {% render_field form.comments %}
-  </div>
-  {% if form.custom_fields %}
-    <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="offset-sm-3">Custom Fields</h5>
-      </div>
-      {% render_custom_fields form %}
-    </div>
-  {% endif %}
-{% endblock %}

+ 3 - 0
netbox/tenancy/views.py

@@ -84,6 +84,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
         'tenant_count',
         'tenant_count',
         cumulative=True
         cumulative=True
     )
     )
+    filterset = filtersets.TenantGroupFilterSet
     table = tables.TenantGroupTable
     table = tables.TenantGroupTable
 
 
 
 
@@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )
+    filterset = filtersets.ContactGroupFilterSet
     table = tables.ContactGroupTable
     table = tables.ContactGroupTable
 
 
 
 
@@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
 
 
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
 class ContactRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactRole.objects.all()
     queryset = ContactRole.objects.all()
+    filterset = filtersets.ContactRoleFilterSet
     table = tables.ContactRoleTable
     table = tables.ContactRoleTable
 
 
 
 

+ 11 - 0
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.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
+from rest_framework.exceptions import PermissionDenied
 
 
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -91,6 +92,16 @@ class TokenSerializer(ValidatedModelSerializer):
             data['key'] = Token.generate_key()
             data['key'] = Token.generate_key()
         return super().to_internal_value(data)
         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):
 class TokenProvisionSerializer(serializers.Serializer):
     username = serializers.CharField()
     username = serializers.CharField()

+ 33 - 4
netbox/users/tests/test_api.py

@@ -12,7 +12,7 @@ class AppTest(APITestCase):
     def test_root(self):
     def test_root(self):
 
 
         url = reverse('users-api:api-root')
         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)
         self.assertEqual(response.status_code, 200)
 
 
@@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
             'password': 'password6',
             'password': 'password6',
         },
         },
     ]
     ]
+    bulk_update_data = {
+        'email': 'test@example.com',
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         users = (
         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)
         User.objects.bulk_create(users)
 
 
@@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Group.objects.bulk_create(users)
         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(
 class TokenTest(
     # No GraphQL support for Token
     # No GraphQL support for Token
@@ -144,6 +153,26 @@ class TokenTest(
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         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(
 class ObjectPermissionTest(
     # No GraphQL support for ObjectPermission
     # No GraphQL support for ObjectPermission

+ 2 - 2
netbox/utilities/forms/fields/fields.py

@@ -34,8 +34,8 @@ class CommentField(forms.CharField):
         Markdown</a> syntax is supported
         Markdown</a> 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):
 class SlugField(forms.SlugField):

+ 5 - 3
netbox/utilities/templates/buttons/clone.html

@@ -1,3 +1,5 @@
-<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
-    <i class="mdi mdi-content-copy" aria-hidden="true"></i>&nbsp;Clone
-</a>
+{% if url %}
+  <a href="{{ url }}" class="btn btn-sm btn-success" role="button">
+    <i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
+  </a>
+{% endif %}

+ 2 - 5
netbox/utilities/templates/form_helpers/render_field.html

@@ -3,11 +3,8 @@
 
 
 <div class="row mb-3{% if field.errors %} has-errors{% endif %}">
 <div class="row mb-3{% if field.errors %} has-errors{% endif %}">
 
 
-  {# 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' %}
     <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
     <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
       {{ label }}
       {{ label }}
     </label>
     </label>

+ 2 - 0
netbox/utilities/templatetags/buttons.py

@@ -20,6 +20,8 @@ def clone_button(instance):
     param_string = prepare_cloned_fields(instance).urlencode()
     param_string = prepare_cloned_fields(instance).urlencode()
     if param_string:
     if param_string:
         url = f'{url}?{param_string}'
         url = f'{url}?{param_string}'
+    else:
+        url = None
 
 
     return {
     return {
         'url': url,
         'url': url,

+ 2 - 1
netbox/utilities/templatetags/helpers.py

@@ -1,5 +1,6 @@
 import datetime
 import datetime
 import decimal
 import decimal
+import json
 from urllib.parse import quote
 from urllib.parse import quote
 from typing import Dict, Any
 from typing import Dict, Any
 
 
@@ -321,7 +322,7 @@ def applied_filters(context, model, form, query_params):
     save_link = None
     save_link = None
     if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
     if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
         content_type = ContentType.objects.get_for_model(model).pk
         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')
         url = reverse('extras:savedfilter_add')
         save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"
         save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"
 
 

+ 4 - 0
netbox/utilities/utils.py

@@ -48,6 +48,10 @@ def get_viewname(model, action=None, rest_api=False):
         if is_plugin:
         if is_plugin:
             viewname = f'plugins-api:{app_label}-api:{model_name}'
             viewname = f'plugins-api:{app_label}-api:{model_name}'
         else:
         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}'
             viewname = f'{app_label}-api:{model_name}'
         # Append the action, if any
         # Append the action, if any
         if action:
         if action:

+ 2 - 24
netbox/virtualization/filtersets.py

@@ -2,9 +2,9 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from dcim.filtersets import CommonInterfaceFilterSet
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
-from ipam.models import L2VPN, VRF
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -250,7 +250,7 @@ class VirtualMachineFilterSet(
         return queryset.exclude(params)
         return queryset.exclude(params)
 
 
 
 
-class VMInterfaceFilterSet(NetBoxModelFilterSet):
+class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
     cluster_id = django_filters.ModelMultipleChoiceFilter(
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_machine__cluster',
         field_name='virtual_machine__cluster',
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
@@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
     mac_address = MultiValueMACAddressFilter(
     mac_address = MultiValueMACAddressFilter(
         label=_('MAC address'),
         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:
     class Meta:
         model = VMInterface
         model = VMInterface

+ 8 - 2
netbox/virtualization/models/virtualmachines.py

@@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
             raise ValidationError({
             raise ValidationError({
                 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
                 '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
         # Validate assigned cluster device
         if self.device and not self.cluster:
         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.",
                         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):
     def get_status_color(self):
         return VirtualMachineStatusChoices.colors.get(self.status)
         return VirtualMachineStatusChoices.colors.get(self.status)
 
 

+ 1 - 1
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 with cluster site but no direct site should have its site set automatically
         vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
         vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
-        vm.full_clean()
+        vm.save()
         self.assertEqual(vm.site, sites[0])
         self.assertEqual(vm.site, sites[0])
 
 
     def test_vm_name_case_sensitivity(self):
     def test_vm_name_case_sensitivity(self):

+ 2 - 0
netbox/virtualization/views.py

@@ -80,6 +80,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
         cluster_count=count_related(Cluster, 'type')
     )
     )
+    filterset = filtersets.ClusterTypeFilterSet
     table = tables.ClusterTypeTable
     table = tables.ClusterTypeTable
 
 
 
 
@@ -147,6 +148,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
         cluster_count=count_related(Cluster, 'group')
     )
     )
+    filterset = filtersets.ClusterGroupFilterSet
     table = tables.ClusterGroupTable
     table = tables.ClusterGroupTable
 
 
 
 

+ 9 - 9
requirements.txt

@@ -1,7 +1,7 @@
 bleach==5.0.1
 bleach==5.0.1
-Django==4.1.7
+Django==4.1.8
 django-cors-headers==3.14.0
 django-cors-headers==3.14.0
-django-debug-toolbar==3.8.1
+django-debug-toolbar==4.0.0
 django-filter==23.1
 django-filter==23.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-mptt==0.14
@@ -19,18 +19,18 @@ graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.4
-mkdocstrings[python-legacy]==0.20.0
+mkdocs-material==9.1.6
+mkdocstrings[python-legacy]==0.21.2
 netaddr==0.8.0
 netaddr==0.8.0
-Pillow==9.4.0
-psycopg2-binary==2.9.5
+Pillow==9.5.0
+psycopg2-binary==2.9.6
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.18.0
+sentry-sdk==1.19.1
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
-social-auth-core[openidconnect]==4.4.0
+social-auth-core[openidconnect]==4.4.1
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.4.0
 tablib==3.4.0
-tzdata==2023.2
+tzdata==2023.3
 
 
 # Workaround for #7401
 # Workaround for #7401
 jsonschema==3.2.0
 jsonschema==3.2.0