diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 744770180..ec7d667e6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.0 + placeholder: v3.6.1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 5cf9b72ab..dc27ebd26 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.0 + placeholder: v3.6.1 validations: required: true - type: dropdown diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index dc332da74..0914d0aa6 100644 --- a/docs/models/dcim/platform.md +++ b/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. diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 3e7762184..8d7580147 100644 --- a/docs/plugins/development/navigation.md +++ b/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 diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 23fd79ea4..e9e958a9f 100644 --- a/docs/release-notes/version-3.6.md +++ b/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 diff --git a/netbox/core/apps.py b/netbox/core/apps.py index ffcf0b4ea..2d999c57e 100644 --- a/netbox/core/apps.py +++ b/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): diff --git a/netbox/core/management/commands/makemigrations.py b/netbox/core/management/commands/makemigrations.py index 10874418a..ce40bd3cc 100644 --- a/netbox/core/management/commands/makemigrations.py +++ b/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): diff --git a/netbox/core/management/commands/migrate.py b/netbox/core/management/commands/migrate.py deleted file mode 100644 index 8d5e45a40..000000000 --- a/netbox/core/management/commands/migrate.py +++ /dev/null @@ -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 diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 8e372c2eb..54a43c7ef 100644 --- a/netbox/core/models/data.py +++ b/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 diff --git a/netbox/core/views.py b/netbox/core/views.py index c7c593770..e3c1a67aa 100644 --- a/netbox/core/views.py +++ b/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 + ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 2f4eb6581..b43611dad 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer): ] -class DeviceNAPALMSerializer(serializers.Serializer): - method = serializers.JSONField() - - # # Device components # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4377e9ee8..2f661e613 100644 --- a/netbox/dcim/views.py +++ b/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) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 4da5fa629..e007db43d 100644 --- a/netbox/extras/api/serializers.py +++ b/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) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index ac68855a0..0c4a0c615 100644 --- a/netbox/extras/models/customfields.py +++ b/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.")) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 91940d66e..90e8027b4 100644 --- a/netbox/extras/models/models.py +++ b/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): diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py index 288a78512..2075c97b6 100644 --- a/netbox/extras/plugins/navigation.py +++ b/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.") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 019aef235..a8153e1bb 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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): diff --git a/netbox/ipam/api/field_serializers.py b/netbox/ipam/api/field_serializers.py index d44d8b7d4..d12530a60 100644 --- a/netbox/ipam/api/field_serializers.py +++ b/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) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c2cf38fe7..6882de56d 100644 --- a/netbox/ipam/api/serializers.py +++ b/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'] # diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 553f5eb92..89977704a 100644 --- a/netbox/ipam/models/ip.py +++ b/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 diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8be5c97a9..31c4f693a 100644 --- a/netbox/netbox/config/parameters.py +++ b/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', diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index a05b1c495..4c7190bbb 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/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]] = () diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 5c7502a03..5b64cfc1e 100644 --- a/netbox/netbox/navigation/menu.py +++ b/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 ), ), ), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3d9df0e1d..75099a029 100644 --- a/netbox/netbox/settings.py +++ b/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 = { diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html new file mode 100644 index 000000000..a80dcfea8 --- /dev/null +++ b/netbox/templates/dcim/component_list.html @@ -0,0 +1,22 @@ +{% extends 'generic/object_list.html' %} +{% load buttons %} +{% load helpers %} +{% load i18n %} + +{% block bulk_buttons %} +
+ {% 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" %} + + {% endwith %} + {% endif %} +
+ {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index a974f9f93..29f405b6e 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -44,17 +44,6 @@ {% trans "Config Template" %} {{ object.config_template|linkify|placeholder }} - - - {% trans "NAPALM Driver" %} - - - diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html index 5937e842a..4f2abf30b 100644 --- a/netbox/templates/extras/configrevision.html +++ b/netbox/templates/extras/configrevision.html @@ -14,11 +14,11 @@
{% 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 %}
@@ -28,6 +28,14 @@
{% endblock controls %} +{% block subtitle %} + {% if object.created %} +
+ {% trans "Created" %} {{ object.created|annotated_date }} +
+ {% endif %} +{% endblock subtitle %} + {% block content %}
diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index fe03f41ed..18c07c1cc 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -32,7 +32,7 @@ {% trans "Active" %} - {% checkmark object.active %} + {% checkmark object.is_active %} {% trans "Staff" %} diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1f4bf4ea0..75ab877cf 100644 --- a/netbox/users/api/serializers.py +++ b/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): diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 9cf5b1ac5..62a32c71b 100644 --- a/netbox/users/api/views.py +++ b/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() + 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) - # 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 + 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() # diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 859dd0b83..001142410 100644 --- a/netbox/users/tests/test_api.py +++ b/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']) diff --git a/netbox/users/views.py b/netbox/users/views.py index 7ff9a8a4d..2e7a47c12 100644 --- a/netbox/users/views.py +++ b/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 { diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index 4a229e952..7534d7034 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/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: diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index cbe953040..173d7047b 100644 --- a/netbox/virtualization/views.py +++ b/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 ) diff --git a/requirements.txt b/requirements.txt index b313f98d6..54f1334ed 100644 --- a/requirements.txt +++ b/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