瀏覽代碼

Merge pull request #13705 from netbox-community/develop

Release v3.6.1
Jeremy Stretch 2 年之前
父節點
當前提交
99ab054ea0
共有 35 個文件被更改,包括 429 次插入180 次删除
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 0 14
      docs/models/dcim/platform.md
  4. 9 6
      docs/plugins/development/navigation.md
  5. 30 1
      docs/release-notes/version-3.6.md
  6. 11 0
      netbox/core/apps.py
  7. 0 12
      netbox/core/management/commands/makemigrations.py
  8. 0 7
      netbox/core/management/commands/migrate.py
  9. 1 1
      netbox/core/models/data.py
  10. 7 1
      netbox/core/views.py
  11. 0 4
      netbox/dcim/api/serializers.py
  12. 90 1
      netbox/dcim/views.py
  13. 1 1
      netbox/extras/api/serializers.py
  14. 17 15
      netbox/extras/models/customfields.py
  15. 4 0
      netbox/extras/models/models.py
  16. 2 1
      netbox/extras/plugins/navigation.py
  17. 91 0
      netbox/extras/tests/test_customfields.py
  18. 27 10
      netbox/ipam/api/field_serializers.py
  19. 3 6
      netbox/ipam/api/serializers.py
  20. 3 3
      netbox/ipam/models/ip.py
  21. 0 33
      netbox/netbox/config/parameters.py
  22. 1 0
      netbox/netbox/navigation/__init__.py
  23. 20 4
      netbox/netbox/navigation/menu.py
  24. 2 1
      netbox/netbox/settings.py
  25. 22 0
      netbox/templates/dcim/component_list.html
  26. 0 11
      netbox/templates/dcim/platform.html
  27. 10 2
      netbox/templates/extras/configrevision.html
  28. 1 1
      netbox/templates/users/user.html
  29. 38 4
      netbox/users/api/serializers.py
  30. 12 24
      netbox/users/api/views.py
  31. 10 2
      netbox/users/tests/test_api.py
  32. 1 1
      netbox/users/views.py
  33. 8 5
      netbox/utilities/templatetags/navigation.py
  34. 0 1
      netbox/virtualization/views.py
  35. 6 6
      requirements.txt

+ 1 - 1
.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.6.0
+      placeholder: v3.6.1
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.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.6.0
+      placeholder: v3.6.1
     validations:
       required: true
   - type: dropdown

+ 0 - 14
docs/models/dcim/platform.md

@@ -23,17 +23,3 @@ If designated, this platform will be available for use only to devices assigned
 ### Configuration Template
 
 The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
-
-### NAPALM Driver
-
-!!! warning "Deprecated Field"
-    NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
-
-The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
-
-### NAPALM Arguments
-
-!!! warning "Deprecated Field"
-    NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
-
-Any additional arguments to send when invoking the NAPALM driver assigned to this platform.

+ 9 - 6
docs/plugins/development/navigation.md

@@ -64,12 +64,15 @@ item1 = PluginMenuItem(
 
 A `PluginMenuItem` has the following attributes:
 
-| Attribute     | Required | Description                                          |
-|---------------|----------|------------------------------------------------------|
-| `link`        | Yes      | Name of the URL path to which this menu item links   |
-| `link_text`   | Yes      | The text presented to the user                       |
-| `permissions` | -        | A list of permissions required to display this link  |
-| `buttons`     | -        | An iterable of PluginMenuButton instances to include |
+| Attribute     | Required | Description                                                                                              |
+|---------------|----------|----------------------------------------------------------------------------------------------------------|
+| `link`        | Yes      | Name of the URL path to which this menu item links                                                       |
+| `link_text`   | Yes      | The text presented to the user                                                                           |
+| `permissions` | -        | A list of permissions required to display this link                                                      |
+| `staff_only`  | -        | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
+| `buttons`     | -        | An iterable of PluginMenuButton instances to include                                                     |
+
+!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1."
 
 ## Menu Buttons
 

+ 30 - 1
docs/release-notes/version-3.6.md

@@ -1,10 +1,38 @@
 # NetBox v3.6
 
+## v3.6.1 (2023-09-06)
+
+### Enhancements
+
+* [#12870](https://github.com/netbox-community/netbox/issues/12870) - Support setting token expiration time using the provisioning API endpoint
+* [#13444](https://github.com/netbox-community/netbox/issues/13444) - Add bulk rename functionality to the global device component lists
+* [#13638](https://github.com/netbox-community/netbox/issues/13638) - Add optional `staff_only` attribute to MenuItem
+
+### Bug Fixes
+
+* [#12553](https://github.com/netbox-community/netbox/issues/12552) - Ensure `family` attribute is always returned when creating aggregates and prefixes via REST API
+* [#13619](https://github.com/netbox-community/netbox/issues/13619) - Fix exception when viewing IP address assigned to a virtual machine
+* [#13596](https://github.com/netbox-community/netbox/issues/13596) - Always display "render config" tab for devices and virtual machines
+* [#13620](https://github.com/netbox-community/netbox/issues/13620) - Show admin menu items only for staff users
+* [#13622](https://github.com/netbox-community/netbox/issues/13622) - Fix exception when viewing current config and no revisions have been created
+* [#13626](https://github.com/netbox-community/netbox/issues/13626) - Correct filtering of recent activity list under user view
+* [#13628](https://github.com/netbox-community/netbox/issues/13628) - Remove stale references to obsolete NAPALM integration
+* [#13630](https://github.com/netbox-community/netbox/issues/13630) - Fix display of active status under user view
+* [#13632](https://github.com/netbox-community/netbox/issues/13632) - Avoid raising exception when checking if FHRP group IP address is primary
+* [#13642](https://github.com/netbox-community/netbox/issues/13642) - Suppress warning about unreflected model changes when applying migrations
+* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
+* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
+* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
+* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modying the configuration when maintenance mode is enabled
+
+---
+
 ## v3.6.0 (2023-08-30)
 
 ### Breaking Changes
 
 * PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
+* The `boto3` and `dulwich` packages are no longer installed automatically. If needed for S3/git remote data backend support, add them to `local_requirements.txt` to ensure their installation.
 * The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
 * The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
 * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
@@ -85,8 +113,9 @@ Tags may now be restricted to use with designated object types. Tags that have n
 * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
 * [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
 * [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
-* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
 * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
+* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
+* [#12906](https://github.com/netbox-community/netbox/issues/12906) - The `boto3` (AWS) and `dulwich` (git) packages for remote data sources are now optional requirements
 * [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11
 * [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
 

+ 11 - 0
netbox/core/apps.py

@@ -1,4 +1,15 @@
 from django.apps import AppConfig
+from django.db import models
+from django.db.migrations.operations import AlterModelOptions
+
+from utilities.migration import custom_deconstruct
+
+# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
+AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
+AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
+
+# Use our custom destructor to ignore certain attributes when calculating field migrations
+models.Field.deconstruct = custom_deconstruct
 
 
 class CoreConfig(AppConfig):

+ 0 - 12
netbox/core/management/commands/makemigrations.py

@@ -1,18 +1,6 @@
-# noinspection PyUnresolvedReferences
 from django.conf import settings
 from django.core.management.base import CommandError
 from django.core.management.commands.makemigrations import Command as _Command
-from django.db import models
-from django.db.migrations.operations import AlterModelOptions
-
-from utilities.migration import custom_deconstruct
-
-# Monkey patch AlterModelOptions to ignore verbose name attributes
-AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
-AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
-
-# Set our custom deconstructor for fields
-models.Field.deconstruct = custom_deconstruct
 
 
 class Command(_Command):

+ 0 - 7
netbox/core/management/commands/migrate.py

@@ -1,7 +0,0 @@
-# noinspection PyUnresolvedReferences
-from django.core.management.commands.migrate import Command
-from django.db import models
-
-from utilities.migration import custom_deconstruct
-
-models.Field.deconstruct = custom_deconstruct

+ 1 - 1
netbox/core/models/data.py

@@ -316,7 +316,7 @@ class DataFile(models.Model):
         if not self.data:
             return None
         try:
-            return bytes(self.data, 'utf-8')
+            return self.data.decode('utf-8')
         except UnicodeDecodeError:
             return None
 

+ 7 - 1
netbox/core/views.py

@@ -2,6 +2,7 @@ from django.contrib import messages
 from django.shortcuts import get_object_or_404, redirect
 
 from extras.models import ConfigRevision
+from netbox.config import get_config
 from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from utilities.utils import count_related
@@ -152,4 +153,9 @@ class ConfigView(generic.ObjectView):
     queryset = ConfigRevision.objects.all()
 
     def get_object(self, **kwargs):
-        return self.queryset.first()
+        if config := self.queryset.first():
+            return config
+        # Instantiate a dummy default config if none has been created yet
+        return ConfigRevision(
+            data=get_config().defaults
+        )

+ 0 - 4
netbox/dcim/api/serializers.py

@@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
         ]
 
 
-class DeviceNAPALMSerializer(serializers.Serializer):
-    method = serializers.JSONField()
-
-
 #
 # Device components
 #

+ 90 - 1
netbox/dcim/views.py

@@ -2033,7 +2033,6 @@ class DeviceRenderConfigView(generic.ObjectView):
     template_name = 'dcim/device/render_config.html'
     tab = ViewTab(
         label=_('Render Config'),
-        permission='extras.view_configtemplate',
         weight=2100
     )
 
@@ -2185,6 +2184,15 @@ class ConsolePortListView(generic.ObjectListView):
     filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(ConsolePort)
@@ -2248,6 +2256,15 @@ class ConsoleServerPortListView(generic.ObjectListView):
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(ConsoleServerPort)
@@ -2311,6 +2328,15 @@ class PowerPortListView(generic.ObjectListView):
     filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(PowerPort)
@@ -2374,6 +2400,15 @@ class PowerOutletListView(generic.ObjectListView):
     filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(PowerOutlet)
@@ -2437,6 +2472,15 @@ class InterfaceListView(generic.ObjectListView):
     filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(Interface)
@@ -2548,6 +2592,15 @@ class FrontPortListView(generic.ObjectListView):
     filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(FrontPort)
@@ -2611,6 +2664,15 @@ class RearPortListView(generic.ObjectListView):
     filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(RearPort)
@@ -2674,6 +2736,15 @@ class ModuleBayListView(generic.ObjectListView):
     filterset = filtersets.ModuleBayFilterSet
     filterset_form = forms.ModuleBayFilterForm
     table = tables.ModuleBayTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(ModuleBay)
@@ -2729,6 +2800,15 @@ class DeviceBayListView(generic.ObjectListView):
     filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(DeviceBay)
@@ -2853,6 +2933,15 @@ class InventoryItemListView(generic.ObjectListView):
     filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
+    template_name = 'dcim/component_list.html'
+    actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
+    action_perms = defaultdict(set, **{
+        'add': {'add'},
+        'import': {'add'},
+        'bulk_edit': {'change'},
+        'bulk_delete': {'delete'},
+        'bulk_rename': {'change'},
+    })
 
 
 @register_model_view(InventoryItem)

+ 1 - 1
netbox/extras/api/serializers.py

@@ -479,7 +479,7 @@ class ReportSerializer(serializers.Serializer):
     module = serializers.CharField(max_length=255)
     name = serializers.CharField(max_length=255)
     description = serializers.CharField(max_length=255, required=False)
-    test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
+    test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
     result = NestedJobSerializer()
     display = serializers.SerializerMethodField(read_only=True)
 

+ 17 - 15
netbox/extras/models/customfields.py

@@ -282,7 +282,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                 raise ValidationError({
                     'default': _(
                         'Invalid default value "{default}": {message}'
-                    ).format(default=self.default, message=self.message)
+                    ).format(default=self.default, message=err.message)
                 })
 
         # Minimum/maximum values can be set only for numeric fields
@@ -317,14 +317,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
                 'choice_set': _("Choices may be set only on selection fields.")
             })
 
-        # A selection field's default (if any) must be present in its available choices
-        if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
-            raise ValidationError({
-                'default': _(
-                    "The specified default value ({default}) is not listed as an available choice."
-                ).format(default=self.default)
-            })
-
         # Object fields must define an object_type; other fields must not
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
             if not self.object_type:
@@ -650,19 +642,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
             # Validate selected choice
             elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
-                if value not in [c[0] for c in self.choices]:
+                if value not in self.choice_set.values:
                     raise ValidationError(
-                        _("Invalid choice ({value}). Available choices are: {choices}").format(
-                            value=value, choices=', '.join(self.choices)
+                        _("Invalid choice ({value}) for choice set {choiceset}.").format(
+                            value=value,
+                            choiceset=self.choice_set
                         )
                     )
 
             # Validate all selected choices
             elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-                if not set(value).issubset([c[0] for c in self.choices]):
+                if not set(value).issubset(self.choice_set.values):
                     raise ValidationError(
-                        _("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
-                            invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
+                        _("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
+                            value=value,
+                            choiceset=self.choice_set
+                        )
                     )
 
             # Validate selected object
@@ -747,6 +742,13 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
     def choices_count(self):
         return len(self.choices)
 
+    @property
+    def values(self):
+        """
+        Returns an iterator of the valid choice values.
+        """
+        return (x[0] for x in self.choices)
+
     def clean(self):
         if not self.base_choices and not self.extra_choices:
             raise ValidationError(_("Must define base or extra choices."))

+ 4 - 0
netbox/extras/models/models.py

@@ -723,6 +723,8 @@ class ConfigRevision(models.Model):
         verbose_name_plural = _('config revisions')
 
     def __str__(self):
+        if not self.pk:
+            return gettext('Default configuration')
         if self.is_active:
             return gettext('Current configuration')
         return gettext('Config revision #{id}').format(id=self.pk)
@@ -733,6 +735,8 @@ class ConfigRevision(models.Model):
         return super().__getattribute__(item)
 
     def get_absolute_url(self):
+        if not self.pk:
+            return reverse('core:config')  # Default config view
         return reverse('extras:configrevision', args=[self.pk])
 
     def activate(self):

+ 2 - 1
netbox/extras/plugins/navigation.py

@@ -36,9 +36,10 @@ class PluginMenuItem:
     permissions = []
     buttons = []
 
-    def __init__(self, link, link_text, permissions=None, buttons=None):
+    def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
         self.link = link
         self.link_text = link_text
+        self.staff_only = staff_only
         if permissions is not None:
             if type(permissions) not in (list, tuple):
                 raise TypeError("Permissions must be passed as a tuple or list.")

+ 91 - 0
netbox/extras/tests/test_customfields.py

@@ -427,6 +427,97 @@ class CustomFieldTest(TestCase):
         self.assertNotIn('field1', site.custom_field_data)
         self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
 
+    def test_default_value_validation(self):
+        choiceset = CustomFieldChoiceSet.objects.create(
+            name="Test Choice Set",
+            extra_choices=(
+                ('choice1', 'Choice 1'),
+                ('choice2', 'Choice 2'),
+            )
+        )
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        object_type = ContentType.objects.get_for_model(Site)
+
+        # Text
+        CustomField(name='test', type='text', required=True, default="Default text").full_clean()
+
+        # Integer
+        CustomField(name='test', type='integer', required=True, default=1).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='integer', required=True, default='xxx').full_clean()
+
+        # Boolean
+        CustomField(name='test', type='boolean', required=True, default=True).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='boolean', required=True, default='xxx').full_clean()
+
+        # Date
+        CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='date', required=True, default='xxx').full_clean()
+
+        # Datetime
+        CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='datetime', required=True, default='xxx').full_clean()
+
+        # URL
+        CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean()
+
+        # JSON
+        CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean()
+
+        # Selection
+        CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean()
+
+        # Multi-select
+        CustomField(
+            name='test',
+            type='multiselect',
+            required=True,
+            choice_set=choiceset,
+            default=['choice1']  # Single default choice
+        ).full_clean()
+        CustomField(
+            name='test',
+            type='multiselect',
+            required=True,
+            choice_set=choiceset,
+            default=['choice1', 'choice2']  # Multiple default choices
+        ).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(
+                name='test',
+                type='multiselect',
+                required=True,
+                choice_set=choiceset,
+                default=['xxx']
+            ).full_clean()
+
+        # Object
+        CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean()
+
+        # Multi-object
+        CustomField(
+            name='test',
+            type='multiobject',
+            required=True,
+            object_type=object_type,
+            default=[site.pk]
+        ).full_clean()
+        with self.assertRaises(ValidationError):
+            CustomField(
+                name='test',
+                type='multiobject',
+                required=True,
+                object_type=object_type,
+                default=["xxx"]
+            ).full_clean()
+
 
 class CustomFieldManagerTest(TestCase):
 

+ 27 - 10
netbox/ipam/api/field_serializers.py

@@ -1,21 +1,18 @@
 from django.utils.translation import gettext_lazy as _
 from rest_framework import serializers
 
-from ipam import models
 from netaddr import AddrFormatError, IPNetwork
 
-__all__ = [
+__all__ = (
     'IPAddressField',
-]
+    'IPNetworkField',
+)
 
 
-#
-# IP address field
-#
-
 class IPAddressField(serializers.CharField):
-    """IPAddressField with mask"""
-
+    """
+    An IPv4 or IPv6 address with optional mask
+    """
     default_error_messages = {
         'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'),
     }
@@ -24,7 +21,27 @@ class IPAddressField(serializers.CharField):
         try:
             return IPNetwork(data)
         except AddrFormatError:
-            raise serializers.ValidationError("Invalid IP address format: {}".format(data))
+            raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data))
+        except (TypeError, ValueError) as e:
+            raise serializers.ValidationError(e)
+
+    def to_representation(self, value):
+        return str(value)
+
+
+class IPNetworkField(serializers.CharField):
+    """
+    An IPv4 or IPv6 prefix
+    """
+    default_error_messages = {
+        'invalid': _('Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation.'),
+    }
+
+    def to_internal_value(self, data):
+        try:
+            return IPNetwork(data)
+        except AddrFormatError:
+            raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data))
         except (TypeError, ValueError) as e:
             raise serializers.ValidationError(e)
 

+ 3 - 6
netbox/ipam/api/serializers.py

@@ -13,7 +13,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from .nested_serializers import *
-from .field_serializers import IPAddressField
+from .field_serializers import IPAddressField, IPNetworkField
 
 
 #
@@ -138,7 +138,7 @@ class AggregateSerializer(NetBoxModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    prefix = serializers.CharField()
+    prefix = IPNetworkField()
 
     class Meta:
         model = Aggregate
@@ -146,7 +146,6 @@ class AggregateSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
-        read_only_fields = ['family']
 
 
 #
@@ -306,7 +305,7 @@ class PrefixSerializer(NetBoxModelSerializer):
     role = NestedRoleSerializer(required=False, allow_null=True)
     children = serializers.IntegerField(read_only=True)
     _depth = serializers.IntegerField(read_only=True)
-    prefix = serializers.CharField()
+    prefix = IPNetworkField()
 
     class Meta:
         model = Prefix
@@ -315,7 +314,6 @@ class PrefixSerializer(NetBoxModelSerializer):
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
             '_depth',
         ]
-        read_only_fields = ['family']
 
 
 class PrefixLengthSerializer(serializers.Serializer):
@@ -386,7 +384,6 @@ class IPRangeSerializer(NetBoxModelSerializer):
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
-        read_only_fields = ['family']
 
 
 #

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

@@ -892,7 +892,7 @@ class IPAddress(PrimaryModel):
     def is_oob_ip(self):
         if self.assigned_object:
             parent = getattr(self.assigned_object, 'parent_object', None)
-            if parent.oob_ip_id == self.pk:
+            if hasattr(parent, 'oob_ip') and parent.oob_ip_id == self.pk:
                 return True
         return False
 
@@ -900,9 +900,9 @@ class IPAddress(PrimaryModel):
     def is_primary_ip(self):
         if self.assigned_object:
             parent = getattr(self.assigned_object, 'parent_object', None)
-            if self.family == 4 and parent.primary_ip4_id == self.pk:
+            if self.family == 4 and hasattr(parent, 'primary_ip4') and parent.primary_ip4_id == self.pk:
                 return True
-            if self.family == 6 and parent.primary_ip6_id == self.pk:
+            if self.family == 6 and hasattr(parent, 'primary_ip6') and parent.primary_ip6_id == self.pk:
                 return True
         return False
 

+ 0 - 33
netbox/netbox/config/parameters.py

@@ -158,39 +158,6 @@ PARAMS = (
         },
     ),
 
-    # NAPALM
-    ConfigParam(
-        name='NAPALM_USERNAME',
-        label=_('NAPALM username'),
-        default='',
-        description=_("Username to use when connecting to devices via NAPALM")
-    ),
-    ConfigParam(
-        name='NAPALM_PASSWORD',
-        label=_('NAPALM password'),
-        default='',
-        description=_("Password to use when connecting to devices via NAPALM")
-    ),
-    ConfigParam(
-        name='NAPALM_TIMEOUT',
-        label=_('NAPALM timeout'),
-        default=30,
-        description=_("NAPALM connection timeout (in seconds)"),
-        field=forms.IntegerField
-    ),
-    ConfigParam(
-        name='NAPALM_ARGS',
-        label=_('NAPALM arguments'),
-        default={},
-        description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"),
-        field=forms.JSONField,
-        field_kwargs={
-            'widget': forms.Textarea(
-                attrs={'class': 'vLargeTextField'}
-            ),
-        },
-    ),
-
     # User preferences
     ConfigParam(
         name='DEFAULT_USER_PREFERENCES',

+ 1 - 0
netbox/netbox/navigation/__init__.py

@@ -34,6 +34,7 @@ class MenuItem:
     link: str
     link_text: str
     permissions: Optional[Sequence[str]] = ()
+    staff_only: Optional[bool] = False
     buttons: Optional[Sequence[MenuItemButton]] = ()
 
 

+ 20 - 4
netbox/netbox/navigation/menu.py

@@ -360,6 +360,7 @@ ADMIN_MENU = Menu(
                     link=f'users:netboxuser_list',
                     link_text=_('Users'),
                     permissions=[f'auth.view_user'],
+                    staff_only=True,
                     buttons=(
                         MenuItemButton(
                             link=f'users:netboxuser_add',
@@ -382,6 +383,7 @@ ADMIN_MENU = Menu(
                     link=f'users:netboxgroup_list',
                     link_text=_('Groups'),
                     permissions=[f'auth.view_group'],
+                    staff_only=True,
                     buttons=(
                         MenuItemButton(
                             link=f'users:netboxgroup_add',
@@ -399,8 +401,20 @@ ADMIN_MENU = Menu(
                         )
                     )
                 ),
-                get_model_item('users', 'token', _('API Tokens')),
-                get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
+                MenuItem(
+                    link=f'users:token_list',
+                    link_text=_('API Tokens'),
+                    permissions=[f'users.view_token'],
+                    staff_only=True,
+                    buttons=get_model_buttons('users', 'token')
+                ),
+                MenuItem(
+                    link=f'users:objectpermission_list',
+                    link_text=_('Permissions'),
+                    permissions=[f'users.view_objectpermission'],
+                    staff_only=True,
+                    buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
+                ),
             ),
         ),
         MenuGroup(
@@ -409,12 +423,14 @@ ADMIN_MENU = Menu(
                 MenuItem(
                     link='core:config',
                     link_text=_('Current Config'),
-                    permissions=['extras.view_configrevision']
+                    permissions=['extras.view_configrevision'],
+                    staff_only=True
                 ),
                 MenuItem(
                     link='extras:configrevision_list',
                     link_text=_('Config Revisions'),
-                    permissions=['extras.view_configrevision']
+                    permissions=['extras.view_configrevision'],
+                    staff_only=True
                 ),
             ),
         ),

+ 2 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 #
 
-VERSION = '3.6.0'
+VERSION = '3.6.1'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -496,6 +496,7 @@ AUTH_EXEMPT_PATHS = (
 # All URLs starting with a string listed here are exempt from maintenance mode enforcement
 MAINTENANCE_EXEMPT_PATHS = (
     f'/{BASE_PATH}admin/',
+    f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
 )
 
 SERIALIZATION_MODULES = {

+ 22 - 0
netbox/templates/dcim/component_list.html

@@ -0,0 +1,22 @@
+{% extends 'generic/object_list.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load i18n %}
+
+{% block bulk_buttons %}
+  <div class="btn-group" role="group">
+    {% if 'bulk_edit' in actions %}
+      {% bulk_edit_button model query_params=request.GET %}
+    {% endif %}
+    {% if 'bulk_rename' in actions %}
+      {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
+        <button type="submit" name="_rename" formaction="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-sm">
+          <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
+        </button>
+      {% endwith %}
+    {% endif %}
+  </div>
+  {% if 'bulk_delete' in actions %}
+    {% bulk_delete_button model query_params=request.GET %}
+  {% endif %}
+{% endblock %}

+ 0 - 11
netbox/templates/dcim/platform.html

@@ -44,17 +44,6 @@
             <th scope="row">{% trans "Config Template" %}</th>
             <td>{{ object.config_template|linkify|placeholder }}</td>
           </tr>
-          <tr>
-            <th scope="row">
-              {% trans "NAPALM Driver" %}
-              <i
-                class="mdi mdi-alert-box text-warning"
-                data-bs-toggle="tooltip"
-                data-bs-placement="right"
-                title="{% trans "This field has been deprecated, and will be removed in NetBox v3.6" %}."
-              ></i>
-            </th>
-          </tr>
         </table>
       </div>
     </div>

+ 10 - 2
netbox/templates/extras/configrevision.html

@@ -14,11 +14,11 @@
   <div class="controls">
     <div class="control-group">
       {% plugin_buttons object %}
-      {% if object.is_active and perms.extras.add_configrevision %}
+      {% if not object.pk or object.is_active and perms.extras.add_configrevision %}
         {% url 'extras:configrevision_add' as edit_url %}
         {% include "buttons/edit.html" with url=edit_url %}
       {% endif %}
-      {% if not object.is_active and perms.extras.delete_configrevision %}
+      {% if object.pk and not object.is_active and perms.extras.delete_configrevision %}
         {% delete_button object %}
       {% endif %}
     </div>
@@ -28,6 +28,14 @@
   </div>
 {% endblock controls %}
 
+{% block subtitle %}
+  {% if object.created %}
+    <div class="object-subtitle">
+      <span>{% trans "Created" %} {{ object.created|annotated_date }}</span>
+    </div>
+  {% endif %}
+{% endblock subtitle %}
+
 {% block content %}
   <div class="row">
     <div class="col col-md-12">

+ 1 - 1
netbox/templates/users/user.html

@@ -32,7 +32,7 @@
             </tr>
             <tr>
               <th scope="row">{% trans "Active" %}</th>
-              <td>{% checkmark object.active %}</td>
+              <td>{% checkmark object.is_active %}</td>
             </tr>
             <tr>
               <th scope="row">{% trans "Staff" %}</th>

+ 38 - 4
netbox/users/api/serializers.py

@@ -1,11 +1,12 @@
 from django.conf import settings
+from django.contrib.auth import authenticate
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
-from rest_framework.exceptions import PermissionDenied
+from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
 
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
@@ -107,9 +108,42 @@ class TokenSerializer(ValidatedModelSerializer):
         return super().validate(data)
 
 
-class TokenProvisionSerializer(serializers.Serializer):
-    username = serializers.CharField()
-    password = serializers.CharField()
+class TokenProvisionSerializer(TokenSerializer):
+    user = NestedUserSerializer(
+        read_only=True
+    )
+    username = serializers.CharField(
+        write_only=True
+    )
+    password = serializers.CharField(
+        write_only=True
+    )
+    last_used = serializers.DateTimeField(
+        read_only=True
+    )
+    key = serializers.CharField(
+        read_only=True
+    )
+
+    class Meta:
+        model = Token
+        fields = (
+            'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
+            'allowed_ips', 'username', 'password',
+        )
+
+    def validate(self, data):
+        # Validate the username and password
+        username = data.pop('username')
+        password = data.pop('password')
+        user = authenticate(request=self.context.get('request'), username=username, password=password)
+        if user is None:
+            raise AuthenticationFailed("Invalid username/password")
+
+        # Inject the user into the validated data
+        data['user'] = user
+
+        return data
 
 
 class ObjectPermissionSerializer(ValidatedModelSerializer):

+ 12 - 24
netbox/users/api/views.py

@@ -1,3 +1,4 @@
+import logging
 from django.contrib.auth import authenticate
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
@@ -63,34 +64,21 @@ class TokenProvisionView(APIView):
     @extend_schema(
         request=serializers.TokenProvisionSerializer,
         responses={
-            201: serializers.TokenSerializer,
+            201: serializers.TokenProvisionSerializer,
             401: OpenApiTypes.OBJECT,
         }
     )
     def post(self, request):
-        serializer = serializers.TokenProvisionSerializer(data=request.data)
-        serializer.is_valid()
-
-        # Authenticate the user account based on the provided credentials
-        username = serializer.data.get('username')
-        password = serializer.data.get('password')
-        if not username or not password:
-            raise AuthenticationFailed("Username and password must be provided to provision a token.")
-        user = authenticate(request=request, username=username, password=password)
-        if user is None:
-            raise AuthenticationFailed("Invalid username/password")
-
-        # Create a new Token for the User
-        token = Token(user=user)
-        token.save()
-        data = serializers.TokenSerializer(token, context={'request': request}).data
-        # Manually append the token key, which is normally write-only
-        data['key'] = token.key
-
-        return Response(data, status=HTTP_201_CREATED)
-
-    def get_serializer_class(self):
-        return serializers.TokenSerializer
+        serializer = serializers.TokenProvisionSerializer(data=request.data, context={'request': request})
+        serializer.is_valid(raise_exception=True)
+        self.perform_create(serializer)
+        return Response(serializer.data, status=HTTP_201_CREATED)
+
+    def perform_create(self, serializer):
+        model = serializer.Meta.model
+        logger = logging.getLogger(f'netbox.api.views.TokenProvisionView')
+        logger.info(f"Creating new {model._meta.verbose_name}")
+        serializer.save()
 
 
 #

+ 10 - 2
netbox/users/tests/test_api.py

@@ -141,17 +141,25 @@ class TokenTest(
         """
         Test the provisioning of a new REST API token given a valid username and password.
         """
-        data = {
+        user_credentials = {
             'username': 'user1',
             'password': 'abc123',
         }
-        user = User.objects.create_user(**data)
+        user = User.objects.create_user(**user_credentials)
+
+        data = {
+            **user_credentials,
+            'description': 'My API token',
+            'expires': '2099-12-31T23:59:59Z',
+        }
         url = reverse('users-api:token_provision')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 201)
         self.assertIn('key', response.data)
         self.assertEqual(len(response.data['key']), 40)
+        self.assertEqual(response.data['description'], data['description'])
+        self.assertEqual(response.data['expires'], data['expires'])
         token = Token.objects.get(user=user)
         self.assertEqual(token.key, response.data['key'])
 

+ 1 - 1
netbox/users/views.py

@@ -68,7 +68,7 @@ class UserView(generic.ObjectView):
     template_name = 'users/user.html'
 
     def get_extra_context(self, request, instance):
-        changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20]
+        changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=instance)[:20]
         changelog_table = ObjectChangeTable(changelog)
 
         return {

+ 8 - 5
netbox/utilities/templatetags/navigation.py

@@ -26,11 +26,14 @@ def nav(context: Context) -> Dict:
         for group in menu.groups:
             items = []
             for item in group.items:
-                if user.has_perms(item.permissions):
-                    buttons = [
-                        button for button in item.buttons if user.has_perms(button.permissions)
-                    ]
-                    items.append((item, buttons))
+                if not user.has_perms(item.permissions):
+                    continue
+                if item.staff_only and not user.is_staff:
+                    continue
+                buttons = [
+                    button for button in item.buttons if user.has_perms(button.permissions)
+                ]
+                items.append((item, buttons))
             if items:
                 groups.append((group, items))
         if groups:

+ 0 - 1
netbox/virtualization/views.py

@@ -397,7 +397,6 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
     template_name = 'virtualization/virtualmachine/render_config.html'
     tab = ViewTab(
         label=_('Render Config'),
-        permission='extras.view_configtemplate',
         weight=2100
     )
 

+ 6 - 6
requirements.txt

@@ -1,5 +1,5 @@
 bleach==6.0.0
-Django==4.2.4
+Django==4.2.5
 django-cors-headers==4.2.0
 django-debug-toolbar==4.2.0
 django-filter==23.2
@@ -12,23 +12,23 @@ django-rich==1.7.0
 django-rq==2.8.1
 django-tables2==2.6.0
 django-taggit==4.0.0
-django-timezone-field==5.1
+django-timezone-field==6.0
 djangorestframework==3.14.0
 drf-spectacular==0.26.4
-drf-spectacular-sidecar==2023.8.1
+drf-spectacular-sidecar==2023.9.1
 feedparser==6.0.10
 graphene-django==3.0.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.2.5
-mkdocstrings[python-legacy]==0.22.0
+mkdocs-material==9.2.7
+mkdocstrings[python-legacy]==0.23.0
 netaddr==0.8.0
 Pillow==10.0.0
 psycopg[binary,pool]==3.1.10
 PyYAML==6.0.1
 sentry-sdk==1.30.0
-social-auth-app-django==5.2.0
+social-auth-app-django==5.3.0
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 tablib==3.5.0